Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbb7d4fe24 | ||
|
|
5c162bf6c5 | ||
|
|
8451607f6c | ||
|
|
c148912b73 | ||
|
|
3464f35a01 | ||
|
|
b3ab87f22c | ||
|
|
b9b930936d | ||
|
|
392f94e95c | ||
|
|
4595dfef34 | ||
|
|
0137fd3a4c | ||
|
|
a9dc19a86b | ||
|
|
22242701e4 | ||
|
|
ca7095af4c | ||
|
|
04b099249e |
@@ -50,13 +50,13 @@ describe('adminNavigation', () => {
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '财务管理', children: ['海外费用明细', '客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(59)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: PrototypeOverseasBusinessIcon,
|
||||
children: [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||
{ key: 'overseas-fees', title: '海外费用管理', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
|
||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
@@ -158,7 +157,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: PrototypeCustomerServiceIcon,
|
||||
children: [
|
||||
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -166,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '财务管理',
|
||||
icon: PrototypeFinanceIcon,
|
||||
children: [
|
||||
{ key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
|
||||
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
|
||||
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
|
||||
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
|
||||
|
||||
@@ -94,7 +94,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
|
||||
<template>
|
||||
<section ref="dashboardRef" class="dashboard-view admin-workbench">
|
||||
<AdminPage as="div" spacing="normal" class="admin-density admin-workbench">
|
||||
<AdminPage as="div" spacing="compact" class="admin-density admin-workbench">
|
||||
<section class="dashboard-context" aria-label="数据看板操作">
|
||||
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
@@ -128,6 +128,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
|
||||
<style scoped>
|
||||
.dashboard-view { display: block; min-width: 0; }
|
||||
.dashboard-view > .admin-page.is-spacing-compact { gap: var(--admin-space-2); }
|
||||
.dashboard-view:fullscreen { overflow: auto; background: var(--workbench-surface); }
|
||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--workbench-section-gap); }
|
||||
.dashboard-head-actions { display: flex; margin-left: auto; align-items: center; gap: 12px; white-space: nowrap; }
|
||||
|
||||
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createRequire } from 'node:module'
|
||||
import DomesticContainersView from './DomesticContainersView.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import * as api from '../api/domesticContainers'
|
||||
import type { InventoryRecord } from '../types'
|
||||
import type { InventoryPage, InventoryRecord } from '../types'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
// Use Node's supported require export so the validator retains its constructor interop.
|
||||
@@ -31,11 +34,16 @@ describe('DomesticContainersView', () => {
|
||||
})
|
||||
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
|
||||
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
|
||||
it('keeps all four inventory facts in the approved compact summary', () => {
|
||||
for (const fact of ['summary.inboundBoxes', 'summary.outboundBoxes', 'summary.availableBoxes', 'summary.inboundVolumeCbm']) expect(source).toContain(fact)
|
||||
it('keeps all four inventory facts and units in shared compact metric cards', () => {
|
||||
for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
|
||||
expect(source).toContain("import './domesticContainersSummary.css'")
|
||||
expect(summaryStyles).toContain('display: flex; flex-wrap: wrap')
|
||||
expect(summaryStyles).toContain('font-size: var(--admin-font-section, 14px)')
|
||||
const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
|
||||
expect(summary).not.toContain('v-if=')
|
||||
expect(summary.match(/class="workbench-metric workbench-tone is-compact"/g)).toHaveLength(4)
|
||||
expect(summary.match(/<span>箱<\/span>/g)).toHaveLength(3)
|
||||
expect(summary).toContain("summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span>")
|
||||
expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
||||
expect(summaryStyles).toContain('gap: var(--workbench-grid-gap)')
|
||||
expect(summaryStyles).not.toContain('position: absolute')
|
||||
})
|
||||
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
|
||||
@@ -49,10 +57,14 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
|
||||
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
|
||||
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
|
||||
let mounted: VueWrapper | undefined
|
||||
async function openCreate(label: string) {
|
||||
async function mountView() {
|
||||
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
mounted = view
|
||||
await flushPromises()
|
||||
return view
|
||||
}
|
||||
async function openCreate(label: string) {
|
||||
const view = await mountView()
|
||||
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
|
||||
await flushPromises()
|
||||
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
|
||||
@@ -64,7 +76,7 @@ async function submitCreate() {
|
||||
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
describe('DomesticContainersView create workflows', () => {
|
||||
describe('DomesticContainersView workflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
@@ -75,6 +87,60 @@ describe('DomesticContainersView create workflows', () => {
|
||||
})
|
||||
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
|
||||
|
||||
it('loads the overview on entry and keeps it above the list across all three tabs', async () => {
|
||||
const view = await mountView()
|
||||
const overview = view.get('.inventory-summary')
|
||||
expect(view.element.firstElementChild).toBe(overview.element)
|
||||
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
||||
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(1)
|
||||
for (const label of ['整柜出库单', '整柜库存', '整柜入库单']) {
|
||||
const requests = vi.mocked(api.listDomesticContainerInventories).mock.calls.length
|
||||
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.element.firstElementChild).toBe(overview.element)
|
||||
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
||||
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(requests + 1)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['整柜入库单', '整柜出库单'])('uses common filters for the overview on %s without passing order-only filters', async label => {
|
||||
const view = await mountView()
|
||||
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
const filters = { warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', cargoName: '测试货物', orderNo: 'ORDER-01', status: 'PENDING' }
|
||||
view.findComponent(QueryToolbar).vm.$emit('search', filters)
|
||||
await flushPromises()
|
||||
expect(api.listDomesticContainerInventories).toHaveBeenLastCalledWith({ page: 1, size: 1, warehouseId: filters.warehouseId, containerNo: filters.containerNo, cargoName: filters.cargoName })
|
||||
const list = label === '整柜入库单' ? api.listDomesticContainerInbounds : api.listDomesticContainerOutbounds
|
||||
expect(list).toHaveBeenLastCalledWith({ ...filters, page: 1, size: 20 })
|
||||
})
|
||||
|
||||
it('shows unknown summary values on failure while keeping the order list available and allowing retry', async () => {
|
||||
vi.mocked(api.listDomesticContainerInventories).mockRejectedValueOnce(new Error('统计失败'))
|
||||
const view = await mountView()
|
||||
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(view.findComponent(DataTable).props('resultsVisible')).toBe(true)
|
||||
const error = view.findComponent(LoadErrorState)
|
||||
expect(error.props('title')).toBe('整柜库存统计加载失败')
|
||||
error.vm.$emit('retry')
|
||||
await flushPromises()
|
||||
expect(view.findComponent(LoadErrorState).exists()).toBe(false)
|
||||
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
||||
})
|
||||
|
||||
it('ignores a stale overview response after switching tabs', async () => {
|
||||
let resolveFirst!: (value: InventoryPage) => void
|
||||
vi.mocked(api.listDomesticContainerInventories).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
||||
const view = await mountView()
|
||||
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('true')
|
||||
await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
|
||||
await flushPromises()
|
||||
resolveFirst({ records: [], page: 1, size: 1, total: 0, pages: 0, summary: { inboundBoxes: 99, outboundBoxes: 0, availableBoxes: 99, inboundVolumeCbm: 99 } })
|
||||
await flushPromises()
|
||||
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
|
||||
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
|
||||
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
|
||||
const { form } = await openCreate('新建入库单')
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Box, Boxes, PackageCheck, PackagePlus, Plus } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -28,7 +28,10 @@ const businessTabs: Array<{ value: TabName; label: string }> = [
|
||||
{ value: 'inventory', label: '整柜库存' },
|
||||
]
|
||||
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
|
||||
const summary = ref<InventorySummary | null>(null)
|
||||
const summaryLoading = ref(false)
|
||||
const summaryError = ref<ApiErrorDetails | null>(null)
|
||||
let loadSequence = 0
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
|
||||
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||
@@ -49,7 +52,41 @@ function displayTime(value: string) { return value ? value.replace('T', ' ').sli
|
||||
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
|
||||
function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' }
|
||||
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
|
||||
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } }
|
||||
async function load() {
|
||||
const sequence = ++loadSequence
|
||||
const tab = activeTab.value
|
||||
const params = queryParams()
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
summaryLoading.value = true
|
||||
summaryError.value = null
|
||||
summary.value = null
|
||||
const inventoryRequest = listDomesticContainerInventories(tab === 'inventory' ? params : {
|
||||
page: 1,
|
||||
size: 1,
|
||||
warehouseId: params.warehouseId,
|
||||
containerNo: params.containerNo,
|
||||
cargoName: params.cargoName,
|
||||
})
|
||||
const summaryTask = inventoryRequest.then(result => {
|
||||
if (sequence === loadSequence) summary.value = result.summary
|
||||
}).catch(cause => {
|
||||
if (sequence === loadSequence && tab !== 'inventory') summaryError.value = getApiErrorDetails(cause, '整柜库存统计加载失败')
|
||||
}).finally(() => {
|
||||
if (sequence === loadSequence) summaryLoading.value = false
|
||||
})
|
||||
const listRequest = tab === 'inbound' ? listDomesticContainerInbounds(params) : tab === 'outbound' ? listDomesticContainerOutbounds(params) : inventoryRequest
|
||||
const listTask = listRequest.then(result => {
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
}).catch(cause => {
|
||||
if (sequence === loadSequence) loadError.value = getApiErrorDetails(cause, '整柜业务加载失败')
|
||||
}).finally(() => {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
})
|
||||
await Promise.all([summaryTask, listTask])
|
||||
}
|
||||
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
|
||||
function search(model = filters.value) { appliedFilters.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function switchTab(name: TabName) { if (activeTab.value === name) return; activeTab.value = name; filters.value = { warehouseId: filters.value.warehouseId || '', orderNo: '', containerNo: '', cargoName: '', status: '' }; appliedFilters.value = { ...filters.value }; page.value = 1; void load() }
|
||||
@@ -73,13 +110,31 @@ async function submit() { if (!await formRef.value?.validate().catch(() => false
|
||||
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
|
||||
</script>
|
||||
|
||||
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
|
||||
<template><AdminPage as="div" spacing="compact" class="domestic-containers-page admin-density admin-workbench">
|
||||
<section v-loading="summaryLoading" class="inventory-summary" aria-label="整柜库存概览" :aria-busy="summaryLoading">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackagePlus aria-hidden="true" /></span><h2 class="workbench-metric-label">入库箱数</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary?.inboundBoxes ?? '—' }}</strong><span>箱</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已出库箱数</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary?.outboundBoxes ?? '—' }}</strong><span>箱</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Boxes aria-hidden="true" /></span><h2 class="workbench-metric-label">当前库存</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary?.availableBoxes ?? '—' }}</strong><span>箱</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="teal">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Box aria-hidden="true" /></span><h2 class="workbench-metric-label">累计入库体积</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<LoadErrorState v-if="summaryError" compact title="整柜库存统计加载失败" :message="summaryError.message" :loading="summaryLoading" @retry="load"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
|
||||
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
|
||||
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></div></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog admin-workbench is-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit">
|
||||
<LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
|
||||
@@ -122,4 +177,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
</el-form>
|
||||
</AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--workbench-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--workbench-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--workbench-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
<style scoped>
|
||||
.admin-workbench.domestic-containers-page.admin-page.is-spacing-compact { gap: 0; }
|
||||
.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
.domestic-containers-page .table-panel .inventory-summary {
|
||||
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; margin: 0;
|
||||
padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 0; border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff);
|
||||
.domestic-containers-page .inventory-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--workbench-grid-gap);
|
||||
}
|
||||
.domestic-containers-page .table-panel .inventory-summary > div {
|
||||
display: flex; align-items: baseline; min-width: 0; gap: var(--admin-space-3, 8px); padding: 0; border: 0;
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.domestic-containers-page .inventory-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.domestic-containers-page .inventory-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.domestic-containers-page .table-panel .inventory-summary span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.domestic-containers-page .table-panel .inventory-summary strong { font-size: var(--admin-font-section, 14px); font-weight: 600; }
|
||||
|
||||
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
|
||||
import { ArrowLeft, CircleCheck, Download, RefreshCw } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -448,12 +448,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="detail" #summary>
|
||||
<dl class="density-detail-overview">
|
||||
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
|
||||
<div><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
@@ -462,9 +456,11 @@ onMounted(() => {
|
||||
<template v-else-if="detail">
|
||||
<section class="version-panel">
|
||||
<header class="section-heading">
|
||||
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div>
|
||||
<div><h2>版本信息</h2></div>
|
||||
</header>
|
||||
<dl class="detail-grid density-fields">
|
||||
<div class="detail-grid__half"><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
|
||||
<div class="detail-grid__half"><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
|
||||
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
|
||||
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
|
||||
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
|
||||
@@ -475,7 +471,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section v-if="isPending && canEdit" class="mapping-section">
|
||||
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
|
||||
<header class="section-heading"><div><h2>仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
|
||||
<el-alert
|
||||
v-if="nonMappingBlockingIssues.length"
|
||||
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
|
||||
@@ -514,37 +510,39 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
|
||||
<header class="section-heading"><div><h2>解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
|
||||
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
|
||||
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
|
||||
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="issueCode" label="编码" width="270" />
|
||||
<el-table-column prop="message" label="说明" min-width="320" />
|
||||
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="detail-section channel-preview">
|
||||
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header>
|
||||
<header class="section-heading"><div><h2>按渠道预览</h2></div></header>
|
||||
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
|
||||
<template v-else>
|
||||
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs">
|
||||
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label><span class="density-tab-icon" aria-hidden="true">🚢</span>{{ channel.channelName }}</template></el-tab-pane>
|
||||
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label>{{ channel.channelName }}</template></el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div>
|
||||
<dl class="channel-facts density-fields" aria-label="渠道信息">
|
||||
<div><dt>渠道</dt><dd>{{ selectedChannel?.channelName || '—' }}</dd></div>
|
||||
<div><dt>计价方式</dt><dd>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</dd></div>
|
||||
<div><dt>来源表页</dt><dd>{{ selectedChannel?.sourceSheetName || '—' }}</dd></div>
|
||||
<div><dt>参考时效</dt><dd>{{ selectedChannel?.referenceTransit || '—' }}</dd></div>
|
||||
<div><dt>最晚进仓时间</dt><dd>{{ selectedChannel?.latestWarehouseEntry || '—' }}</dd></div>
|
||||
<div><dt>船期</dt><dd>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</dd></div>
|
||||
<div class="channel-facts__wide"><dt>附加费</dt><dd>{{ selectedChannel?.surchargeDescription || '—' }}</dd></div>
|
||||
<div class="channel-facts__wide"><dt>备注</dt><dd>{{ selectedChannel?.remark || '—' }}</dd></div>
|
||||
</dl>
|
||||
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
|
||||
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="originWarehouseName" label="起运仓" min-width="180" />
|
||||
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
|
||||
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
|
||||
<el-table-column prop="chargeUnit" label="单位" width="78" />
|
||||
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
|
||||
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
|
||||
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
|
||||
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
|
||||
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
|
||||
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
|
||||
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
@@ -555,23 +553,24 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.price-book-detail { min-width: 0; max-width: 1880px; }
|
||||
.version-panel, .mapping-section, .detail-section { padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
|
||||
.price-book-detail { min-width: 0; }
|
||||
.price-book-detail :deep(.density-detail-layout > .detail-page-layout__content) { padding: 0; }
|
||||
.price-book-detail .channel-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); --admin-field-label-width: 110px; }
|
||||
.channel-facts .channel-facts__wide { grid-column: 1 / -1; }
|
||||
.detail-grid dd, .channel-facts dd { overflow-wrap: anywhere; }
|
||||
.version-panel, .mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
|
||||
.detail-section:last-child { border-bottom: 0; }
|
||||
.price-book-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.detail-grid .detail-grid__half { grid-column: span 2; }
|
||||
.detail-grid .detail-grid__wide { grid-column: 1 / -1; }
|
||||
.section-heading > div { min-width: 0; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin: 0; }
|
||||
.section-heading h2 { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
|
||||
.section-heading h2 svg { color: var(--workbench-primary); }
|
||||
.section-heading p { overflow-wrap: anywhere; margin: 5px 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.55; }
|
||||
.is-danger { color: var(--el-color-danger) !important; }
|
||||
.mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.mapping-section .section-heading, .detail-section .section-heading { margin-bottom: 0; }
|
||||
.mapping-groups { display: grid; gap: var(--admin-space-5); }
|
||||
.mapping-group { display: grid; gap: var(--admin-space-3); }
|
||||
.mapping-group h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); }
|
||||
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
|
||||
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
|
||||
.mapping-row > div { display: grid; min-width: 0; gap: var(--admin-space-1); }
|
||||
.mapping-row strong { overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-body); }
|
||||
.mapping-row span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
@@ -580,8 +579,8 @@ onMounted(() => {
|
||||
.mapping-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
|
||||
.mapping-load-error p { margin: var(--admin-space-1) 0; color: var(--workbench-text); font-size: var(--admin-font-caption); }
|
||||
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
|
||||
.channel-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
|
||||
.channel-meta { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); margin: calc(-1 * var(--admin-space-1)) 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
|
||||
.detail-toolbar { width: 100%; }
|
||||
|
||||
@@ -56,8 +56,8 @@ describe('CarrierRatesView price-book source contract', () => {
|
||||
})
|
||||
|
||||
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
|
||||
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
|
||||
expect(detailSource).toContain(`label="${label}"`)
|
||||
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注']) {
|
||||
expect(detailSource).toContain(`<dt>${label}</dt>`)
|
||||
}
|
||||
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
|
||||
expect(detailSource).toContain(`selectedChannel?.${field}`)
|
||||
|
||||
@@ -82,15 +82,15 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
|
||||
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
|
||||
{ key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
|
||||
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' },
|
||||
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
{ key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
|
||||
{ key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
|
||||
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
|
||||
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 290, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
|
||||
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 260, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
|
||||
]
|
||||
|
||||
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
|
||||
@@ -382,7 +382,7 @@ onMounted(() => {
|
||||
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
|
||||
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
|
||||
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
|
||||
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
|
||||
<template #contents="{ row }"><div class="metric-cell"><span><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong> 渠道</span><span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong> 价格行</span></div></template>
|
||||
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
|
||||
<template #empty><el-empty description="暂无船期价格表版本" /></template>
|
||||
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
|
||||
@@ -423,6 +423,7 @@ onMounted(() => {
|
||||
.version-cell strong { color: var(--workbench-text); }
|
||||
.version-cell span, .metric-cell span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); }
|
||||
.metric-cell > span, .issue-counts > span { white-space: nowrap; }
|
||||
.metric-cell strong { color: var(--workbench-text); font-size: var(--admin-font-body); }
|
||||
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 |
|
||||
|---|---|---|---|
|
||||
| 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” |
|
||||
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 沿用现有 620px 抽屉与表单,增加可空默认价格及说明 |
|
||||
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 2026-10-27 用户确认改为居中弹框(760px,受视口宽度限制),三个分区使用白底和中性边框;保留可空默认价格、字段分组及保存行为 |
|
||||
| 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 |
|
||||
| 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 |
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FeeItemsView from './FeeItemsView.vue'
|
||||
import * as api from '../api/platformFeeItems'
|
||||
|
||||
// Preserve the validator constructor interop through Element Plus's supported Node export.
|
||||
const { default: ElementPlus, ElCheckbox, ElDrawer, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const { default: ElementPlus, ElCheckbox, ElDialog, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
|
||||
vi.mock('../api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), getPlatformFeeItem: vi.fn(), createPlatformFeeItem: vi.fn(), updatePlatformFeeItem: vi.fn(), changePlatformFeeItemStatus: vi.fn(), deletePlatformFeeItem: vi.fn() }))
|
||||
let record: api.PlatformFeeItemResource
|
||||
@@ -28,7 +27,7 @@ async function openEditor(create = false) {
|
||||
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
|
||||
}
|
||||
async function setText(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-drawer [placeholder="${placeholder}"]`)!
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
|
||||
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
}
|
||||
async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) {
|
||||
@@ -51,12 +50,14 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('fee item editing and confirmations', () => {
|
||||
it('creates from the original compact drawer with a zero default and all direction flags', async () => {
|
||||
it('creates from a centered white dialog with a zero default and all direction flags', async () => {
|
||||
const wrapper = await openEditor(true)
|
||||
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' })
|
||||
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true)
|
||||
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3)
|
||||
expect(document.querySelector('.el-dialog')).toBeNull()
|
||||
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '新增费用项目', width: 'min(760px, 94vw)' })
|
||||
expect(document.querySelector('.el-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
|
||||
expect(document.querySelectorAll('.el-dialog .density-dialog-section')).toHaveLength(3)
|
||||
expect(document.querySelector('.fee-item-editor .density-dialog-section[data-tone]')).toBeNull()
|
||||
expect(wrapper.findComponent(ElDialog).props('alignCenter')).toBe(true)
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
|
||||
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true)
|
||||
@@ -64,33 +65,33 @@ describe('fee item editing and confirmations', () => {
|
||||
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
|
||||
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
|
||||
await setText('请输入适用场景或核算说明', ' 新说明 ')
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).toHaveBeenCalledWith({ category: '操作费', tradeScope: 'DOMESTIC', feeTypeName: '新增操作费', defaultPrice: 0, supplierCategory: 'TRUCKING', pickupPayable: true, pickupReceivable: true, warehousePayable: true, warehouseReceivable: true, allocationFee: true, description: '新说明' })
|
||||
expect(useUnsavedWorkStore().entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('validates required fields and payable supplier category without losing entered values', async () => {
|
||||
const wrapper = await openEditor(true)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-drawer')?.textContent).toContain('请输入费用名称'))
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入费用名称'))
|
||||
await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
|
||||
await check(wrapper, '提货环节应付', true)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用')
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('新费用')
|
||||
})
|
||||
|
||||
it('edits the same long ID and clears the supplier payload when payable directions are removed', async () => {
|
||||
record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 }
|
||||
const wrapper = await openEditor()
|
||||
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费')
|
||||
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId)
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑费用项目 · 测试操作费')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(privateId)
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
|
||||
await check(wrapper, '提货环节应付', false)
|
||||
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull()
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-dialog [placeholder="请选择供应商类别"]')).toBeNull()
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false }))
|
||||
})
|
||||
|
||||
@@ -99,17 +100,17 @@ describe('fee item editing and confirmations', () => {
|
||||
vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
|
||||
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true)
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
rejectSave(new Error('保存失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
|
||||
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unsaved registration and the existing permission-change closure', async () => {
|
||||
@@ -118,7 +119,7 @@ describe('fee item editing and confirmations', () => {
|
||||
await setText('请输入费用名称', '权限变化前的修改')
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
|
||||
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(useUnsavedWorkStore().entries).toHaveLength(0)
|
||||
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.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 InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -430,22 +429,22 @@ onMounted(() => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--editor admin-workbench is-drawer"
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog fee-item-editor"
|
||||
v-model="editorOpen"
|
||||
:title="editorTitle"
|
||||
size="min(620px, 94vw)"
|
||||
width="min(760px, 94vw)" align-center
|
||||
:loading="editorLoading"
|
||||
:confirm-loading="saving"
|
||||
>
|
||||
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用基本信息">
|
||||
<el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="费用基本信息">
|
||||
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable fee-item-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" label-width="120px" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="选填" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用方向与供应商">
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="费用方向与供应商">
|
||||
<h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody>
|
||||
<tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr>
|
||||
@@ -454,7 +453,7 @@ onMounted(() => {
|
||||
<tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用适用说明">
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="费用适用说明">
|
||||
<h3 class="density-dialog-section__title">适用说明</h3>
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table>
|
||||
</section>
|
||||
@@ -465,7 +464,7 @@ onMounted(() => {
|
||||
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
|
||||
v-model="statusOpen"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
.admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); }
|
||||
.admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; }
|
||||
.admin-density .settlement-summary.density-detail-overview { padding: 0; border: 0; }
|
||||
.admin-density .statement-summary-section { padding: var(--admin-space-5); }
|
||||
.admin-density .account-metrics .is-negative,
|
||||
.admin-density .density-detail-overview .is-danger,
|
||||
@@ -15,3 +14,32 @@
|
||||
.admin-density .density-detail-overview .is-success,
|
||||
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
|
||||
.admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); }
|
||||
|
||||
/* Outrank AdminPage scoped spacing regardless of stylesheet load order; the toolbar supplies the gap. */
|
||||
.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,
|
||||
.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }
|
||||
.admin-workbench .finance-metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
|
||||
@media (max-width: 720px) {
|
||||
.admin-workbench .finance-metric-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* The outer AdminPage owns the screen inset for every finance detail section. */
|
||||
@media screen {
|
||||
.admin-density .detail-page-layout.finance-density-detail > :is(.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer),
|
||||
.admin-density .finance-density-detail > .detail-page-layout__header > .density-detail-header,
|
||||
.admin-density .finance-density-detail > .detail-page-layout__summary > :is(.density-detail-overview, .statement-summary-section),
|
||||
.admin-density .finance-density-detail > .detail-page-layout__content > .settlement-detail-content {
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Detail sections share one vertical rhythm instead of stacking section padding. */
|
||||
.admin-density .finance-density-detail > .detail-page-layout__content,
|
||||
.admin-density .finance-density-detail .settlement-detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--admin-space-5);
|
||||
}
|
||||
.admin-density .finance-density-detail > .detail-page-layout__content .density-form-section {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(page).toContain('v-else-if="!detailOpen"')
|
||||
expect(page).toContain('返回客户账户')
|
||||
expect(page).toContain('<DetailPageLayout')
|
||||
expect(page).toContain('class="density-detail-overview account-metrics"')
|
||||
expect(page).toContain('class="account-metrics"')
|
||||
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
|
||||
expect(page).toContain('class="ledger-query workbench-query"')
|
||||
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
|
||||
@@ -83,18 +83,18 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
|
||||
it('keeps term editing aligned with the account summary and nearby actions', () => {
|
||||
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
|
||||
expect(page).toContain('density-detail-overview account-metrics')
|
||||
expect(page).toContain('account-metrics')
|
||||
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column)
|
||||
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
|
||||
expect(page).toContain('justify-content: start')
|
||||
expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"')
|
||||
expect(page).toContain('class="density-detail-header"')
|
||||
expect(page).toContain('padding: var(--admin-space-4) 0')
|
||||
expect(page).toContain('class="detail-table-scroll workbench-table"')
|
||||
expect(page).toContain('class="detail-table-scroll workbench-table is-page-surface"')
|
||||
expect(page).toContain('class="ledger-identifier"')
|
||||
expect(page).toContain('class="ledger-document"')
|
||||
expect(page).toContain('text-overflow: ellipsis')
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
expect(page).toContain('overflow-wrap: anywhere')
|
||||
expect(page).not.toContain('text-overflow: ellipsis')
|
||||
})
|
||||
|
||||
it('keeps account detail and term editing readable across the 1920 workspace', () => {
|
||||
@@ -186,7 +186,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('客户A')
|
||||
expect(wrapper.text()).toContain('C-001')
|
||||
const profile = wrapper.find('.customer-profile-section')
|
||||
expect(profile.text()).toContain('客户名称客户A')
|
||||
expect(profile.text()).not.toContain('客户编号')
|
||||
expect(profile.text()).not.toContain('C-001')
|
||||
expect(wrapper.text()).toContain('张三')
|
||||
expect(wrapper.text()).toContain('13800000000')
|
||||
const metrics = wrapper.find('.account-metrics')
|
||||
@@ -194,7 +197,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
|
||||
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
|
||||
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
|
||||
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期')
|
||||
expect(metrics.text()).toContain('授信额度¥20000.00 CNY2028-09-10 到期')
|
||||
expect(metrics.text()).toContain('已用授信¥0.00 CNY')
|
||||
expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
|
||||
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
|
||||
import { ArrowLeft, CircleCheck, CreditCard, Download, LockKeyhole, MinusCircle, PlusCircle, Save, Wallet } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
@@ -100,13 +100,13 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
|
||||
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
|
||||
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
|
||||
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' },
|
||||
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
|
||||
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' },
|
||||
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' },
|
||||
{ key: 'actualBalance', label: '实际余额(CNY)', align: 'right', width: 155, slotName: 'actualBalance' },
|
||||
{ key: 'frozenBalance', label: '冻结金额(CNY)', align: 'right', width: 150, slotName: 'frozenBalance' },
|
||||
{ key: 'availableBalance', label: '总可用余额(CNY)', align: 'right', width: 155, slotName: 'availableBalance' },
|
||||
{ key: 'creditLimit', label: '授信额度(CNY)', align: 'right', width: 155, slotName: 'creditLimit' },
|
||||
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
|
||||
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' },
|
||||
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
|
||||
{ key: 'creditUsed', label: '已用授信(CNY)', align: 'right', width: 150, slotName: 'creditUsed' },
|
||||
{ key: 'pendingRechargeCount', label: '待审核充值', align: 'right', width: 120, slotName: 'pendingRechargeCount' },
|
||||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
||||
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
|
||||
]
|
||||
@@ -123,18 +123,18 @@ const ledgerQueryFields: QueryField[] = [
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
|
||||
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
|
||||
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, align: 'right', slotName: 'amount' },
|
||||
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
|
||||
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' },
|
||||
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' },
|
||||
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' },
|
||||
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' },
|
||||
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, align: 'right', slotName: 'actualBalanceAfter' },
|
||||
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, align: 'right', slotName: 'frozenBalanceAfter' },
|
||||
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, align: 'right', slotName: 'availableBalanceAfter' },
|
||||
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, align: 'right', slotName: 'creditUsedAfter' },
|
||||
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const rechargeColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
|
||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, align: 'right', slotName: 'amount' },
|
||||
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
|
||||
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
|
||||
@@ -516,18 +516,43 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
|
||||
<AdminPage v-else as="div" class="finance-account-detail admin-density admin-workbench is-embedded" spacing="normal">
|
||||
<section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface">
|
||||
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
|
||||
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></div></div></template>
|
||||
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额">
|
||||
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">实际余额</h2></div>
|
||||
<div class="workbench-metric-value"><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong> <span>CNY</span></div>
|
||||
</article>
|
||||
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><LockKeyhole aria-hidden="true" /></span><h2 class="workbench-metric-label">冻结金额</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(selected.frozenBalance) }}</strong> <span>CNY</span></div>
|
||||
</article>
|
||||
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">总可用余额</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(selected.availableBalance) }}</strong> <span>CNY</span></div>
|
||||
</article>
|
||||
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">授信额度</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(selected.creditLimit) }}</strong> <span>CNY</span></div><div class="workbench-note" :class="{ 'credit-expired': selected.creditExpiresOn && !selected.creditActive }">{{ creditExpiryLabel(selected) }}</div>
|
||||
</article>
|
||||
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">已用授信</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(selected.creditUsed) }}</strong> <span>CNY</span></div>
|
||||
</article>
|
||||
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">可用授信</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(selected.availableCredit) }}</strong> <span>CNY</span></div>
|
||||
</article>
|
||||
</section></template>
|
||||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||||
<template v-else-if="selected">
|
||||
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
|
||||
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions account-profile" :column="3"><el-descriptions-item label="客户名称">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
|
||||
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
|
||||
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
|
||||
<div v-else class="detail-table-scroll workbench-table"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
|
||||
<div v-else class="detail-table-scroll workbench-table is-page-surface"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
|
||||
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
|
||||
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
|
||||
@@ -535,8 +560,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</DataTable></div>
|
||||
</FormSection>
|
||||
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
|
||||
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
|
||||
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
<div class="detail-table-scroll workbench-table is-page-surface"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" appearance="integrated" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #filters><QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /></template><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
</FormSection>
|
||||
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
|
||||
</template>
|
||||
@@ -670,27 +694,26 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.ledger-section {
|
||||
padding: var(--admin-space-4) 0 0;
|
||||
.account-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: var(--admin-space-5);
|
||||
padding-block: var(--admin-space-3) 0;
|
||||
}
|
||||
|
||||
.recharge-section {
|
||||
padding: var(--admin-space-4) 0 0;
|
||||
border-bottom: 1px solid var(--workbench-border);
|
||||
.account-metrics .workbench-metric-value > .is-negative,
|
||||
.account-metrics .credit-expired {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.account-profile :deep(.el-descriptions__table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.recharge-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-query {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
:deep(.ledger-query.query-toolbar) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -702,9 +725,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
.ledger-identifier,
|
||||
.ledger-document {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -761,12 +782,6 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.account-metric:nth-child(3),
|
||||
.account-metric:nth-child(5) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__surface {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -795,16 +810,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-metric,
|
||||
.account-metric:first-child,
|
||||
.account-metric:nth-child(3),
|
||||
.account-metric:nth-child(5) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.account-metric + .account-metric {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -127,6 +127,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([['PAYABLE_BILLED', '已生成应付单'], ['PARTIALLY_PAID', '部分付款'], ['PAID', '已付款']])(
|
||||
'shows payable progress and prevents editing occupied or paid sources: %s', async (status, label) => {
|
||||
const row = { ...feeSummary, direction: 'PAYABLE', sourceSystem: 'FINANCE', sourceType: 'MANUAL',
|
||||
reviewStatus: 'APPROVED', sourceStatus: status, status, statementGenerated: false } as FinanceFeeSourceSummary
|
||||
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [row], total: 1, pages: 1 })
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
|
||||
await loadRows(wrapper)
|
||||
const body = wrapper.get('.el-table__body-wrapper')
|
||||
expect(body.text()).toContain(label)
|
||||
expect(body.text()).not.toContain('可结算')
|
||||
expect(body.text()).not.toContain('未生成')
|
||||
expect(body.findAll('button').map(button => button.text().trim())).toEqual(['详情'])
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, ...row })
|
||||
await body.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.arap-metrics').text()).toContain(label)
|
||||
expect(wrapper.get('.arap-information').text()).toContain('不适用')
|
||||
},
|
||||
)
|
||||
|
||||
it('shows detail from the list permission but keeps G2 operations independent', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'])
|
||||
await loadRows(wrapper)
|
||||
@@ -253,7 +273,9 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(wrapper.find('.arap-metrics').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
|
||||
expect(wrapper.find('.arap-information').text()).toContain('单据信息')
|
||||
expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算')
|
||||
expect(wrapper.find('.arap-settlement').exists()).toBe(false)
|
||||
expect(wrapper.find('.arap-information').text()).toContain('已生成对账单')
|
||||
expect(wrapper.find('.arap-information').text()).toContain('审核原因')
|
||||
expect(wrapper.find('.operation-log').text()).toContain('操作记录')
|
||||
expect(wrapper.find('.arap-information').text()).toContain('业务日期')
|
||||
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
|
||||
@@ -262,8 +284,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
it('keeps the fee-source detail readable across the 1920 workspace', () => {
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||
expect(page).toContain('width: min(100%, 1920px)')
|
||||
expect(page).toContain('@media (min-width: 1920px)')
|
||||
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)')
|
||||
expect(page).toContain('<el-descriptions border :column="3" class="arap-detail density-descriptions">')
|
||||
expect(page).not.toContain('arap-detail-grid')
|
||||
expect(page).toContain('finance-density-detail')
|
||||
expect(page).not.toContain('max-width: 520px')
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
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: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
|
||||
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已生成应付单', value: 'PAYABLE_BILLED' }, { label: '部分付款', value: 'PARTIALLY_PAID' }, { label: '已付款', value: 'PAID' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
|
||||
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
|
||||
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
|
||||
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
||||
@@ -141,10 +141,17 @@ function closeCustomerSelectOnScroll() {
|
||||
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
|
||||
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
|
||||
}
|
||||
// Eight fixed fields precede settlement status; optional source fields also occupy a cell.
|
||||
const settlementStatusSpan = computed(() => {
|
||||
const source = detail.value
|
||||
const precedingFields = 8 + Number(source?.direction === 'PAYABLE') + Number(source?.quantity != null) + Number(Boolean(source?.periodStart))
|
||||
return 3 - precedingFields % 3
|
||||
})
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
|
||||
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', align: 'right', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
|
||||
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
|
||||
]
|
||||
@@ -191,7 +198,7 @@ function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 's
|
||||
}
|
||||
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
|
||||
function sourceStatusDisplay(value: unknown) {
|
||||
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
|
||||
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', PAYABLE_BILLED: '已生成应付单', PARTIALLY_PAID: '部分付款', PAID: '已付款', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
|
||||
return labels[String(value || '').toUpperCase()] || '状态待确认'
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
@@ -414,17 +421,17 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
|
||||
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||||
<template #generated="{ row }">{{ row.direction === 'PAYABLE' ? '不适用' : row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||||
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
|
||||
<AdminPage v-else as="div" class="finance-arap-detail admin-density admin-workbench is-embedded" spacing="normal">
|
||||
<section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface">
|
||||
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
|
||||
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
|
||||
<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"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
|
||||
<template v-else-if="detail"><FormSection class="arap-information density-form-section" title="单据信息与审核结算" :bordered="false"><el-descriptions border :column="3" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单" :span="settlementStatusSpan">{{ detail.direction === 'PAYABLE' ? '不适用' : detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="3">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table is-page-surface"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
@@ -522,23 +529,12 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
color: var(--workbench-muted);
|
||||
}
|
||||
|
||||
.arap-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: var(--admin-space-6);
|
||||
border-bottom: 1px solid var(--workbench-border);
|
||||
}
|
||||
|
||||
.arap-information,
|
||||
.arap-settlement,
|
||||
.operation-log {
|
||||
padding: var(--admin-space-4) 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: var(--admin-space-6);
|
||||
border-left: 1px solid var(--workbench-border);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -566,16 +562,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
color: var(--workbench-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
|
||||
.arap-detail-grid {
|
||||
gap: var(--admin-space-4);
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: var(--admin-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.finance-arap-detail__surface {
|
||||
@@ -591,16 +577,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.arap-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--workbench-border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -108,13 +108,37 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
|
||||
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(22)
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
|
||||
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
|
||||
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
expect(document.querySelector('.statement-detail-page .el-tabs')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户A')
|
||||
expect(wrapper.find('[aria-label="调整记录"]').isVisible()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
|
||||
const toolbar = () => wrapper.find('#statement-query-filters')
|
||||
const toggle = () => toolbar().find('button[aria-controls="statement-query-filters"]')
|
||||
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(3)
|
||||
await toggle().trigger('click')
|
||||
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(9)
|
||||
vm.filters.containerNo = 'CONT-31'
|
||||
vm.filters.createdRange = ['2026-08-01', '2026-08-31']
|
||||
await toggle().trigger('click')
|
||||
expect(vm.filters.containerNo).toBe('CONT-31')
|
||||
const reset = toolbar().findAll('button').find((button) => button.text() === '重置')!
|
||||
await reset.trigger('click')
|
||||
await flushPromises()
|
||||
expect(vm.filters.containerNo).toBe('')
|
||||
expect(vm.filters.createdRange).toEqual([])
|
||||
expect(vm.appliedFilters.containerNo).toBe('')
|
||||
})
|
||||
|
||||
it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
|
||||
@@ -163,9 +187,8 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
|
||||
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
|
||||
await vm.openDetail(statement)
|
||||
vm.activeDetailTab = 'payments'
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../financeDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Download, Plus } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -15,6 +15,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -65,7 +66,7 @@ const selectedId = ref('')
|
||||
const detail = ref<FinanceReconciliationDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const activeDetailTab = ref<'lines' | 'payments'>('lines')
|
||||
const showAdvancedFilters = ref(false)
|
||||
const detailCache = new Map<string, FinanceReconciliationDetail>()
|
||||
const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
@@ -118,6 +119,13 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
|
||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
|
||||
])
|
||||
const visibleFields = computed(() => showAdvancedFilters.value ? fields.value : fields.value.slice(0, 3))
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [] }
|
||||
void load()
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
|
||||
@@ -126,15 +134,15 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
{ key: 'periodStart', label: '结算起始日', width: 130 },
|
||||
{ key: 'periodEnd', label: '结算结束日', width: 130 },
|
||||
{ key: 'creditDays', label: '账期(天)', width: 100 },
|
||||
{ key: 'creditDays', label: '账期(天)', align: 'right', width: 100 },
|
||||
{ key: 'lineCount', label: '费用明细数', width: 110 },
|
||||
{ key: 'sourceCount', label: '来源数量', width: 100 },
|
||||
{ key: 'currency', label: '货币', width: 86 },
|
||||
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
|
||||
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
|
||||
{ key: 'sourceAmount', label: '调整前金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'adjustmentAmount', label: '调整金额(CNY)', align: 'right', width: 145 },
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', align: 'right', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', align: 'right', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', align: 'right', width: 145 },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
@@ -157,10 +165,10 @@ const detailLineColumns: DataTableColumn[] = [
|
||||
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
|
||||
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
|
||||
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
|
||||
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
|
||||
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
|
||||
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
|
||||
{ key: 'quantity', label: '计费量', align: 'right', width: 110, slotName: 'quantity' },
|
||||
{ key: 'unitPrice', label: '单价(CNY)', align: 'right', width: 120, slotName: 'unitPrice' },
|
||||
{ key: 'sourceAmount', label: '来源金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'amount', label: '账单金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
|
||||
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
|
||||
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
|
||||
@@ -169,7 +177,7 @@ const detailLineColumns: DataTableColumn[] = [
|
||||
const paymentColumns: DataTableColumn[] = [
|
||||
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
|
||||
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
|
||||
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
|
||||
{ key: 'amount', label: '支付金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
|
||||
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
|
||||
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
|
||||
@@ -612,9 +620,10 @@ watch(detailRouteId, (statementId) => {
|
||||
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
|
||||
<template v-if="detail" #summary>
|
||||
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
|
||||
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
|
||||
<dl class="density-fields statement-summary-grid">
|
||||
<div class="statement-summary-item"><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div>
|
||||
<div class="statement-summary-item"><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div>
|
||||
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
|
||||
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
|
||||
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
|
||||
@@ -638,12 +647,11 @@ watch(detailRouteId, (statementId) => {
|
||||
</dl>
|
||||
</FormSection>
|
||||
</template>
|
||||
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
|
||||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<template v-else-if="detail">
|
||||
<FormSection class="density-form-section" :bordered="false" title="账单明细表" aria-label="账单明细">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||||
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
|
||||
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
|
||||
@@ -658,12 +666,9 @@ watch(detailRouteId, (statementId) => {
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><div class="workbench-table"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></div></el-collapse-item></el-collapse>
|
||||
</FormSection>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template>
|
||||
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<FormSection class="density-form-section" :bordered="false" title="账单支付历史表" aria-label="支付历史">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
@@ -674,15 +679,27 @@ watch(detailRouteId, (statementId) => {
|
||||
</DataTable>
|
||||
</div>
|
||||
</FormSection>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<FormSection v-if="detail.revisions.length" class="density-form-section" :bordered="false" title="调整记录" aria-label="调整记录">
|
||||
<div class="workbench-table is-page-surface">
|
||||
<el-table :data="detail.revisions" size="small" border stripe>
|
||||
<el-table-column prop="revisionNo" label="修订" width="72" />
|
||||
<el-table-column label="动作" width="120"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column>
|
||||
<el-table-column prop="beforeAmount" label="调整前" align="right" width="130" />
|
||||
<el-table-column prop="afterAmount" label="调整后" align="right" width="130" />
|
||||
<el-table-column prop="reason" label="原因" min-width="280"><template #default="{ row }"><LineClampText :content="row.reason" :lines="1" /></template></el-table-column>
|
||||
<el-table-column label="操作人" width="170"><template #default="{ row }"><LineClampText :content="operatorDisplayName(row.operatorName)" /></template></el-table-column>
|
||||
<el-table-column label="发生时间" width="180"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</FormSection>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<ListPageLayout v-else aria-label="客户对账单">
|
||||
|
||||
|
||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
||||
<template #toolbar-start><h1>客户对账单</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
|
||||
<template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
|
||||
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
|
||||
|
||||
|
||||
@@ -751,12 +768,8 @@ watch(detailRouteId, (statementId) => {
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||||
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
|
||||
.statement-outstanding { color: var(--el-color-warning-dark-2); }
|
||||
.statement-detail-tabs { min-width: 0; }
|
||||
.statement-detail-table { min-width: 0; }
|
||||
.detail-table-scroll { min-width: 0; overflow-x: auto; }
|
||||
.statement-line-table { min-width: 1840px; }
|
||||
.statement-payment-table { min-width: 1300px; }
|
||||
.revision-collapse { margin-top: var(--admin-space-5); }
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
@@ -765,13 +778,6 @@ watch(detailRouteId, (statementId) => {
|
||||
padding: var(--admin-space-3) var(--admin-space-5);
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
.revision-collapse {
|
||||
margin-top: var(--admin-space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -6,11 +6,11 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
|
||||
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
|
||||
|
||||
describe('Finance formal settlement desktop presentation', () => {
|
||||
it('keeps the title and actions in the toolbar and settlement totals before results', () => {
|
||||
it('keeps the title and actions in the toolbar and settlement totals above the list', () => {
|
||||
expect(source).toContain('<template #toolbar-start>')
|
||||
expect(source).toContain('<h1>{{ title }}</h1>')
|
||||
expect(source).toContain('<template #before-results>')
|
||||
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">')
|
||||
expect(source.indexOf('<section class="finance-metric-grid"')).toBeLessThan(source.indexOf('<ListPageLayout :aria-label="title">'))
|
||||
expect(source).toContain('<section class="finance-metric-grid" aria-label="结算汇总"')
|
||||
expect(source).toContain('<template #toolbar-actions>')
|
||||
expect(source).toContain('<div class="settlement-list-actions">')
|
||||
expect(source).not.toContain('class="settlement-list-toolbar"')
|
||||
@@ -44,7 +44,7 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
it('uses a continuous detail layout with locally scrollable wide tables', () => {
|
||||
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"')
|
||||
expect(source).toContain('class="settlement-detail-content"')
|
||||
expect(source).toContain('class="detail-table-scroll workbench-table"')
|
||||
expect(source.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
|
||||
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
|
||||
expect(source).not.toContain('class="settlement-detail-card"')
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../financeDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { ArrowLeft, CircleCheck, Clock3, CreditCard, Download, HandCoins, Plus, RotateCcw, Wallet, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -142,9 +142,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
|
||||
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'receivableAmount', label: '应收金额', align: 'right', width: 130, slotName: 'money' },
|
||||
{ key: 'receivedAmount', label: '已收', align: 'right', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
|
||||
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
|
||||
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
|
||||
@@ -162,9 +162,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
|
||||
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'payableAmount', label: '应付金额', align: 'right', width: 130, slotName: 'money' },
|
||||
{ key: 'paidAmount', label: '已付', align: 'right', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
|
||||
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
|
||||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||||
@@ -520,7 +520,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" spacing="normal">
|
||||
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" :spacing="detailMode ? 'normal' : 'compact'">
|
||||
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
|
||||
<OperationErrorToast :message="receiptValidationError" />
|
||||
<OperationErrorToast :details="receiptError" />
|
||||
@@ -534,17 +534,25 @@ onMounted(() => {
|
||||
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
</div></div></template>
|
||||
<template v-if="detail" #summary>
|
||||
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总">
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div>
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div>
|
||||
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div>
|
||||
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div>
|
||||
</dl>
|
||||
<section class="finance-metric-grid" aria-label="结算金额汇总">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(detail.amount) }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(detail.paidAmount) }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">未结余额</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ money(detail.outstandingAmount) }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
|
||||
<section v-else-if="detail" class="settlement-detail-content">
|
||||
<FormSection class="density-form-section" title="基本信息">
|
||||
<FormSection class="density-form-section" title="基本信息" :bordered="false">
|
||||
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
|
||||
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
|
||||
@@ -562,8 +570,8 @@ onMounted(() => {
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="费用明细">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<FormSection class="density-form-section" title="费用明细" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
|
||||
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
|
||||
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
|
||||
@@ -575,8 +583,8 @@ onMounted(() => {
|
||||
</el-table>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
|
||||
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
|
||||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
@@ -590,7 +598,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="修订记录" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
|
||||
<el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
|
||||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
@@ -603,7 +611,22 @@ onMounted(() => {
|
||||
</FormSection>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
<ListPageLayout v-else :aria-label="title">
|
||||
<template v-else>
|
||||
<section class="finance-metric-grid" aria-label="结算汇总" v-if="!customerError && !error" v-loading="loading" :aria-busy="loading">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.amount) }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收' : '已付' }}</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.paid) }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '待收' : '待付' }}</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.outstanding) }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<ListPageLayout :aria-label="title">
|
||||
|
||||
|
||||
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
|
||||
@@ -614,13 +637,7 @@ onMounted(() => {
|
||||
<template #notices><template v-if="customerError || error">
|
||||
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
|
||||
</template></template>
|
||||
<template #before-results>
|
||||
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<template #toolbar-actions>
|
||||
@@ -654,6 +671,7 @@ onMounted(() => {
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
|
||||
@@ -700,8 +718,8 @@ onMounted(() => {
|
||||
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
|
||||
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
|
||||
<el-form class="settlement-operation-form density-form density-form--outlined" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
|
||||
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
|
||||
@@ -717,8 +735,7 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
|
||||
.settlement-summary { gap: var(--admin-space-2) 0; }
|
||||
.settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
|
||||
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); }
|
||||
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
|
||||
@@ -754,21 +771,11 @@ onMounted(() => {
|
||||
padding: 0 var(--admin-space-5) var(--admin-space-4);
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.settlement-detail-summary { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
|
||||
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
|
||||
.settlement-detail-content { padding: 0 16px 14px; }
|
||||
.settlement-detail-state { margin: 14px; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
.settlement-list-actions { width: 100%; justify-content: flex-start; }
|
||||
.summary-item strong { font-size: 21px; }
|
||||
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
.settlement-detail-actions { width: 100%; }
|
||||
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
|
||||
|
||||
@@ -39,21 +39,21 @@ describe('finance presentation governance', () => {
|
||||
expect(settlement).toContain('class="settlement-detail-info density-descriptions"')
|
||||
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
|
||||
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
|
||||
expect(settlement.match(/class="detail-table-scroll workbench-table"/g)).toHaveLength(3)
|
||||
expect(settlement.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
|
||||
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
|
||||
expect(settlement).not.toContain('class="detail-section surface-panel"')
|
||||
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form density-form(?: density-form--outlined)?"/g)).toHaveLength(2)
|
||||
expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
|
||||
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in the table toolbar', () => {
|
||||
expect(settlement).toContain('<template #toolbar-start>')
|
||||
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"')
|
||||
expect(settlement).toContain('class="finance-metric-grid" aria-label="结算汇总"')
|
||||
expect(settlement).toContain('<template #toolbar-actions>')
|
||||
expect(settlement).toContain('class="settlement-list-actions"')
|
||||
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }')
|
||||
expect(financeDensity).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
|
||||
expect(settlement).not.toContain('class="settlement-list-toolbar"')
|
||||
})
|
||||
|
||||
@@ -148,15 +148,13 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
|
||||
})
|
||||
|
||||
it('uses a shared compact financial summary row for customer accounts', () => {
|
||||
expect(accounts).toContain('class="density-detail-overview account-metrics"')
|
||||
it('uses shared compact financial cards for customer accounts', () => {
|
||||
expect(accounts).toContain('class="account-metrics"')
|
||||
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
|
||||
expect(density).toContain('.density-detail-overview')
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
|
||||
expect(accounts).toContain('padding: 0;')
|
||||
const summaryStyles = density.match(/\.density-detail-overview \{([^}]*)\}/)?.[1]
|
||||
expect(summaryStyles).toContain('display: flex;')
|
||||
expect(summaryStyles).toContain('flex-wrap: wrap;')
|
||||
expect(accounts).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
|
||||
expect(financeDensity).toContain('padding-block: 0;')
|
||||
expect(accounts.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
|
||||
const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1]
|
||||
expect(accounts).toContain('class="ledger-query workbench-query"')
|
||||
expect(queryStyles).toContain('border-color: var(--workbench-border);')
|
||||
@@ -168,9 +166,11 @@ describe('finance presentation governance', () => {
|
||||
it('keeps fee-source details continuous without nested metric and section cards', () => {
|
||||
expect(arap).toContain('class="density-detail-overview arap-metrics"')
|
||||
expect(arap).toContain('class="arap-information density-form-section"')
|
||||
expect(arap).toContain('class="arap-settlement density-form-section"')
|
||||
expect(arap).toContain('padding-left: var(--admin-space-6);')
|
||||
expect(arap).toContain('border-left: 1px solid')
|
||||
expect(arap).toContain('title="单据信息与审核结算"')
|
||||
expect(arap).toContain('label="审核原因" :span="3"')
|
||||
expect(arap).not.toContain('arap-detail-grid')
|
||||
expect(arap).not.toContain('arap-settlement')
|
||||
expect(arap).not.toContain('border-left: 1px solid')
|
||||
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
|
||||
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@ describe('receivable statistics page', () => {
|
||||
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
|
||||
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
|
||||
expect(page).toContain('window.print()')
|
||||
expect(page).toContain('aria-label="统计月份"')
|
||||
expect(page).toContain('aria-label="应收单位"')
|
||||
for (const removed of ['<span>统计月份</span>', '<span>应收单位</span>', '#before-results', '应收款明细', '仅统计CNY正式应收结算单']) expect(page).not.toContain(removed)
|
||||
expect(page).toContain('displayCustomerName(row)')
|
||||
expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)')
|
||||
expect(page).toContain("return masterName || '—'")
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
|
||||
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -39,9 +39,9 @@ const customerNameById = computed(() => new Map(customers.value.map((item) => [S
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
|
||||
{ key: 'currency', label: '币种', width: 100 },
|
||||
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' },
|
||||
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
|
||||
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' },
|
||||
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
|
||||
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
|
||||
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
@@ -90,22 +90,31 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
|
||||
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
|
||||
<template v-else>
|
||||
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已收合计</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>应收款统计表</h1></template>
|
||||
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
|
||||
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
|
||||
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
|
||||
<div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
|
||||
<div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
|
||||
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
|
||||
</section></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #before-results><dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
|
||||
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
|
||||
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
|
||||
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
|
||||
</dl><div class="statistics-report-heading"><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></div></template>
|
||||
|
||||
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
|
||||
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
|
||||
@@ -117,20 +126,17 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
|
||||
<style scoped>
|
||||
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
|
||||
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--workbench-muted); font-size: var(--admin-font-body); }
|
||||
.query-field { min-width: 220px; }
|
||||
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
|
||||
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
|
||||
.report-actions { margin-left: auto; }
|
||||
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
|
||||
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
|
||||
.statistics-table { overflow: hidden; }
|
||||
.statistics-report-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
|
||||
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.statistics-report-heading span { color: var(--workbench-muted); font-size: var(--admin-font-body); }
|
||||
@media print {
|
||||
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
|
||||
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
|
||||
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
|
||||
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
|
||||
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -434,8 +434,8 @@ onMounted(loadInitialData)
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户基本资料">
|
||||
<el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="用户基本资料">
|
||||
<h3 class="density-dialog-section__title">用户资料</h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
|
||||
@@ -444,7 +444,7 @@ onMounted(loadInitialData)
|
||||
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库">
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" aria-label="用户作业仓库">
|
||||
<h3 class="density-dialog-section__title">作业仓库</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
|
||||
@@ -459,7 +459,7 @@ onMounted(loadInitialData)
|
||||
</el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码">
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" aria-label="用户登录密码">
|
||||
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
|
||||
|
||||
@@ -85,14 +85,15 @@ describe('PermissionsView IAM mode', () => {
|
||||
it('keeps the shared status badge prop defined during table slot initialization', () => {
|
||||
expect(permissionsSource).toContain("(row as Permission).status || '—'")
|
||||
})
|
||||
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
|
||||
it('uses outlined business action buttons without global tree view helpers', () => {
|
||||
expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
|
||||
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
|
||||
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
|
||||
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
|
||||
expect(permissionsSource).toContain('<RowActions text-actions v-if="permissionActions(row as Permission).some')
|
||||
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
|
||||
expect(permissionsSource).not.toContain('展开全部')
|
||||
expect(permissionsSource).not.toContain('收起操作权限')
|
||||
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
|
||||
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
|
||||
expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link')
|
||||
@@ -103,7 +104,10 @@ describe('PermissionsView IAM mode', () => {
|
||||
})
|
||||
|
||||
it('keeps technical permission fields out of the default list and labels the table', () => {
|
||||
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }")
|
||||
expect(permissionsSource).not.toContain("key: 'code', label: '权限编码'")
|
||||
expect(permissionsSource).not.toContain('<FolderTree')
|
||||
expect(permissionsSource).not.toContain('<ShieldCheck')
|
||||
expect(permissionsSource).not.toContain('<Braces')
|
||||
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }")
|
||||
expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }")
|
||||
expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { FolderTree, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -90,9 +90,10 @@ const parentOptions = computed(() => permissions.value.filter((item) => (
|
||||
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
|
||||
)))
|
||||
const permissionColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions', className: 'permission-actions-column' },
|
||||
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
|
||||
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
|
||||
{ key: 'menuCount', label: '菜单数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).menuCount },
|
||||
{ key: 'actionCount', label: '操作数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).actionCount },
|
||||
{ key: 'type', prop: 'type', label: '类型', width: 80 },
|
||||
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
|
||||
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
|
||||
@@ -128,25 +129,16 @@ function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
|
||||
return ids
|
||||
}
|
||||
|
||||
function permissionSummary(row: PermissionTreeNode) {
|
||||
function permissionCounts(row: PermissionTreeNode) {
|
||||
const descendants = collectDescendantIds(row.children ?? [])
|
||||
.map((id) => permissions.value.find((item) => item.id === id))
|
||||
.filter((item): item is Permission => Boolean(item))
|
||||
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
|
||||
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
|
||||
if (menuCount === 0 && actionCount === 0) return '无下级权限'
|
||||
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
|
||||
return { menuCount, actionCount }
|
||||
}
|
||||
|
||||
function expandAllRows() {
|
||||
expandedRowKeys.value = collectDescendantIds(treeRows.value)
|
||||
tableRenderKey.value += 1
|
||||
}
|
||||
|
||||
function collapseActionRows() {
|
||||
expandedRowKeys.value = rootRowKeys.value
|
||||
tableRenderKey.value += 1
|
||||
}
|
||||
|
||||
function openCreate(parentId: string | null = null) {
|
||||
Object.assign(form, emptyForm(), { parentId })
|
||||
@@ -235,7 +227,6 @@ async function removePermission(row: Permission) {
|
||||
rowActionId.value = ''
|
||||
}
|
||||
}
|
||||
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
|
||||
|
||||
function canAddChild(row: Permission) {
|
||||
return canCreate.value && ['菜单', '页面'].includes(row.type)
|
||||
@@ -287,13 +278,11 @@ onMounted(loadPermissions)
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
</template></template>
|
||||
<template #before-results><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
|
||||
<template #actions="{ row }"><RowActions text-actions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
|
||||
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
|
||||
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
|
||||
<template #name="{ row }"><strong>{{ (row as Permission).name }}</strong></template>
|
||||
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
|
||||
@@ -317,23 +306,19 @@ onMounted(loadPermissions)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(td.permission-actions-column > .cell) { display: flex; align-items: center; white-space: nowrap; }
|
||||
:deep(td.permission-actions-column > .cell > .row-actions) { display: inline-flex; flex-wrap: nowrap; flex-shrink: 0; }
|
||||
:deep(td.permission-actions-column > .cell > .el-table__indent),
|
||||
:deep(td.permission-actions-column > .cell > .el-table__expand-icon),
|
||||
:deep(td.permission-actions-column > .cell > .el-table__placeholder) { flex-shrink: 0; }
|
||||
.permission-editor-table { table-layout: fixed; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: 50%; }
|
||||
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
|
||||
.permission-protection-hint { color: var(--workbench-muted); }
|
||||
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
|
||||
.tree-view-actions .el-button + .el-button { margin-left: 0; }
|
||||
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.permission-name small { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.35; }
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.permission-editor-table tr { display: grid; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: auto; }
|
||||
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.tree-view-actions { width: 100%; justify-content: flex-start; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -68,7 +68,7 @@ beforeEach(() => {
|
||||
describe('roles and permission dialogs', () => {
|
||||
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => {
|
||||
const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree)
|
||||
expect(dialog.props()).toMatchObject({ title: '配置角色 · TEST_OPERATOR', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
|
||||
expect(dialog.props()).toMatchObject({ title: '配置角色 · 测试角色', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
|
||||
expect(tree.vm.getCheckedKeys()).toContain(childId)
|
||||
expect(tree.vm.getNode(hiddenId)).toBeTruthy()
|
||||
|
||||
@@ -117,11 +117,11 @@ describe('RolesView', () => {
|
||||
expect(rolesSource).toContain('aria-label="平台角色列表"')
|
||||
})
|
||||
|
||||
it('uses the prototype-sized centered role editor with responsive split panes', () => {
|
||||
it('uses the prototype-sized centered role editor with stacked information and permissions', () => {
|
||||
expect(rolesSource).toContain('<AppDialog v-model="editorOpen"')
|
||||
expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"')
|
||||
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
|
||||
expect(rolesSource).not.toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
|
||||
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
|
||||
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
|
||||
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))')
|
||||
|
||||
@@ -63,7 +63,7 @@ const formRules: FormRules = {
|
||||
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
}
|
||||
const editorLabel = computed(() => form.id ? `配置角色 · ${form.code.trim() || form.name.trim() || '—'}` : '新增角色')
|
||||
const editorLabel = computed(() => form.id ? `配置角色 · ${form.name.trim() || '—'}` : '新增角色')
|
||||
const canEdit = computed(() => access.can('platform:role:edit'))
|
||||
const canGrant = computed(() => access.can('platform:role:grant'))
|
||||
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
|
||||
@@ -263,13 +263,13 @@ onMounted(loadRoles)
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="角色基本资料">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-form--outlined density-dialog-sections role-editor-layout" :disabled="saving">
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="角色基本资料">
|
||||
<h3 class="density-dialog-section__title">基本信息</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="角色编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
|
||||
<table class="density-table density-table--editable role-basic-table"><tbody>
|
||||
<tr><td :colspan="form.id ? 2 : 1"><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td>
|
||||
<td v-if="!form.id"><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
|
||||
@@ -282,10 +282,12 @@ onMounted(loadRoles)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; }
|
||||
.role-editor-layout:not(:has(.role-editor-permissions)) { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-basic-table { table-layout: fixed; }
|
||||
.role-basic-table td:not([colspan]) { width: 50%; }
|
||||
.role-editor-permissions .permission-tree { max-height: min(320px, 35vh); overflow: auto; overscroll-behavior: contain; }
|
||||
.permission-node span { min-width: 0; }
|
||||
.permission-node.is-navigation-group span { font-weight: 650; }
|
||||
:global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
|
||||
@media (max-width: 720px) { .role-editor-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 720px) { .role-basic-table td { display: block; width: 100%; box-sizing: border-box; } }
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 采购样品履约是一条独立链路:下单、包装、面单、交寄、国内核费、寄客户;改节点或费用前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`,不套用普通头程或尾程流程。
|
||||
- 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。
|
||||
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
|
||||
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。
|
||||
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', async importOriginal => ({ ...await importOriginal<typeof import('@/shared/api/http')>(), http: api }))
|
||||
import { getProcurementWorkflow, submitProcurementWorkflow } from './procurementWorkflow'
|
||||
beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }); api.post.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }) })
|
||||
it('scopes workflow and every batch operation to the exact order and customer', async () => {
|
||||
await getProcurementWorkflow('OP/1', '1001')
|
||||
expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/workflow', { params: { customerId: '1001' } })
|
||||
for (const action of ['receipts', 'request-confirmation', 'outbounds', 'outbounds/9007199254740993/dispatch', 'outbounds/9007199254740993/complete']) {
|
||||
const command = { version: 3, idempotencyKey: 'retry-same-key' }
|
||||
await submitProcurementWorkflow('OP/1', '1001', action, command)
|
||||
expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${action}`, command, { params: { customerId: '1001' } })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface ProcurementWorkflowReceipt { id: string; quantity: number; fileIds: string[]; receivedAt: string }
|
||||
export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null }
|
||||
export interface ProcurementWorkflow {
|
||||
orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number
|
||||
receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[]
|
||||
}
|
||||
export interface WorkflowCommand { version: number; idempotencyKey: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
|
||||
const base = '/platform/v1/last-leg/overseas-procurements'
|
||||
export async function getProcurementWorkflow(orderNo: string, customerId: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } }))
|
||||
}
|
||||
export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } }))
|
||||
}
|
||||
@@ -68,6 +68,18 @@ beforeEach(() => {
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('单据费用操作', () => {
|
||||
it('uses the same fee dialog for procurement with balance deduction selected by default', async () => {
|
||||
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' }
|
||||
const wrapper = mount(BusinessOrderFees, { props: { business: procurement }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
|
||||
await flushPromises(); await click(wrapper, '新增费用')
|
||||
const dialog = activeDialog(wrapper)
|
||||
const form = dialog.getComponent({ name: 'ElForm' }).props('model')
|
||||
expect(form.settlementMode).toBe('WALLET')
|
||||
Object.assign(form, { feeItemCode: 'HANDLING', amount: 12.34 })
|
||||
await click(dialog, '保存')
|
||||
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', businessId: procurement.businessId, customerId: procurement.customerId, settlementMode: 'WALLET', amount: 12.34 }), expect.any(String))
|
||||
})
|
||||
it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => {
|
||||
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
|
||||
|
||||
@@ -70,7 +70,7 @@ watch(open, async value => {
|
||||
openedBusinessKey = businessKey()
|
||||
error.value = null
|
||||
const fee = props.fee
|
||||
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
|
||||
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : props.business?.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
|
||||
options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : []
|
||||
captureBaseline()
|
||||
formRef.value?.clearValidate()
|
||||
@@ -79,7 +79,7 @@ watch(open, async value => {
|
||||
const sourceTypes: Record<string, string> = {
|
||||
LASTLEG_ORDER: 'OVERSEAS_ORDER_FEE', WAREHOUSE_TRANSFER: 'OVERSEAS_WAREHOUSE_FEE', RETURN_ORDER: 'OVERSEAS_WAREHOUSE_FEE',
|
||||
DISCARD_ORDER: 'OVERSEAS_WAREHOUSE_FEE', RELABEL_ORDER: 'OVERSEAS_RELABEL_FEE', US_TRUCK: 'OVERSEAS_TRUCK_FEE',
|
||||
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE',
|
||||
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE', OVERSEAS_PROCUREMENT: 'OVERSEAS_PROCUREMENT_FEE',
|
||||
OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE',
|
||||
}
|
||||
const selectableTypes = Object.keys(sourceTypes)
|
||||
@@ -130,7 +130,7 @@ async function save() {
|
||||
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item>
|
||||
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
|
||||
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
|
||||
<el-form-item label="费用说明" class="fee-remark" :required="duplicates.length > 0"><el-input v-model="form.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item>
|
||||
</div>
|
||||
<div v-if="duplicates.length" class="fee-existing" role="status"><span>此单已有相同费用,确认本次为新增作业后可继续添加。</span><p v-for="row in duplicates" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</p></div>
|
||||
|
||||
@@ -29,8 +29,8 @@ function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'r
|
||||
return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
async function open(wrapper: ReturnType<typeof render>) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) }
|
||||
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) }
|
||||
async function confirm(wrapper: ReturnType<typeof render>) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() }
|
||||
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => ['操作说明', '审核说明', '拒绝原因'].includes(row.props('label')))!.find('textarea').setValue(value) }
|
||||
async function confirm(wrapper: ReturnType<typeof render>) { const dialog = wrapper.findComponent(AppDialog); const approve = dialog.findAll('button').find(button => button.text() === '通过'); if (approve) await approve.trigger('click'); else dialog.vm.$emit('confirm'); await flushPromises() }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
beforeEach(() => {
|
||||
@@ -40,40 +40,43 @@ beforeEach(() => {
|
||||
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
|
||||
})
|
||||
describe('procurement purchase node operations', () => {
|
||||
it('switches to domestic warehouses, clears old selection and preserves editable direction after failure', async () => {
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(async direction => direction === 'DOMESTIC_TO_OVERSEAS'
|
||||
? [{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }]
|
||||
: [{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外采购仓', countryCode: 'US' }])
|
||||
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
|
||||
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!
|
||||
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
|
||||
field('采购方向').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
|
||||
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith('DOMESTIC_TO_OVERSEAS')
|
||||
expect(field('采购仓').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '30001')
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('connection lost'))
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ procurementDirection: 'DOMESTIC_TO_OVERSEAS', purchaseWarehouseId: '30001' }))
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
expect(field('采购方向').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('DOMESTIC_TO_OVERSEAS')
|
||||
await confirm(wrapper)
|
||||
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
|
||||
expect(next.procurementDirection).toBe('DOMESTIC_TO_OVERSEAS')
|
||||
expect(next.purchaseWarehouseId).toBe('30001')
|
||||
expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
it.each(['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'] as const)('locks review direction and requests the matching warehouse catalog: %s', async direction => {
|
||||
const value = fixture(); value.customerView.order.procurementDirection = direction
|
||||
const wrapper = render(value); const dialog = await open(wrapper)
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
const directionField = fields.find(item => item.props('label') === '采购方向')!
|
||||
expect(directionField.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
|
||||
expect(directionField.text()).toContain(direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购,发往海外' : '海外采购,发往国内')
|
||||
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith(direction)
|
||||
expect(fields.some(item => item.props('label') === (direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓'))).toBe(true)
|
||||
expect(fields.some(item => item.props('label') === '审核决定')).toBe(false)
|
||||
expect(dialog.findAll('button').map(button => button.text())).toEqual(expect.arrayContaining(['取消', '拒绝', '通过']))
|
||||
expect(dialog.text()).not.toContain('确认提交')
|
||||
})
|
||||
it('ignores late warehouse responses from a previous direction', async () => {
|
||||
let finishImport!: (rows: Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>) => void
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'DOMESTIC_TO_OVERSEAS'
|
||||
? Promise.resolve([{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }])
|
||||
: new Promise(resolve => { finishImport = resolve }))
|
||||
it('allows approval without a remark after a blank rejection attempt once a warehouse is selected', async () => {
|
||||
const wrapper = render(); const dialog = await open(wrapper)
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购方向')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
|
||||
finishImport([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '过期海外候选', countryCode: 'US' }]); await flushPromises()
|
||||
const warehouse = dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' })
|
||||
expect(warehouse.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual(['30001'])
|
||||
await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises()
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'APPROVE', remark: '', purchaseWarehouseId: '20001' }))
|
||||
})
|
||||
it('requires a nonblank rejection reason and lets cancel close without changing the order', async () => {
|
||||
const wrapper = render(); const dialog = await open(wrapper)
|
||||
const reject = () => dialog.findAll('button').find(button => button.text() === '拒绝')!
|
||||
await reject().trigger('click'); await flushPromises()
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(dialog.text()).toContain('请填写拒绝原因')
|
||||
await explain(wrapper, ' '); await reject().trigger('click'); await flushPromises()
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
await explain(wrapper, '供应商无法提供该商品'); await reject().trigger('click'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT', remark: '供应商无法提供该商品' }))
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[0]![3]).not.toHaveProperty('purchaseWarehouseId')
|
||||
wrapper.unmount()
|
||||
const next = render(); const nextDialog = await open(next)
|
||||
await nextDialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
expect(nextDialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('disables terminal physical changes and keeps the management evidence form open', async () => {
|
||||
@@ -264,14 +267,14 @@ describe('procurement purchase node operations', () => {
|
||||
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
|
||||
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', procurementDirection: 'OVERSEAS_TO_DOMESTIC', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
})
|
||||
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable'))
|
||||
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求')
|
||||
dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper)
|
||||
await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' }))
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId')
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ import ProcurementTransportFields from './ProcurementTransportFields.vue'
|
||||
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
|
||||
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
|
||||
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
|
||||
import { directionLabel, procurementDirections, regionText, type ProcurementDirection } from '../procurementDirection'
|
||||
import { directionLabel, regionText } from '../procurementDirection'
|
||||
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
@@ -39,8 +39,10 @@ const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); c
|
||||
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
|
||||
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
|
||||
const extraFiles = ref<ProcurementFile[]>([])
|
||||
const form = reactive({ procurementDirection: (props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') as ProcurementDirection, decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
const warehouseDirection = computed(() => command.value === 'review' ? form.procurementDirection : props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
const warehouseDirection = computed(() => props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
|
||||
const purchaseWarehouseLabel = computed(() => warehouseDirection.value === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓')
|
||||
const rejecting = ref(false)
|
||||
const t = (text: string) => regionText(text, warehouseDirection.value)
|
||||
const control = ref(controlForm(props.detail))
|
||||
const finance = ref(financeForm(props.detail))
|
||||
@@ -69,7 +71,7 @@ const isFinance = computed(() => !!command.value && financeCommands.has(command.
|
||||
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
|
||||
const isPhysical = computed(() => !!command.value && physicalCommands.has(command.value))
|
||||
const needsEvidence = computed(() => ['dispositions', 'receipts/correct', 'dispositions/revoke', 'domestic-repack', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(command.value || ''))
|
||||
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || (command.value === 'review' && form.decision === 'APPROVE'))
|
||||
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || command.value === 'review')
|
||||
const needsAmount = computed(() => ['purchase', 'budget-authorizations'].includes(command.value || ''))
|
||||
const fileCategory = computed(() => isControl.value ? 'EXCEPTION' : isFinance.value ? 'VOUCHER' : command.value === 'labels' ? 'LABEL' : command.value === 'domestic-repack' ? 'RECEIPT' : needsEvidence.value ? 'EXCEPTION' : command.value === 'purchase' ? 'PURCHASE' : 'BUDGET')
|
||||
const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].filter((file, index, all) => all.findIndex(other => other.fileId === file.fileId) === index))
|
||||
@@ -98,8 +100,8 @@ function open(action: ProcurementAction) {
|
||||
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
|
||||
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
|
||||
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
|
||||
Object.assign(form, { procurementDirection: props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC', decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
opened.value = true; captureBaseline()
|
||||
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
rejecting.value = false; opened.value = true; captureBaseline()
|
||||
if (needsWarehouse.value) void loadWarehouses()
|
||||
}
|
||||
function openCorrectionDraft() {
|
||||
@@ -131,9 +133,9 @@ watch(financialCommand, () => { if (editingDraft.value) return; finance.value.fe
|
||||
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
|
||||
function build(): ProcurementCommandBody {
|
||||
const order = props.detail.customerView.order
|
||||
if (!form.reason.trim()) throw new Error('请填写操作说明')
|
||||
if (!form.reason.trim() && (command.value !== 'review' || form.decision === 'REJECT')) throw new Error(command.value === 'review' ? '请填写拒绝原因' : '请填写操作说明')
|
||||
const base = { version: order.version, idempotencyKey: commandKey(), reason: form.reason.trim() }
|
||||
if (needsWarehouse.value && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
|
||||
if (needsWarehouse.value && !(command.value === 'review' && form.decision === 'REJECT') && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
|
||||
if (isControl.value && command.value) return buildControl(command.value, control.value, props.detail, allFiles.value, base, form.warehouseId, form.voucherFileId)
|
||||
if (isFinance.value && command.value) return buildFinance(command.value, finance.value, props.detail, allFiles.value, base, form.voucherFileId)
|
||||
if (isTransport.value && command.value) return buildTransport(command.value, transport.value, props.detail, allFiles.value, base, form.voucherFileId)
|
||||
@@ -141,7 +143,7 @@ function build(): ProcurementCommandBody {
|
||||
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
|
||||
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
|
||||
}
|
||||
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: form.procurementDirection, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
|
||||
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: warehouseDirection.value, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
|
||||
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
|
||||
if (needsAmount.value) {
|
||||
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
|
||||
@@ -153,6 +155,12 @@ function build(): ProcurementCommandBody {
|
||||
}
|
||||
return base
|
||||
}
|
||||
async function review(decision: 'APPROVE' | 'REJECT') {
|
||||
if (saving.value || uploading.value) return
|
||||
rejecting.value = decision === 'REJECT'
|
||||
form.decision = decision
|
||||
await submit()
|
||||
}
|
||||
async function submit() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); return }
|
||||
if (saving.value || uploading.value || storageError.value || !key.value) return
|
||||
@@ -208,7 +216,7 @@ async function upload(event: Event) {
|
||||
</details>
|
||||
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<ApiErrorMeta v-if="error && !opened" :details="error" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="command !== 'review' && submit()">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<el-alert v-if="managementBlock" :title="t(managementBlock)" type="warning" :closable="false" />
|
||||
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
|
||||
@@ -219,18 +227,17 @@ async function upload(event: Event) {
|
||||
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
|
||||
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
|
||||
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
|
||||
<el-form-item v-if="command === 'review'" label="采购方向" required><el-select v-model="form.procurementDirection"><el-option v-for="(label, value) in procurementDirections" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" :placeholder="t('选择有效海外收货仓')"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
|
||||
<el-form-item v-if="command === 'review'" label="采购方向"><span>{{ directionLabel(warehouseDirection) }}</span></el-form-item>
|
||||
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : purchaseWarehouseLabel" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" :placeholder="t('选择有效海外收货仓')"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
|
||||
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
|
||||
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item :label="t('预计到海外仓时间')" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
|
||||
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-form-item :label="command === 'review' ? (rejecting ? '拒绝原因' : '审核说明') : '操作说明'" :required="command !== 'review' || rejecting"><el-input v-model="form.reason" :placeholder="command === 'review' ? '拒绝时必须填写具体原因' : '请填写操作说明'" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="t(error.message)" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><template v-if="command === 'review'"><el-button :disabled="saving" @click="opened = false">取消</el-button><el-button type="danger" :loading="saving && form.decision === 'REJECT'" :disabled="saving || !!storageError || !key" @click="review('REJECT')">拒绝</el-button><el-button type="primary" :loading="saving && form.decision === 'APPROVE'" :disabled="saving || warehouseLoading || !!storageError || !key" @click="review('APPROVE')">通过</el-button></template><template v-else><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></template></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import type { ProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import { submitProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
|
||||
const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement
|
||||
const beforeOutbound = vi.fn<() => Promise<void>>()
|
||||
function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(undefined); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) })
|
||||
describe('simplified procurement operations', () => {
|
||||
it('blocks the outbound dialog when fees are missing or not approved and permits retry after approval', async () => {
|
||||
const wrapper = render('CONFIRMED')
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')!
|
||||
beforeOutbound.mockRejectedValueOnce(new Error('请先添加费用明细并审核通过后再出库'))
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(submitProcurementWorkflow).not.toHaveBeenCalled()
|
||||
beforeOutbound.mockRejectedValueOnce(new Error('费用明细尚未全部审核通过'))
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
})
|
||||
it('gates receipt and outbound actions with the existing operation permissions', () => {
|
||||
const receipt = render('RECEIVING', [])
|
||||
expect(receipt.findAll('button').find(button => button.text() === '登记收货')!.attributes('disabled')).toBeDefined()
|
||||
expect(receipt.text()).toContain('当前账号未开通采购收货权限')
|
||||
const outbound = render('CONFIRMED', [])
|
||||
expect(outbound.findAll('button').find(button => button.text() === '创建出库单')!.attributes('disabled')).toBeDefined()
|
||||
expect(outbound.text()).toContain('当前账号未开通采购出库及签收权限')
|
||||
expect(render('CONFIRMED').text()).toContain('创建出库单')
|
||||
})
|
||||
it('requires receipt photos and uploads them before submitting the current batch quantity', async () => {
|
||||
const wrapper = render(); await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click')
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled()
|
||||
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '1', fileId: '9007199254740993', category: 'RECEIPT', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' })
|
||||
const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['photo'], 'receipt.png', { type: 'image/png' })] })
|
||||
input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'receipts', { version: 4, idempotencyKey: expect.any(String), quantity: 1, fileIds: ['9007199254740993'] })
|
||||
})
|
||||
it('retains the outbound input and idempotency key after a failed submit', async () => {
|
||||
vi.mocked(submitProcurementWorkflow).mockRejectedValue(new Error('暂时失败'))
|
||||
const wrapper = render('CONFIRMED'); await wrapper.findAll('button').find(button => button.text() === '创建出库单')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model')
|
||||
form.trackingNo = 'TRACK-1'; form.quantity = 3
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(submitProcurementWorkflow).mock.calls[0]![3]).toEqual(vi.mocked(submitProcurementWorkflow).mock.calls[1]![3])
|
||||
expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3)
|
||||
form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('requires a rejection reason and preserves the current version and customer scope', async () => {
|
||||
const wrapper = render('PENDING_REVIEW', ['review'])
|
||||
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
|
||||
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
|
||||
form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined }))
|
||||
})
|
||||
it('only completes the selected delivering batch after signature evidence is supplied', async () => {
|
||||
const wrapper = render('DELIVERING')
|
||||
await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '上传签收凭证')!.trigger('click'); await flushPromises()
|
||||
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled()
|
||||
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '2', fileId: '9007199254740995', category: 'RECEIPT', fileName: 'signed.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' })
|
||||
const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element
|
||||
expect(input.getAttribute('accept')).toContain('application/pdf')
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['signed'], 'signed.pdf', { type: 'application/pdf' })] })
|
||||
input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-1', '1001', 'RECEIPT', expect.any(File))
|
||||
expect(form.fileIds).toEqual(['9007199254740995'])
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'outbounds/9007199254740993/complete', { version: 4, idempotencyKey: expect.any(String), fileIds: ['9007199254740995'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
|
||||
import { dateTime, statusLabel } from '../procurementPresentation'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import type { OverseasPurchaseWarehouseOption } from '../types'
|
||||
|
||||
const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<void> }>()
|
||||
const emit = defineEmits<{ changed: []; download: [id: string] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const action = ref(''), batchId = ref(''), warehouses = ref<OverseasPurchaseWarehouseOption[]>([]), warehouseLoading = ref(false)
|
||||
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' })
|
||||
const uploadedNames = ref<string[]>([])
|
||||
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'request-confirmation': '采购完成', outbounds: '创建出库单', dispatch: '开始派送', complete: '上传签收凭证' })[action.value] || '采购操作')
|
||||
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
|
||||
const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`)
|
||||
const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas')
|
||||
? '当前账号未开通采购收货权限,请联系管理员开通。'
|
||||
: ['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(props.workflow.status) && !can('ship-customer')
|
||||
? '当前账号未开通采购出库及签收权限,请联系管理员开通。' : '')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-workflow', label: title, isOpen: open, model: form, requiredPermission: permission })
|
||||
let fingerprint = '', requestKey = '', openedScope = '', dialogGeneration = 0
|
||||
const scope = () => `${props.customerId}:${props.workflow.orderNo}`
|
||||
watch(scope, () => { dialogGeneration++; open.value = false; error.value = null })
|
||||
watch(open, value => { if (!value) dialogGeneration++ })
|
||||
async function begin(name: string, batch?: ProcurementOutbound) {
|
||||
if (saving.value || uploading.value || checkingFees.value) return
|
||||
const required = name === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
|
||||
if (!can(required)) return
|
||||
const current = ++dialogGeneration
|
||||
if (name === 'outbounds') {
|
||||
checkingFees.value = true
|
||||
try { await props.beforeOutbound() }
|
||||
catch (failure) { if (current === dialogGeneration) ElMessage.warning(getApiErrorDetails(failure, '费用校验失败').message); return }
|
||||
finally { checkingFees.value = false }
|
||||
if (current !== dialogGeneration) return
|
||||
}
|
||||
action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false
|
||||
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = []
|
||||
fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline()
|
||||
if (name === 'review') {
|
||||
warehouseLoading.value = true
|
||||
try { const result = await listOverseasPurchaseWarehouseOptions(props.detail.customerView.order.procurementDirection); if (current === dialogGeneration) warehouses.value = result }
|
||||
catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '仓库加载失败') }
|
||||
finally { if (current === dialogGeneration) warehouseLoading.value = false }
|
||||
}
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = ''
|
||||
if (!files.length || uploading.value) return
|
||||
const current = dialogGeneration; const orderNo = props.workflow.orderNo; const customer = props.customerId; uploading.value = true; error.value = null
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (action.value === 'receipts' && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片')
|
||||
const result = await uploadProcurementFile(orderNo, customer, 'RECEIPT', file)
|
||||
if (current !== dialogGeneration) return
|
||||
form.fileIds.push(result.fileId); uploadedNames.value.push(file.name)
|
||||
}
|
||||
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '上传失败') }
|
||||
finally { uploading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return
|
||||
if (action.value === 'review' && form.decision === 'APPROVE' && !form.warehouseId) { ElMessage.warning('请选择收货仓'); return }
|
||||
if (action.value === 'review' && form.decision === 'REJECT' && !form.reason.trim()) { ElMessage.warning('请填写拒绝原因'); return }
|
||||
if (['receipts', 'outbounds'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
|
||||
if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
|
||||
if (action.value === 'outbounds' && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity)) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return }
|
||||
if (['receipts', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
|
||||
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : action.value === 'outbounds' ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
|
||||
const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data])
|
||||
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
|
||||
const current = dialogGeneration
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined })
|
||||
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
|
||||
if (current !== dialogGeneration) return
|
||||
captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed')
|
||||
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '操作失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
const receiptColumns: DataTableColumn[] = [{ prop: 'quantity', label: '收货数量', width: 140 }, { key: 'receivedAt', label: '收货时间', minWidth: 170, formatter: (row: ProcurementWorkflowReceipt) => dateTime(row.receivedAt) }, { key: 'files', label: '照片', minWidth: 180, slotName: 'files' }]
|
||||
const outboundColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, slotName: 'actions', fixed: 'left' }, { prop: 'outboundNo', label: '出库单号', minWidth: 190 },
|
||||
{ prop: 'quantity', label: '出库数量', width: 110 }, { prop: 'trackingNo', label: '物流跟踪号', minWidth: 190 },
|
||||
{ key: 'status', label: '状态', width: 120, formatter: (row: ProcurementOutbound) => statusLabel(row.status) },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.createdAt) },
|
||||
{ key: 'completedAt', label: '签收时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.completedAt) },
|
||||
{ key: 'files', label: '签收凭证', minWidth: 170, slotName: 'files' },
|
||||
]
|
||||
const fileName = (id: string) => props.detail.files.find(file => file.fileId === id)?.fileName || id
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<section class="procurement-workflow">
|
||||
<div class="density-actions">
|
||||
<el-button v-if="workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')">审核</el-button>
|
||||
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
|
||||
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
|
||||
</div>
|
||||
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
|
||||
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
|
||||
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
|
||||
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><el-button v-if="row.status === 'IN_TRANSIT'" :disabled="!can('ship-customer')" title="开始本批次派送" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" title="上传图片或文件并确认本批次签收" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
|
||||
<AppDialog v-model="open" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save">
|
||||
<el-form :model="form" class="density-form" label-position="top" :disabled="saving">
|
||||
<template v-if="action === 'review'"><el-form-item label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></el-form-item></template>
|
||||
<el-form-item v-if="['receipts', 'outbounds'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : detail.customerView.order.purchaseQuantity - workflow.receivedQuantity" :precision="0" /></el-form-item>
|
||||
<el-form-item v-if="action === 'outbounds'" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
|
||||
<el-form-item v-if="['receipts', 'complete'].includes(action)" :label="action === 'receipts' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action === 'receipts' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
|
||||
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.inventory { margin: var(--admin-space-4) 0; }
|
||||
h3 { margin: var(--admin-space-4) 0 var(--admin-space-3); font-size: var(--admin-font-section); }
|
||||
</style>
|
||||
@@ -31,14 +31,14 @@ defineExpose({
|
||||
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="货物信息">
|
||||
@@ -58,8 +58,8 @@ defineExpose({
|
||||
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
|
||||
</div></el-form-item>
|
||||
<div />
|
||||
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { executeUsTruckCommand } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import { availableTruckCommands, truckCommandHints, truckCommandLabels, type TruckCommand, type TruckOperation } from '../truckWorkflow'
|
||||
import type { LocalTransportDetail } from '../types'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses/api/platformWarehouses'
|
||||
|
||||
const props = defineProps<{ detail: LocalTransportDetail }>()
|
||||
const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>()
|
||||
@@ -20,7 +21,8 @@ const open = ref(false), saving = ref(false)
|
||||
const active = ref<TruckCommand | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
|
||||
const pending = ref<TruckOperation | null>(null)
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
|
||||
const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
|
||||
@@ -30,25 +32,33 @@ watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version
|
||||
pending.value = null; open.value = false; active.value = null; error.value = null
|
||||
})
|
||||
|
||||
function start(command: TruckCommand) {
|
||||
async function start(command: TruckCommand) {
|
||||
if (saving.value || !commands.value.includes(command)) return
|
||||
if (pending.value) {
|
||||
active.value = pending.value.command; open.value = true
|
||||
return
|
||||
}
|
||||
active.value = command; error.value = null; validationError.value = ''
|
||||
Object.assign(form, { decision: 'APPROVE', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
|
||||
Object.assign(form, { decision: 'APPROVE', warehouseId: '', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
|
||||
if (command === 'review' || command === 'arrange') {
|
||||
try { warehouses.value = (await listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })).records } catch { warehouses.value = [] }
|
||||
}
|
||||
open.value = true; captureBaseline()
|
||||
}
|
||||
|
||||
function buildOperation(): TruckOperation | null {
|
||||
if (!active.value) return null
|
||||
const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined }
|
||||
if ((active.value === 'review' && form.decision === 'APPROVE') || active.value === 'arrange') {
|
||||
if (!form.warehouseId) { validationError.value = '请选择 A 海外仓'; return null }
|
||||
}
|
||||
if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) {
|
||||
validationError.value = '请填写拒绝或取消原因'; return null
|
||||
}
|
||||
switch (active.value) {
|
||||
case 'review': case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
|
||||
case 'review': return { command: active.value, payload: { ...base, decision: form.decision, ...(form.decision === 'APPROVE' ? { warehouseId: form.warehouseId } : {}) } }
|
||||
case 'arrange': return { command: active.value, payload: { ...base, warehouseId: form.warehouseId } }
|
||||
case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
|
||||
case 'receive-pallets':
|
||||
if (!Number.isSafeInteger(form.actualPalletCount) || form.actualPalletCount < Math.max(1, props.detail.receivedPalletCount)) {
|
||||
validationError.value = '累计实收托数须为正整数,且不能少于已实收托数'; return null
|
||||
@@ -70,7 +80,9 @@ function buildOperation(): TruckOperation | null {
|
||||
async function submit() {
|
||||
if (saving.value || !active.value || !commands.value.includes(active.value)) return
|
||||
validationError.value = ''; error.value = null
|
||||
const operation = buildOperation()
|
||||
// Keep an operation immutable after an uncertain response so a retry cannot
|
||||
// accidentally use a different version, idempotency key, or edited payload.
|
||||
const operation = pending.value || buildOperation()
|
||||
if (!operation) return
|
||||
pending.value = operation; saving.value = true
|
||||
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
|
||||
@@ -82,7 +94,7 @@ async function submit() {
|
||||
emit('updated', updated)
|
||||
} catch (cause) {
|
||||
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -98,6 +110,7 @@ async function submit() {
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
|
||||
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="选择 A 海外仓" filterable><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>
|
||||
<template v-if="active === 'receive-pallets'">
|
||||
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
|
||||
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import { getProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/procurementCommands')>(),
|
||||
downloadProcurementFile: vi.fn(),
|
||||
@@ -46,7 +48,7 @@ function memoryStorage(): Storage { const values = new Map<string, string>(); re
|
||||
|
||||
const purchase = {
|
||||
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3,
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'RECEIVING' as const, version: 3,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086',
|
||||
estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
@@ -70,6 +72,7 @@ describe('overseas business mounted pages', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
|
||||
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(downloadProcurementFile).mockReset()
|
||||
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
|
||||
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
|
||||
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
|
||||
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
|
||||
@@ -161,12 +164,13 @@ describe('overseas business mounted pages', () => {
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
|
||||
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
|
||||
expect(wrapper.findAll('.density-fields')).toHaveLength(3)
|
||||
expect(wrapper.text()).toContain('洛杉矶采购仓')
|
||||
expect(wrapper.findAll('.density-fields')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('预算单价')
|
||||
expect(wrapper.text()).toContain('两段实际交寄')
|
||||
expect(wrapper.text()).not.toContain('库存信息')
|
||||
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('收货记录')
|
||||
expect(wrapper.text()).toContain('出库单')
|
||||
expect(wrapper.text()).toContain('采购库存')
|
||||
expect(wrapper.text()).not.toContain('两段实际交寄')
|
||||
expect(wrapper.text()).not.toContain('internal-purchase-route')
|
||||
await wrapper.get('.density-back').trigger('click')
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
|
||||
@@ -177,12 +181,13 @@ describe('overseas business mounted pages', () => {
|
||||
const result = procurementDetail()
|
||||
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
|
||||
let rejectDownload!: (error: Error) => void
|
||||
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
useAuthStore().permissions = ['platform:overseas-business:purchase:list']
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === '下载')
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === 'label.pdf')
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click'); await flushPromises()
|
||||
expect(downloadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', '9007199254741001')
|
||||
@@ -194,18 +199,14 @@ describe('overseas business mounted pages', () => {
|
||||
expect(document.body.textContent).toContain('文件下载失败')
|
||||
})
|
||||
|
||||
it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => {
|
||||
it('uses the shared overseas fees panel for the exact procurement customer and document', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
const result = procurementDetail()
|
||||
result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已冻结,未扣款')
|
||||
expect(wrapper.text()).toContain('预估不入池')
|
||||
expect(wrapper.text()).not.toContain('订单全部入库后结算')
|
||||
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-100', customerId: '10001', businessId: '9007199254740993' }))
|
||||
expect(wrapper.text()).not.toContain('预估不入池')
|
||||
})
|
||||
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
|
||||
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
|
||||
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
|
||||
@@ -223,7 +224,7 @@ describe('overseas business mounted pages', () => {
|
||||
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
|
||||
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
|
||||
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
|
||||
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
|
||||
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('收货记录'); expect(wrapper.text()).toContain('出库单'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
|
||||
|
||||
it('submits only the internal remark and preserves input after a version conflict', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
@@ -276,10 +277,10 @@ describe('overseas business mounted pages', () => {
|
||||
expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0)
|
||||
expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD')
|
||||
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
|
||||
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([
|
||||
'客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地',
|
||||
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
|
||||
])
|
||||
const routeLabels = domain === 'truck'
|
||||
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
|
||||
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
|
||||
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
|
||||
Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' })
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
|
||||
await submit.trigger('click'); await flushPromises()
|
||||
@@ -307,7 +308,7 @@ describe('overseas business mounted pages', () => {
|
||||
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
|
||||
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
|
||||
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
|
||||
it('requires split contacts and both local times, rejecting reversed times', async () => {
|
||||
|
||||
@@ -133,7 +133,7 @@ onMounted(() => { void load(); void loadContext() })
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench">
|
||||
<ListPageLayout aria-label="海外费用管理">
|
||||
<ListPageLayout aria-label="海外费用明细">
|
||||
<section class="workbench-table is-page-surface">
|
||||
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane label="费用明细" name="fees">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -9,7 +8,6 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -17,136 +15,60 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
|
||||
import { remainingLabels, decisionLabels } from '../procurementControlForm'
|
||||
import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
|
||||
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
|
||||
|
||||
import { directionLabel, regionText } from '../procurementDirection'
|
||||
import { statusLabel, money, dateTime, text } from '../procurementPresentation'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { directionLabel } from '../procurementDirection'
|
||||
import { requireReviewedProcurementFees } from '../procurementOutboundFees'
|
||||
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
|
||||
const downloading = ref<string | null>(null)
|
||||
const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null), downloading = ref('')
|
||||
const order = computed(() => detail.value?.customerView.order)
|
||||
const t = (text: string) => regionText(text, order.value?.procurementDirection)
|
||||
const feePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
|
||||
async function beforeOutbound() {
|
||||
if (!feePanel.value) throw new Error('费用明细尚未加载完成,请稍后重试')
|
||||
requireReviewedProcurementFees(await feePanel.value.getFees())
|
||||
}
|
||||
const fields = computed(() => [
|
||||
['采购方向', directionLabel(order.value?.procurementDirection)],
|
||||
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
|
||||
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
|
||||
[t('预计到海外仓'), dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
|
||||
] as const)
|
||||
const purchaseFields = computed(() => [
|
||||
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
|
||||
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
|
||||
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
|
||||
] as const)
|
||||
const receiptColumns: DataTableColumn[] = [
|
||||
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
|
||||
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
|
||||
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
|
||||
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
|
||||
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
|
||||
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
|
||||
]
|
||||
const dispositionColumns: DataTableColumn[] = [
|
||||
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
|
||||
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
|
||||
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
|
||||
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
|
||||
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
|
||||
]
|
||||
const cartonColumns: DataTableColumn[] = [
|
||||
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
|
||||
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
|
||||
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
|
||||
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
|
||||
]
|
||||
const shipmentColumns: DataTableColumn[] = [
|
||||
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
|
||||
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
|
||||
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
|
||||
]
|
||||
const feeColumns: DataTableColumn[] = [
|
||||
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => t(feePurposes[row.purpose] || '待核对') },
|
||||
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
|
||||
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
|
||||
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
|
||||
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
|
||||
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
|
||||
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
|
||||
]
|
||||
const labelGroups = computed(() => detail.value ? [
|
||||
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
|
||||
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
|
||||
].filter(group => group.value) : [])
|
||||
const labelColumns: DataTableColumn[] = [
|
||||
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
|
||||
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
|
||||
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
|
||||
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
|
||||
]
|
||||
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
|
||||
const noteColumns: DataTableColumn[] = [
|
||||
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
|
||||
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
|
||||
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
|
||||
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
|
||||
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
|
||||
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
|
||||
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
|
||||
]
|
||||
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
|
||||
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
|
||||
const closureItemColumns: DataTableColumn[] = [
|
||||
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
|
||||
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
|
||||
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
|
||||
]
|
||||
const correctionColumns: DataTableColumn[] = [
|
||||
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
|
||||
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
|
||||
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
|
||||
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
|
||||
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
|
||||
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
|
||||
]
|
||||
const fileColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', slotName: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
|
||||
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
|
||||
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
|
||||
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
|
||||
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
|
||||
['客户', order.value?.customerName], ['采购方向', directionLabel(order.value?.procurementDirection)], ['收货仓', order.value?.actualWarehouseName || order.value?.plannedWarehouseName],
|
||||
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification],
|
||||
['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress],
|
||||
...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []),
|
||||
])
|
||||
let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = ''
|
||||
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
function load() {
|
||||
const currentScope = scope.value
|
||||
if (inFlight && inFlightScope === currentScope) return inFlight
|
||||
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
|
||||
detail.value = null; loadError.value = null; loading.value = false
|
||||
detail.value = null; workflow.value = null; loadError.value = null; loading.value = false
|
||||
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
|
||||
loading.value = true; inFlightScope = currentScope
|
||||
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
|
||||
const promise = Promise.all([getOverseasPurchase(number, customer), getProcurementWorkflow(number, customer)]).then(([result, process]) => {
|
||||
if (current === generation) { detail.value = result; workflow.value = process }
|
||||
}).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
|
||||
if (current === generation) loading.value = false
|
||||
if (inFlight === promise) inFlight = null
|
||||
})
|
||||
inFlight = promise; return promise
|
||||
}
|
||||
async function download(fileId: unknown) {
|
||||
async function download(fileId: string) {
|
||||
if (!order.value || downloading.value) return
|
||||
const file = detail.value?.files.find(item => item.fileId === fileId)
|
||||
if (!file || !order.value || downloading.value) return
|
||||
downloading.value = file.fileId; operationError.value = null; const current = scope.value
|
||||
downloading.value = fileId; operationError.value = null; const current = scope.value
|
||||
try {
|
||||
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
|
||||
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, fileId)
|
||||
if (scope.value !== current) return
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file?.fileName || fileId; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
|
||||
finally { downloading.value = null }
|
||||
finally { downloading.value = '' }
|
||||
}
|
||||
let activatedOnce = false
|
||||
watch(scope, () => { void load() }, { immediate: true })
|
||||
@@ -154,29 +76,14 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
|
||||
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单履约详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status, order.procurementDirection)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
|
||||
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
|
||||
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑备注</el-button></div></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-grid">
|
||||
<template v-if="detail">
|
||||
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
|
||||
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
|
||||
<section><h3>分次实收记录</h3><div class="workbench-table"><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></div></section>
|
||||
<section><h3>差异处置记录</h3><div class="workbench-table"><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></div></section>
|
||||
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><div class="workbench-table"><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></div></section>
|
||||
<section v-if="detail.transport.domesticCargo.length"><h3>{{ t('国内包装与可寄货物核对') }}</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
|
||||
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><div class="workbench-table"><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></div></section>
|
||||
<section><h3>两段实际交寄</h3><div class="workbench-table"><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></div></section>
|
||||
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></div></section>
|
||||
<section><h3>费用更正记录<InlineHelp title="费用更正说明"><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></div></section>
|
||||
<section><h3>事实说明与阻断</h3><div class="workbench-table"><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></div></section>
|
||||
<section><h3>剩余货物去向核对</h3><div class="workbench-table"><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></div></section>
|
||||
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><div class="workbench-table"><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /></div><div class="workbench-table"><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></div></section>
|
||||
<section><h3>本单文件</h3><div class="workbench-table"><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></div></section>
|
||||
<section><h3>操作流水</h3><div class="workbench-table"><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /></div><div class="workbench-table"><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></div></section>
|
||||
<template v-if="detail && order && workflow">
|
||||
<section><h3>采购信息</h3><dl class="density-fields"><div v-for="field in fields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<ProcurementWorkflowPanel :detail="detail" :workflow="workflow" :customer-id="customerId" :downloading="downloading" :before-outbound="beforeOutbound" @changed="load" @download="download" />
|
||||
<BusinessDocumentFees ref="feePanel" business-type="OVERSEAS_PROCUREMENT" :business-no="order.orderNo" :customer-id="customerId" :business-id="order.id" />
|
||||
</template>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
@@ -186,6 +93,4 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.detail-grid section { min-width: 0; }
|
||||
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
|
||||
.detail-grid p { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.density-fields > .wide { grid-column: span 2; }
|
||||
</style>
|
||||
|
||||
@@ -28,10 +28,10 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label: ({ WAIT_OVERSEAS_RECEIPT: '待起运仓收货', OVERSEAS_RECEIVING: '起运地收货中', WAIT_OVERSEAS_SHIPMENT: '待国际交寄', DOMESTIC_RECEIVING: '目的地收货中', WAIT_CHARGES: '待目的地核费' } as Record<string, string>)[value] || label })) },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
@@ -76,7 +76,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
|
||||
|
||||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
|
||||
<template #toolbar-start><h1>采购订单</h1></template>
|
||||
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function mountDetail(manage = true) {
|
||||
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
|
||||
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true } } })
|
||||
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true, ElSelect: true, ElOption: true } } })
|
||||
}
|
||||
async function click(wrapper: VueWrapper, label: string) {
|
||||
const button = wrapper.findAll('button').find(item => item.text() === label)
|
||||
@@ -43,10 +43,14 @@ async function click(wrapper: VueWrapper, label: string) {
|
||||
await button!.trigger('click'); await flushPromises()
|
||||
}
|
||||
async function confirm(wrapper: VueWrapper) {
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
const form = wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' })
|
||||
if (form.exists() && form.props('model').decision === 'APPROVE') form.props('model').warehouseId = '9'
|
||||
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
}
|
||||
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
|
||||
|
||||
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: '9', warehouseCode: 'WH-9', warehouseName: '海外仓' }] }) }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -78,21 +82,12 @@ describe('truck review and fulfillment', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
|
||||
})
|
||||
|
||||
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
|
||||
it('loads customer scoped documents and moves approval directly to dispatch', async () => {
|
||||
let current = order()
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
|
||||
const next = { ...current, version: current.version + 1 }
|
||||
switch (operation.command) {
|
||||
case 'review': next.status = 'WAITING_INBOUND'; break
|
||||
case 'receive-pallets':
|
||||
next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2
|
||||
next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' }))
|
||||
break
|
||||
case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break
|
||||
case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break
|
||||
case 'dispatch': next.status = 'DISPATCHED'; break
|
||||
case 'start-transit': next.status = 'IN_TRANSIT'; break
|
||||
case 'complete': next.status = 'COMPLETED'; break
|
||||
case 'review': next.status = 'PENDING_DISPATCH'; break
|
||||
}
|
||||
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
|
||||
current = next; return next
|
||||
@@ -100,79 +95,61 @@ describe('truck review and fulfillment', () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
|
||||
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
|
||||
await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过')
|
||||
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
|
||||
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
|
||||
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
expect(current.status).toBe('COMPLETED')
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
|
||||
await click(wrapper, '审核并生成排车'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('排车审核'); expect(wrapper.text()).toContain('资料核对通过')
|
||||
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘')
|
||||
expect(current.status).toBe('PENDING_DISPATCH')
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1])
|
||||
})
|
||||
|
||||
it('requires a rejection reason and displays the resulting rejected state', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
|
||||
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
|
||||
formModel(wrapper).remark = '取送时间需重新确认'
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
|
||||
await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
|
||||
})
|
||||
|
||||
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
|
||||
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('订单版本冲突')
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
|
||||
wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await click(wrapper, '审核'); await confirm(wrapper)
|
||||
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await click(wrapper, '审核并生成排车'); await confirm(wrapper)
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
expect(wrapper.text()).toContain('排车审核')
|
||||
})
|
||||
|
||||
it('uses delivery as the last flow node and omits point aliases', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'COMPLETED' }))
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.findAll('.truck-flow-label').map(node => node.text())).toEqual(['待审核', '排车审核', '取货', '派送中', '送达'])
|
||||
expect(wrapper.findAll('.truck-flow li.done')).toHaveLength(5)
|
||||
expect(wrapper.text()).not.toMatch(/B点|C点|B\/C/)
|
||||
})
|
||||
|
||||
it('locks duplicate submission until the original response arrives', async () => {
|
||||
let finish!: (value: LocalTransportDetail) => void
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车'); await confirm(wrapper); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
|
||||
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
finish(order({ status: 'PENDING_DISPATCH', version: 2 })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车审核')
|
||||
})
|
||||
|
||||
it('validates cumulative receipt totals without submitting invalid input', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收')
|
||||
formModel(wrapper).actualPalletCount = 1; await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数')
|
||||
Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('hides obsolete pallet actions after approval and keeps the standalone order read-only', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PENDING_DISPATCH', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
|
||||
it('requires received pallets and permits partial allocation using their string IDs', async () => {
|
||||
const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null }
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
formModel(wrapper).palletIds = [pallet.id]
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] }))
|
||||
await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车')
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) }))
|
||||
})
|
||||
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 }))
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核')
|
||||
Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) }))
|
||||
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘'); expect(wrapper.text()).not.toContain('超托审核')
|
||||
})
|
||||
|
||||
it('requires cancellation reason and submits only the cancellation status', async () => {
|
||||
@@ -185,7 +162,7 @@ describe('truck review and fulfillment', () => {
|
||||
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
|
||||
const wrapper = mountDetail(false); await flushPromises()
|
||||
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, MapPin } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
@@ -16,7 +16,7 @@ import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import TruckOrderActions from '../components/TruckOrderActions.vue'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { truckStatusLabels } from '../truckWorkflow'
|
||||
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
|
||||
import type { LocalTransportDetail, OverseasBusinessHistory } from '../types'
|
||||
|
||||
const route = useRoute(), router = useRouter()
|
||||
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
@@ -24,33 +24,43 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
|
||||
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const flowSteps = ['待审核', '排车审核', '取货', '派送中', '送达']
|
||||
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
const statusLabel = (value: string | null) => value ? truckStatusLabels[value as keyof typeof truckStatusLabels] || value : '—'
|
||||
const flowIndex = computed(() => {
|
||||
const status = detail.value?.status
|
||||
if (status === 'PENDING_REVIEW') return 0
|
||||
if (status === 'IN_TRANSIT') return 3
|
||||
if (status === 'COMPLETED') return 4
|
||||
if (status === 'CANCELLED' || status === 'REJECTED') return 0
|
||||
return 1
|
||||
})
|
||||
const fields = computed(() => detail.value ? [
|
||||
['客户编号', detail.value.customerId], ['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
|
||||
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
|
||||
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
|
||||
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
|
||||
['申报件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
|
||||
['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
|
||||
['取货地', detail.value.origin], ['取货地址', detail.value.originAddress],
|
||||
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
|
||||
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
|
||||
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
|
||||
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
|
||||
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
|
||||
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
|
||||
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
|
||||
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
|
||||
] : [])
|
||||
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
|
||||
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '实收件数', width: 120 },
|
||||
{ key: 'receivedAt', label: '实收时间', minWidth: 170, formatter: (row: LocalTransportPallet) => dateTime(row.receivedAt) },
|
||||
]
|
||||
const operations: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', RECEIVE_PALLETS: '登记实收', REVIEW_PALLET_OVERAGE: '超托审核', ALLOCATE_PALLETS: '分配托盘', PREPARE_DISPATCH: '确认待派车', DISPATCH: '确认已派车', START_TRANSIT: '确认发运', COMPLETE: '确认完成', CANCEL: '取消订单' }
|
||||
const operations: Record<string, string> = {
|
||||
CREATE: '创建', UPDATE: '编辑', REVIEW: '审核', TRANSPORT_ASSIGN: '接入排车',
|
||||
RECEIVE_PALLETS: '取货', START_TRANSIT: '派送中', COMPLETE: '送达', LOCAL_TRUCK_DEPART: '取货完成', LOCAL_TRUCK_COMPLETE: '送达', CANCEL: '取消',
|
||||
DISPATCH: '接入排车', PREPARE_DISPATCH: '接入排车',
|
||||
}
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
|
||||
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
|
||||
{ key: 'createdAt', label: '时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作', minWidth: 120, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'fromStatus', label: '原状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
|
||||
{ key: 'toStatus', label: '新状态', width: 120, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', prop: 'remark', label: '说明', minWidth: 220 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (saving.value) return
|
||||
const sequence = ++requestSequence
|
||||
@@ -84,11 +94,21 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
|
||||
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section>
|
||||
|
||||
<section class="truck-detail-section truck-flow-section" aria-label="运输流程">
|
||||
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div>
|
||||
<ol class="truck-flow">
|
||||
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }">
|
||||
<span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span>
|
||||
<span class="truck-flow-label">{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
|
||||
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
|
||||
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
|
||||
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2><span class="truck-flow-caption">保留关键节点</span></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
</template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
@@ -99,4 +119,25 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
.truck-detail-header .status-badge { flex-shrink: 0; }
|
||||
.truck-detail-actions { flex-wrap: wrap; }
|
||||
.truck-detail-section { margin-bottom: var(--admin-space-6); }
|
||||
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.truck-section-heading h2 { margin: 0; font-size: 16px; color: var(--cc-text); }
|
||||
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-detail-note { margin: 10px 0 0; }
|
||||
.truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
|
||||
.truck-flow li::before { content: ''; position: absolute; top: 15px; left: -50%; width: 100%; height: 1px; background: var(--cc-border); }
|
||||
.truck-flow li:first-child::before { display: none; }
|
||||
.truck-flow li.done::before, .truck-flow li.active::before { background: var(--el-color-primary); }
|
||||
.truck-flow-marker { position: relative; z-index: 1; display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--cc-border); border-radius: 50%; background: var(--cc-surface); color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-flow li.done .truck-flow-marker, .truck-flow li.active .truck-flow-marker { border-color: var(--el-color-primary); color: var(--el-color-primary); }
|
||||
.truck-flow li.active .truck-flow-marker { background: var(--el-color-primary); color: #fff; box-shadow: 0 0 0 4px color-mix(in srgb, var(--el-color-primary) 14%, transparent); }
|
||||
.truck-flow-label { font-size: 12px; white-space: nowrap; }
|
||||
.truck-flow li.active .truck-flow-label { color: var(--el-color-primary); font-weight: 600; }
|
||||
.truck-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); overflow: hidden; background: var(--cc-border); }
|
||||
.truck-detail-fact { min-width: 0; display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 11px 14px; background: var(--cc-surface); }
|
||||
.truck-detail-fact dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-detail-fact dd { min-width: 0; margin: 0; color: var(--cc-text); overflow-wrap: anywhere; font-size: 13px; }
|
||||
.truck-detail-fact:last-child { grid-column: span 2; }
|
||||
@media (max-width: 1100px) { .truck-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 760px) { .truck-flow { overflow-x: auto; grid-template-columns: repeat(5, 100px); padding-bottom: 4px; } .truck-detail-grid { grid-template-columns: 1fr; } .truck-detail-fact:last-child { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { requireReviewedProcurementFees } from './procurementOutboundFees'
|
||||
|
||||
describe('procurement outbound fee gate', () => {
|
||||
const approved = { direction: 'RECEIVABLE' as const, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }
|
||||
it('requires a current receivable, excluding voided fees and payables', () => {
|
||||
for (const rows of [[], [{ ...approved, sourceStatus: 'VOIDED' }], [{ ...approved, direction: 'PAYABLE' as const }]]) {
|
||||
expect(() => requireReviewedProcurementFees(rows)).toThrow('请先添加费用明细')
|
||||
}
|
||||
})
|
||||
it('requires every current fee to be active and approved', () => {
|
||||
for (const reviewStatus of ['PENDING', 'REJECTED']) {
|
||||
expect(() => requireReviewedProcurementFees([approved, { ...approved, reviewStatus }])).toThrow('尚未全部审核通过')
|
||||
}
|
||||
expect(() => requireReviewedProcurementFees([approved, { ...approved, sourceStatus: 'VOIDING' }])).toThrow('尚未全部审核通过')
|
||||
expect(() => requireReviewedProcurementFees([approved, { ...approved, sourceStatus: 'VOIDED', reviewStatus: 'REJECTED' }])).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { OverseasFee } from './api/overseasFees'
|
||||
|
||||
export function requireReviewedProcurementFees(rows: Pick<OverseasFee, 'direction' | 'sourceStatus' | 'reviewStatus'>[]) {
|
||||
const fees = rows.filter(fee => fee.direction === 'RECEIVABLE' && fee.sourceStatus !== 'VOIDED')
|
||||
if (!fees.length) throw new Error('请先添加费用明细并审核通过后再出库')
|
||||
if (fees.some(fee => fee.sourceStatus !== 'ACTIVE' || fee.reviewStatus !== 'APPROVED')) throw new Error('费用明细尚未全部审核通过,请先完成费用审核后再出库')
|
||||
}
|
||||
@@ -2,21 +2,19 @@ import { regionText } from './procurementDirection'
|
||||
import type { Amount, ProcurementFee } from './procurementTypes'
|
||||
|
||||
export const procurementStatuses: Record<string, string> = {
|
||||
PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中',
|
||||
WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中',
|
||||
DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户',
|
||||
REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭',
|
||||
PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
|
||||
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
|
||||
}
|
||||
export const feePurposes: Record<string, string> = {
|
||||
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
|
||||
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
|
||||
}
|
||||
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) }
|
||||
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
|
||||
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
|
||||
if (status === 'CANCELLED') return 'info'
|
||||
if (status === 'INTERNATIONAL_TRANSIT') return 'primary'
|
||||
if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary'
|
||||
return 'warning'
|
||||
}
|
||||
export function cents(value: Amount | null | undefined): bigint | null {
|
||||
|
||||
@@ -6,7 +6,7 @@ export const overseasBusinessRoutes = [
|
||||
path: 'overseas-business/fees',
|
||||
name: 'overseas-business-fees',
|
||||
component: () => import('./pages/OverseasFeesView.vue'),
|
||||
meta: { title: '海外费用管理', permission: 'platform:overseas-fee:list' },
|
||||
meta: { title: '海外费用明细', permission: 'platform:overseas-fee:list' },
|
||||
},
|
||||
{
|
||||
path: 'overseas-business/purchases',
|
||||
|
||||
@@ -32,10 +32,14 @@ export function createTransportDemandForm(): TransportDemandForm {
|
||||
}
|
||||
}
|
||||
|
||||
const requiredText = {
|
||||
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
|
||||
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
|
||||
} as const
|
||||
const requiredText = (kind: TransportDemandKind) => ({
|
||||
origin: kind === 'truck' ? 'B点取货地' : '起运地',
|
||||
destination: kind === 'truck' ? 'C点送达地' : '目的地',
|
||||
originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人',
|
||||
originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话',
|
||||
destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人',
|
||||
destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话',
|
||||
} as const)
|
||||
|
||||
export function localMinuteInput(value: unknown) {
|
||||
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
|
||||
@@ -43,8 +47,9 @@ export function localMinuteInput(value: unknown) {
|
||||
|
||||
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||
const rules: FormRules<TransportDemandForm> = {}
|
||||
for (const [field, label] of Object.entries(requiredText)) {
|
||||
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||
const labels = requiredText(kind)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||
}
|
||||
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
@@ -52,23 +57,26 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||
}
|
||||
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
|
||||
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
|
||||
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
|
||||
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
|
||||
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
|
||||
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
|
||||
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
|
||||
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change',
|
||||
}]
|
||||
return rules
|
||||
}
|
||||
|
||||
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
|
||||
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
|
||||
for (const [field, label] of Object.entries(requiredText)) {
|
||||
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
|
||||
const labels = requiredText(kind)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
|
||||
}
|
||||
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
|
||||
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
|
||||
for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) {
|
||||
if (!form[field]) return `请选择${label}`
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
|
||||
}
|
||||
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
|
||||
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return `${deliveryLabel}不能早于${pickupLabel}`
|
||||
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
|
||||
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
|
||||
}
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import type { LocalTransportDetail, LocalTransportStatus } from './types'
|
||||
|
||||
export const truckStatusLabels: Record<LocalTransportStatus, string> = {
|
||||
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
|
||||
PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配',
|
||||
PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
|
||||
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货',
|
||||
PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货',
|
||||
PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
|
||||
}
|
||||
export const truckCommandLabels = {
|
||||
review: '审核', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
|
||||
review: '审核并生成排车', arrange: '补建排车单', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
|
||||
'prepare-dispatch': '确认待派车', dispatch: '确认已派车', 'start-transit': '确认发运', complete: '确认完成', cancel: '取消订单',
|
||||
} as const
|
||||
export type TruckCommand = keyof typeof truckCommandLabels
|
||||
export interface TruckCommandBase { version: number; idempotencyKey: string; remark?: string }
|
||||
export type TruckOperation =
|
||||
| { command: 'review' | 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
|
||||
| { command: 'review'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT'; warehouseId?: string } }
|
||||
| { command: 'arrange'; payload: TruckCommandBase & { warehouseId: string } }
|
||||
| { command: 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
|
||||
| { command: 'receive-pallets'; payload: TruckCommandBase & { actualPalletCount: number; packageCount?: number; palletPackageCounts?: number[] } }
|
||||
| { command: 'allocate-pallets'; payload: TruckCommandBase & { palletIds: string[] } }
|
||||
| { command: 'prepare-dispatch' | 'dispatch' | 'start-transit' | 'complete'; payload: TruckCommandBase }
|
||||
| { command: 'cancel'; payload: TruckCommandBase & { status: 'CANCELLED' } }
|
||||
|
||||
const nextCommands: Partial<Record<LocalTransportStatus, TruckCommand>> = {
|
||||
PENDING_REVIEW: 'review', WAITING_INBOUND: 'receive-pallets', RECEIVING: 'receive-pallets',
|
||||
PALLET_OVERAGE_REVIEW: 'review-pallet-overage', WAITING_CONSOLIDATION: 'allocate-pallets',
|
||||
PARTIALLY_ALLOCATED: 'allocate-pallets', ALLOCATED: 'prepare-dispatch', PENDING_DISPATCH: 'dispatch',
|
||||
DISPATCHED: 'start-transit', IN_TRANSIT: 'complete',
|
||||
}
|
||||
const cancellable = new Set<LocalTransportStatus>(['PENDING_REVIEW', 'WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'WAITING_CONSOLIDATION', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH'])
|
||||
|
||||
/** 拼车来源只读,独立单据只能执行当前状态对应的具名命令。 */
|
||||
/** 拼车来源只读;原单只保留审核入口,排车后的执行动作统一在排车任务完成。 */
|
||||
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
|
||||
if (order.fleetArrangement) return []
|
||||
const next = nextCommands[order.status]
|
||||
return [...(next ? [next] : []), ...(cancellable.has(order.status) ? ['cancel' as const] : [])]
|
||||
if (order.status === 'PENDING_REVIEW') return ['review', 'cancel']
|
||||
return order.status === 'PENDING_DISPATCH' ? ['arrange', 'cancel'] : []
|
||||
}
|
||||
|
||||
export const truckCommandHints: Record<TruckCommand, string> = {
|
||||
review: '请核对车型、路线、两端联系信息、取送时间和货物资料。通过后进入待入仓,拒绝后结束本次申请。',
|
||||
review: '核对基础资料后进入排车审核。审核通过后,取货照片、派送中和送达照片均在排车单中完成。',
|
||||
arrange: '选择真实 A 海外仓,补建本土卡车排车单。',
|
||||
'receive-pallets': '按实际收货填写截至本次的累计实收托数。首次实收确定本单托数,后续超出已确认托数需要超托审核。',
|
||||
'review-pallet-overage': '通过后将超收托盘纳入本单;拒绝后将超收托盘标记为拒收,按原已确认托数继续。',
|
||||
'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。',
|
||||
@@ -42,5 +37,5 @@ export const truckCommandHints: Record<TruckCommand, string> = {
|
||||
dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。',
|
||||
'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。',
|
||||
complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。',
|
||||
cancel: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。',
|
||||
cancel: '取消后本单不能继续履约,请填写取消原因。',
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
|
||||
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
|
||||
}
|
||||
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'WAIT_PURCHASE' | 'WAIT_OVERSEAS_RECEIPT' | 'OVERSEAS_RECEIVING' | 'WAIT_PACKING' | 'WAIT_LABELS' | 'WAIT_OVERSEAS_SHIPMENT' | 'INTERNATIONAL_TRANSIT' | 'DOMESTIC_RECEIVING' | 'WAIT_CHARGES' | 'WAIT_CUSTOMER_SHIPMENT' | 'SHIPPED_TO_CUSTOMER' | 'REJECTED' | 'CANCELLED' | 'EXCEPTION_CLOSED'
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED'
|
||||
export interface OverseasPurchaseQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
|
||||
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
|
||||
|
||||
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
|
||||
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
||||
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
||||
describe('outsourced business HTTP contract', () => {
|
||||
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
|
||||
const license: OutsourcedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
|
||||
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
|
||||
vi.mocked(http.get).mockResolvedValue(blob)
|
||||
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
|
||||
await expect(getOutsourcedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
|
||||
})
|
||||
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function saveOutsourcedBusinessOperations(no: string, body: Outsour
|
||||
}
|
||||
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
|
||||
requireSlot(slot)
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
|
||||
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
|
||||
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
|
||||
@@ -68,7 +68,7 @@ export async function getOutsourcedBusinessPhotoContent(no: string, photo: Outso
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
|
||||
await downloadError(blob, 200)
|
||||
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
|
||||
if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
|
||||
return blob
|
||||
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
|
||||
const base = '/platform/v1/last-leg/fleet/owned-business'
|
||||
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
||||
describe('owned business HTTP contract', () => {
|
||||
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
|
||||
const license: OwnedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
|
||||
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
|
||||
vi.mocked(http.get).mockResolvedValue(blob)
|
||||
await expect(getOwnedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
|
||||
await expect(getOwnedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
await expect(getOwnedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
|
||||
})
|
||||
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
|
||||
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
|
||||
@@ -76,3 +85,11 @@ describe('owned business HTTP contract', () => {
|
||||
await expect(uploadOwnedBusinessPhoto('DS-OWN-1','FUEL_START',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts unknown transport ownership without guessing current master data', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, taskType: 'LOCAL_TRUCK', actualPalletCount: null, vehicle: { ...detail.row.vehicle, ownership: null } } } })
|
||||
const result = await getOwnedBusiness(detail.row.dispatchNo)
|
||||
expect(result.row.vehicle.ownership).toBeNull()
|
||||
expect(result.row.actualPalletCount).toBeNull()
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
|
||||
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
|
||||
}
|
||||
function row(value: OwnedBusinessRow): OwnedBusinessRow {
|
||||
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('自有排车来源或阶段不完整,请重新读取')
|
||||
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !(['OWNED','DISPATCHED'].includes(value.vehicle?.ownership || '') || (value.vehicle?.ownership === null && ['LOCAL_TRUCK','PICKUP'].includes(value.taskType || '')))) throw new Error('自有排车来源或阶段不完整,请重新读取')
|
||||
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
|
||||
}
|
||||
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
||||
@@ -38,7 +38,7 @@ export async function saveOwnedBusinessOperations(no: string, body: OwnedBusines
|
||||
return value
|
||||
}
|
||||
export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) {
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
|
||||
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
const value = unwrapApiResponse(await http.post<ApiEnvelope<OwnedBusinessPhoto>, ApiEnvelope<OwnedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
|
||||
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
|
||||
@@ -60,7 +60,7 @@ export async function getOwnedBusinessPhotoContent(no: string, photo: OwnedBusin
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } })
|
||||
await downloadError(blob, 200)
|
||||
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
|
||||
if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
|
||||
return blob
|
||||
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
|
||||
}
|
||||
|
||||
@@ -46,3 +46,11 @@ it('rejects JSON download errors and preserves correlation evidence instead of p
|
||||
vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无权读取', requestId: 'request-evidence', traceId: 'trace-evidence' })
|
||||
await expect(downloadTripOperationsEvidence('TR1', '81')).rejects.toMatchObject({ code: 'A0403', requestId: 'request-evidence', traceId: 'trace-evidence' })
|
||||
})
|
||||
|
||||
it('keeps transport pallet facts unknown while retaining actual shipment quantities', async () => {
|
||||
vi.clearAllMocks()
|
||||
const counts = { physicalPallets: null, palletSpaces: null, ownPackages: '10', externalPackages: '0', externalAgreedAmounts: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'LOCAL-1', destinations: [], externalPalletRates: [], measured: null, costs: null }], total: '1', page: '1', size: '20', pages: '1' }, summary: { ...counts, physicalPallets: '0', palletSpaces: '0', trips: '1', inTransit: '1', completed: '0', costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } })
|
||||
const result = await listTripOperations({ from: '2026-10-26T00:00:00Z', to: '2026-10-28T00:00:00Z' })
|
||||
expect(result.page.records[0]).toMatchObject({ physicalPallets: null, palletSpaces: null, ownPackages: 10, externalPackages: 0 })
|
||||
})
|
||||
|
||||
@@ -15,12 +15,12 @@ export interface TripOperationsSeries { label: string; unit: string; points: { l
|
||||
export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] }
|
||||
export interface TripOperationsRow {
|
||||
tripNo: string; status: string; vehiclePlate: string | null; driverName: string | null; warehouseName: string | null; departedAt: string; completedAt: string | null; destinations: { key: string; label: string }[]
|
||||
physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string
|
||||
physicalPallets: number | null; palletSpaces: number | null; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string
|
||||
externalPalletRates: { currency: string; pricedPalletSpaces: number; agreedAmount: number }[]; measured: TripOperationsMeasured | null; costs: OperationsRowCosts | null
|
||||
}
|
||||
export interface TripOperationsSummary { trips: number; inTransit: number; completed: number; physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; costs: OperationsCosts }
|
||||
export interface TripOperationsResult { page: PageResult<TripOperationsRow>; summary: TripOperationsSummary; charts: TripOperationsCharts }
|
||||
type WireNumbers<T> = { [K in keyof T]: T[K] extends number ? number | string : T[K] }
|
||||
type WireNumbers<T> = { [K in keyof T]: T[K] extends number | null ? number | string | Extract<T[K], null> : T[K] }
|
||||
type WireMoney = { currency: string; amount: string | number }
|
||||
type WireEvidence = Omit<TripOperationsEvidence, 'sizeBytes'> & { sizeBytes: number | string }
|
||||
type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null; evidenceFiles?: WireEvidence[] }
|
||||
@@ -29,7 +29,7 @@ type WireRow = Omit<WireNumbers<TripOperationsRow>, 'externalAgreedAmounts' | 'm
|
||||
type WireSummary = Omit<WireNumbers<TripOperationsSummary>, 'externalAgreedAmounts' | 'costs'> & { externalAgreedAmounts: WireMoney[]; costs: { visible: boolean; unrecordedTrips: string | number | null; unallocatedTrips: string | number | null; totals: WireCostTotal[] } }
|
||||
const costTotals = (rows: WireCostTotal[]): OperationsCostTotal[] => rows.map(r => ({ ...r, directCost: fleetMoney(r.directCost), ownCost: r.ownCost == null ? null : fleetMoney(r.ownCost), externalCost: r.externalCost == null ? null : fleetMoney(r.externalCost) }))
|
||||
const money = (rows: WireMoney[]) => rows.map(r => ({ ...r, amount: fleetMoney(r.amount) }))
|
||||
const counts = (r: WireRow | WireSummary) => ({ physicalPallets: fleetNumber(r.physicalPallets), palletSpaces: fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) })
|
||||
const counts = (r: WireRow | WireSummary) => ({ physicalPallets: r.physicalPallets == null ? null : fleetNumber(r.physicalPallets), palletSpaces: r.palletSpaces == null ? null : fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) })
|
||||
const evidence = (r: WireEvidence): TripOperationsEvidence => {
|
||||
if (typeof r.fileId !== 'string' || !/^[1-9]\d*$/.test(r.fileId) || !r.fileName || !['application/pdf', 'image/jpeg', 'image/png'].includes(r.contentType)) throw new Error('实测凭证信息无效')
|
||||
return { ...r, sizeBytes: fleetNumber(r.sizeBytes) }
|
||||
@@ -40,7 +40,7 @@ type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] }
|
||||
const series = (values: WireSeries[]) => values.map(s => ({ ...s, points: s.points.map(p => ({ ...p, value: fleetNumber(p.value) })) }))
|
||||
export async function listTripOperations(query: TripOperationsQuery): Promise<TripOperationsResult> {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query }))
|
||||
return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } }
|
||||
return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), physicalPallets: fleetNumber(result.summary.physicalPallets), palletSpaces: fleetNumber(result.summary.palletSpaces), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } }
|
||||
}
|
||||
export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null }
|
||||
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null }
|
||||
|
||||
@@ -53,8 +53,8 @@ async function save() {
|
||||
<h3 class="density-dialog-section__title">报价资料</h3>
|
||||
<dl class="density-fields fleet-quote-facts"><div v-for="(fact,index) in facts" :key="index"><dt>{{fact[0]}}</dt><dd>{{fact[1]}}</dd></div></dl>
|
||||
</section>
|
||||
<el-form v-else label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="报价路线与归属">
|
||||
<el-form v-else label-position="left" label-width="130px" class="density-form density-form--outlined density-dialog-sections" :disabled="saving">
|
||||
<section class="density-dialog-section" aria-label="报价路线与归属">
|
||||
<h3 class="density-dialog-section__title">基础信息</h3>
|
||||
<table class="density-table density-table--editable fleet-quote-form"><tbody>
|
||||
<tr><td><el-form-item label="用途"><span>{{form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'}}</span></el-form-item></td><td><el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="saving" /></el-form-item></td></tr>
|
||||
@@ -62,14 +62,14 @@ async function save() {
|
||||
<tr><td><el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item></td><td><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="报价金额">
|
||||
<section class="density-dialog-section" aria-label="报价金额">
|
||||
<h3 class="density-dialog-section__title">报价金额<InlineHelp title="报价金额说明"><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable fleet-quote-form"><tbody>
|
||||
<tr><td><el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td><td><el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="留空表示未报价" /></el-form-item></td><td><el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="留空表示未报价" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="留空表示未报价" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="报价有效期与备注">
|
||||
<section class="density-dialog-section" aria-label="报价有效期与备注">
|
||||
<h3 class="density-dialog-section__title">有效期与备注</h3>
|
||||
<table class="density-table density-table--editable fleet-quote-form"><tbody>
|
||||
<tr><td><el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('outsourced business per-slot photo save',()=>{
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(calls.content).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看资料')!.trigger('click');await flushPromises()
|
||||
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
|
||||
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFi
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !uploadable.value) return
|
||||
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
@@ -62,12 +62,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
<a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
|
||||
<img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
</div>
|
||||
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OutsourcedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
@@ -42,7 +42,7 @@ async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId?
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !uploadable.value) return
|
||||
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
@@ -62,12 +62,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button>
|
||||
<label v-if="uploadable" class="owned-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
<a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
|
||||
<img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
|
||||
<label v-if="uploadable" class="owned-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
</div>
|
||||
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OwnedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PR
|
||||
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
|
||||
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
|
||||
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
|
||||
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
|
||||
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
|
||||
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
|
||||
export interface OutsourcedBusinessRow {
|
||||
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
|
||||
@@ -26,7 +26,7 @@ export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIN
|
||||
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
|
||||
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
|
||||
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
|
||||
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
|
||||
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
|
||||
|
||||
@@ -5,12 +5,13 @@ export type OwnedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEP
|
||||
export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
|
||||
export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
|
||||
export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] }
|
||||
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
|
||||
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
|
||||
export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
|
||||
export interface OwnedBusinessRow {
|
||||
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
|
||||
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
|
||||
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'OWNED' | 'DISPATCHED' }; driver: { name: string | null; phone: string | null }
|
||||
taskType?: 'DELIVERY' | 'LOCAL_TRUCK' | 'PICKUP'
|
||||
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'OWNED' | 'DISPATCHED' | null }; driver: { name: string | null; phone: string | null }
|
||||
destinations: OwnedBusinessDestination[]; actualPalletCount: number | null; operations: OwnedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
|
||||
photoSummary: { loadingCount: number; fuelCount: number }; actions: { edit: OwnedBusinessGate; upload: OwnedBusinessGate }; sourceIssues: string[]
|
||||
}
|
||||
@@ -26,7 +27,7 @@ export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHO
|
||||
export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
|
||||
export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] }
|
||||
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
|
||||
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
|
||||
export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
|
||||
|
||||
@@ -13,7 +13,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeExchangeRateStatus, createExchangeRate, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
|
||||
import { changeExchangeRateStatus, createExchangeRate, getExchangeRate, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
|
||||
@@ -33,10 +33,9 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
const summary = reactive({ latestMonth: '', latestUsdToCnyAverage: null as number | null, updatedBy: '', updatedAt: '' })
|
||||
const form = reactive({ month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 })
|
||||
function params() { const month = String(filters.value.month || ''); return { page: page.value, size: pageSize.value, monthFrom: month || undefined, monthTo: month || undefined, status: (filters.value.status || undefined) as MasterDataStatus | undefined } }
|
||||
async function load() { loading.value = true; error.value = null; try { const [result, latest] = await Promise.all([listExchangeRates(params()), getExchangeRateSummary()]); rows.value = result.records; total.value = result.total; Object.assign(summary, latest) } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listExchangeRates(params()); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
|
||||
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
|
||||
@@ -54,7 +53,6 @@ watch([page, pageSize], load); onMounted(load)
|
||||
<template #toolbar-start><h1>汇率管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
|
||||
<template #before-results><dl class="rate-summary density-detail-overview"><div v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl></template>
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
|
||||
@@ -62,10 +60,10 @@ watch([page, pageSize], load); onMounted(load)
|
||||
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||
</DataTable></div></section>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="editorOpen" :title="editingMonth ? `编辑月度汇率 · ${editingMonth}` : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form class="density-form" label-position="left" label-width="160px" :disabled="saving">
|
||||
<el-form class="density-form density-form--outlined" label-position="left" label-width="160px" :disabled="saving">
|
||||
<table class="density-table density-table--editable" aria-label="月度汇率资料"><tbody>
|
||||
<tr><td><el-form-item label="汇率月份"><span v-if="editingMonth">{{ form.month }}</span><el-date-picker v-else v-model="form.month" type="month" value-format="YYYY-MM" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入美元兑人民币月均汇率" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入汇率" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
<p class="density-dialog-note">汇率方向:1 USD = X CNY</p>
|
||||
|
||||
@@ -46,7 +46,7 @@ watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); w
|
||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="报价用途"><button v-for="tab in ([['PURCHASE','车队采购报价'],['SALE','外接销售报价']] as const)" :key="tab[0]" role="tab" :aria-selected="kind === tab[0]" :class="{ 'is-active': kind === tab[0] }" @click="kind = tab[0]">{{ tab[1] }}</button></div></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车队报价查询" @search="search" @reset="search" /></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export') && access.can('platform:download:list')" :loading="busy" @click="download">导出</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
|
||||
<template #origin="{ row }">{{ row.details.originCity }}</template><template #destination="{ row }">{{ row.details.destinationCode }}</template><template #miles="{ row }">{{ row.details.distanceMiles }}</template><template #currency="{ row }">{{ row.details.currency }}</template>
|
||||
<template #pickup="{ row }">{{ row.details.selfPickupPrice ?? '—' }}</template><template #full="{ row }">{{ row.details.fullDeliveryPrice ?? '—' }}</template><template #loose="{ row }">{{ row.details.looseDeliveryPrice ?? '—' }}</template>
|
||||
<template #validity="{ row }">{{ row.details.validFrom }} — {{ row.details.validTo }}</template><template #status="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
|
||||
|
||||
@@ -85,8 +85,8 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="editorOpen" :title="editingId ? `编辑海外仓报价 · ${form.name.trim() || '—'}` : '新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" confirm-text="保存报价" @confirm="save">
|
||||
<el-form class="density-form density-dialog-sections" label-position="left" label-width="90px" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="报价基础信息">
|
||||
<el-form class="density-form density-form--outlined density-dialog-sections" label-position="left" label-width="90px" :disabled="saving">
|
||||
<section class="density-dialog-section" aria-label="报价基础信息">
|
||||
<h3 class="density-dialog-section__title">基础信息</h3>
|
||||
<table class="density-table density-table--editable quote-basics-table"><tbody>
|
||||
<tr><td><el-form-item label="报价名称"><el-input v-model="form.name" placeholder="请输入报价名称" /></el-form-item></td><td><el-form-item label="海外仓"><span v-if="editingId">{{ editingWarehouse }}</span><el-select v-else v-model="form.warehouseId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item></td></tr>
|
||||
@@ -95,14 +95,14 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
|
||||
<tr><td><el-form-item label="报价版本"><span v-if="editingId">{{form.versionCode || '—'}}</span><el-input v-else v-model="form.versionCode" placeholder="请输入版本号" /></el-form-item></td><td><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="编辑仓储作业费用">
|
||||
<section class="density-dialog-section" aria-label="编辑仓储作业费用">
|
||||
<h3 class="density-dialog-section__title quote-section-heading">仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3>
|
||||
<div class="quote-lines-scroll"><table class="density-table density-table--editable quote-lines-table"><thead><tr><th scope="col">操作</th><th scope="col">费用项目</th><th scope="col">计费单位</th><th scope="col">单价</th><th scope="col">最低收费</th></tr></thead><tbody>
|
||||
<tr v-for="(f,i) in form.warehouseFees" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项费用`" @click="form.warehouseFees.splice(i,1)">删除</el-button></td><td><el-input v-model="f.item" :aria-label="`第${i+1}项费用项目`" placeholder="请输入费用项目"/></td><td><el-input v-model="f.billingUnit" :aria-label="`第${i+1}项计费单位`" placeholder="请输入计费单位"/></td><td><el-input-number v-model="f.unitPrice" :aria-label="`第${i+1}项费用单价`" :min="0" placeholder="请输入单价" /></td><td><el-input-number v-model="f.minimumCharge" :aria-label="`第${i+1}项费用最低收费`" :min="0" placeholder="请输入最低收费" /></td></tr>
|
||||
<tr v-if="!form.warehouseFees.length"><td colspan="5">暂无仓储作业费用</td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="编辑中转派送价格">
|
||||
<section class="density-dialog-section" aria-label="编辑中转派送价格">
|
||||
<h3 class="density-dialog-section__title quote-section-heading">中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3>
|
||||
<div class="quote-lines-scroll"><table class="density-table density-table--editable quote-lines-table"><thead><tr><th scope="col">操作</th><th scope="col">目的FBA仓</th><th scope="col">车型/提货方式</th><th scope="col">报价</th><th scope="col">最低收费</th></tr></thead><tbody>
|
||||
<tr v-for="(t,i) in form.transitPrices" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项派送价格`" @click="form.transitPrices.splice(i,1)">删除</el-button></td><td><el-input v-model="t.fbaWarehouseCode" :aria-label="`第${i+1}项目的FBA仓`" placeholder="请输入目的 FBA 仓"/></td><td><el-input v-model="t.deliveryMethod" :aria-label="`第${i+1}项车型或提货方式`" placeholder="请输入车型或提货方式"/></td><td><el-input-number v-model="t.price" :aria-label="`第${i+1}项派送报价`" :min="0" placeholder="请输入报价" /></td><td><el-input-number v-model="t.minimumCharge" :aria-label="`第${i+1}项派送最低收费`" :min="0" placeholder="请输入最低收费" /></td></tr>
|
||||
|
||||
@@ -27,7 +27,7 @@ const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户'
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
|
||||
const taskFields = computed(() => {
|
||||
const row = detail.value?.row
|
||||
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
|
||||
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], [row?.taskType === 'LOCAL_TRUCK' ? '起点' : '提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
|
||||
})
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
|
||||
|
||||
@@ -36,9 +36,9 @@ const summary = computed(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
|
||||
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
|
||||
{ key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' },
|
||||
{ key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' },
|
||||
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '起点', minWidth: 150 },
|
||||
{ key: 'destinations', label: '目的地', minWidth: 150, slotName: 'destinations' },
|
||||
|
||||
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
||||
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
|
||||
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
|
||||
@@ -50,6 +50,10 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'stage', label: '状态', minWidth: 110, slotName: 'stage' },
|
||||
]
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
// 全列无值时只显示一个占位;多站有值时保留顺序,目的地通过悬停说明。
|
||||
function hasAppointments(row: OwnedBusinessRow) { return row.destinations.some(destination => destination.appointments.length > 0) }
|
||||
function hasScheduledArrival(row: OwnedBusinessRow) { return row.destinations.some(destination => destination.appointments.some(appointment => !!appointment.scheduledAt)) }
|
||||
function hasActualArrival(row: OwnedBusinessRow) { return row.destinations.some(destination => !!destination.actualArrivedAt) }
|
||||
async function load() {
|
||||
if (!active) return
|
||||
const request = ++generation
|
||||
@@ -102,10 +106,10 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
||||
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
||||
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
||||
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
||||
<template #appointments="{ row }"><template v-if="hasAppointments(row as OwnedBusinessRow)"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination" :title="destination.warehouseCode || destination.warehouseName || destination.address || undefined"><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template><span v-else>—</span></template>
|
||||
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination">{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</div><span v-if="!row.destinations.length">—</span></template>
|
||||
<template #scheduledArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div><span v-if="!row.destinations.length">—</span></template>
|
||||
<template #actualArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div>{{ time(destination.actualArrivedAt) }}</div></div><span v-if="!row.destinations.length">—</span></template>
|
||||
<template #scheduledArrival="{ row }"><template v-if="hasScheduledArrival(row as OwnedBusinessRow)"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination" :title="destination.warehouseCode || destination.warehouseName || destination.address || undefined"><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div></template><span v-else>—</span></template>
|
||||
<template #actualArrival="{ row }"><template v-if="hasActualArrival(row as OwnedBusinessRow)"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination" :title="destination.warehouseCode || destination.warehouseName || destination.address || undefined">{{ time(destination.actualArrivedAt) }}</div></template><span v-else>—</span></template>
|
||||
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</template>
|
||||
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}</template><span v-else>未登记</span></template>
|
||||
<template #unitPrice="{ row }"><template v-if="row.operations.money && row.unitPrice != null">{{ row.operations.money.currency }} {{ row.unitPrice }}</template><span v-else>{{ row.operations.money ? '—' : '未登记' }}</span></template>
|
||||
|
||||
@@ -38,19 +38,35 @@ describe('owned business views', () => {
|
||||
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
|
||||
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
|
||||
const columns = wrapper.findComponent(Table).props('columns') as { key?: string; label: string; slotName?: string }[]
|
||||
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的仓', '派送仓库', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
|
||||
expect(columns.find(column => column.key === 'deliveryWarehouses')!.slotName).toBe('destinations')
|
||||
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的地', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
|
||||
expect(columns.filter(column => column.slotName === 'destinations')).toHaveLength(1)
|
||||
expect(wrapper.get('[data-column="money"]').text()).toBe('USD 0.00')
|
||||
expect(wrapper.get('[data-column="unitPrice"]').text()).toBe('USD 0.00')
|
||||
expect(wrapper.get('[data-column="latestLoaded"]').text()).toBe('—')
|
||||
const scheduled = wrapper.findAll('[data-column="scheduledArrival"] .owned-business-destination')
|
||||
expect(scheduled.map(cell => cell.text())).toEqual(['FBA-1' + dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), 'FBA-2—'])
|
||||
expect(scheduled.map(cell => cell.text())).toEqual([dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), '—'])
|
||||
expect(scheduled.map(cell => cell.attributes('title'))).toEqual(['FBA-1', 'FBA-2'])
|
||||
const actual = wrapper.findAll('[data-column="actualArrival"] .owned-business-destination')
|
||||
expect(actual.map(cell => cell.text())).toEqual(['FBA-1—', 'FBA-2' + dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
|
||||
expect(actual.map(cell => cell.text())).toEqual(['—', dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
|
||||
expect(calls.detail).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows one placeholder for missing appointment and arrival values without warehouse names', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
detail.row.destinations = [
|
||||
{ destinationKey: 'one', warehouseCode: null, warehouseName: '洛杉矶仓', address: null, appointments: [], actualArrivedAt: null },
|
||||
{ destinationKey: 'two', warehouseCode: null, warehouseName: '龙华', address: null, appointments: [], actualArrivedAt: null },
|
||||
]
|
||||
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: {} })
|
||||
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
|
||||
for (const column of ['appointments', 'scheduledArrival', 'actualArrival']) {
|
||||
expect(wrapper.get(`[data-column="${column}"]`).text()).toBe('—')
|
||||
}
|
||||
expect(wrapper.get('[data-column="destinations"]').text()).toContain('洛杉矶仓')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
|
||||
|
||||
@@ -36,10 +36,10 @@ const fields: QueryField[] = [
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100, formatter: (row: TripOperationsRow) => row.physicalPallets ?? '—' },
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
|
||||
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
|
||||
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
|
||||
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false, formatter: (row: TripOperationsRow) => row.palletSpaces ?? '—' }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
|
||||
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
|
||||
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
|
||||
function range(): TripOperationsQuery {
|
||||
|
||||
@@ -5,14 +5,14 @@ export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTUR
|
||||
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
|
||||
export const loadingPhotoLabels: Record<LoadingPhotoSlot, string> = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
|
||||
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
|
||||
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
||||
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; reviewRound: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
||||
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; uploadKey: string; replaceFileId?: string }
|
||||
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
|
||||
export async function getLoadingDocuments(no: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
|
||||
}
|
||||
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string, reviewRound: string) {
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key); form.append('reviewRound', reviewRound)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
|
||||
}
|
||||
export async function recoverLoadingPhoto(no: string, key: string) {
|
||||
@@ -23,6 +23,6 @@ export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body:
|
||||
}
|
||||
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
|
||||
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
|
||||
if (!(blob instanceof Blob) || !(['image/jpeg', 'image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料读取失败,请重新核对')
|
||||
return blob
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Resources, ReviewAttachments, Version } from '../dispatchContracts
|
||||
|
||||
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
|
||||
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
|
||||
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
|
||||
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
|
||||
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
|
||||
@@ -13,7 +13,7 @@ const base = '/platform/v1/last-leg/dispatches/transport-tasks'
|
||||
const path = (number: string) => `${base}/${encodeURIComponent(number)}`
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
const normalize = (value: WireDetail): TransportDetail => ({ ...value, execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
|
||||
const normalize = (value: WireDetail): TransportDetail => ({ ...value, departureEvidence: value.departureEvidence || [], completionEvidence: value.completionEvidence || [], execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
|
||||
export const getTransport = (number: string) => get<WireDetail>(path(number)).then(normalize)
|
||||
export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
|
||||
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
|
||||
|
||||
@@ -170,17 +170,18 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||
.evidence-input { display: none; }
|
||||
.dispatch-evidence--photos { min-width: 0; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; }
|
||||
.dispatch-evidence--photos { min-width: 0; display: flex; flex-direction: column; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; flex: 1; display: flex; flex-direction: column; }
|
||||
.evidence-photo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: start; gap: var(--admin-space-3); }
|
||||
.evidence-photo-item { min-width: 0; }
|
||||
.evidence-photo-name { margin: var(--admin-space-2) 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.evidence-photo-actions { justify-content: center; }
|
||||
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
|
||||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; background: var(--el-fill-color-lighter); overflow: hidden; }
|
||||
.evidence-photo-empty { background: var(--el-fill-color-lighter); }
|
||||
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
|
||||
.evidence-photo-card figcaption { margin-top: auto; padding-top: var(--admin-space-3); font-weight: 600; }
|
||||
.evidence-preview { display: grid; place-items: center; height: min(60vh, 600px); min-width: 0; }
|
||||
.evidence-image { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operato
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal<typeof api>(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() }))
|
||||
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
|
||||
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
||||
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
||||
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
|
||||
async function upload(wrapper: ReturnType<typeof setup>, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) {
|
||||
const input = wrapper.get('input[aria-label="上传司机驾驶证"]')
|
||||
@@ -24,34 +24,102 @@ beforeEach(() => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] }))
|
||||
vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable'))
|
||||
})
|
||||
it('refreshes photos added or replaced by PDA without writing and notifies the parent to reload the stage', async () => {
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [photo] }))
|
||||
const wrapper = setup(); await flushPromises()
|
||||
const photos = api.loadingPhotoSlots.flatMap((slot, category) => Array.from({ length: 5 }, (_, index) => ({
|
||||
...photo, slot, fileId: `${category + 2}${index}`, fileName: `pda-${category}-${index}.png`,
|
||||
})))
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos }))
|
||||
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
|
||||
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
|
||||
expect(wrapper.text()).not.toContain(photo.fileName)
|
||||
expect(wrapper.findAll('.loading-document-heading').map(row => row.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}5/5 张`))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
|
||||
vi.mocked(api.getLoadingDocuments).mockRejectedValueOnce(new Error('刷新失败'))
|
||||
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
|
||||
const table = wrapper.get('table[aria-label="装车照片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}0/5 张`))
|
||||
const list = wrapper.get('[aria-label="装车照片"]')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(list.findAll('h3').map(heading => heading.text())).toEqual(Object.values(api.loadingPhotoLabels))
|
||||
expect(list.findAll('.photo-count').map(count => count.text())).toEqual(Array(5).fill('0/5 张'))
|
||||
expect(list.findAll('.photo-empty')).toHaveLength(5)
|
||||
await upload(wrapper)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String), '1')
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2].uploadKey).toBe(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![3])
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('图片读取失败')
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
expect(list.get('.loading-photo-row').attributes('aria-label')).toBe('司机驾驶证第1张')
|
||||
expect(list.text()).not.toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('stops a batch when re-review changes the source during upload instead of rebasing old files', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
vi.mocked(api.uploadLoadingPhoto).mockImplementationOnce(async () => {
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '8', dispatchRecordVersion: '12', reviewRound: '2' }))
|
||||
return photo
|
||||
})
|
||||
await upload(wrapper, [new File(['a'], 'old-driver.png', { type: 'image/png' }), new File(['b'], 'old-driver-2.png', { type: 'image/png' })])
|
||||
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![4]).toBe('1')
|
||||
expect(wrapper.text()).toContain('审核轮次已变化')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows an imported PDF license as an authenticated document and accepts an exact PDF replacement', async () => {
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:license')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [license] }))
|
||||
vi.mocked(api.readLoadingPhoto).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' }))
|
||||
const wrapper = setup()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.get('a').attributes('href')).toBe('blob:license')
|
||||
expect(wrapper.findComponent({ name: 'ElImage' }).exists()).toBe(false)
|
||||
const input = wrapper.get('input[aria-label="更换司机驾驶证第1张"]')
|
||||
expect(input.attributes('accept')).toContain('application/pdf')
|
||||
expect(wrapper.get('input[aria-label="上传车牌照片"]').attributes('accept')).not.toContain('application/pdf')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['%PDF-1.4'], 'new.pdf', { type: 'application/pdf' })] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ replaceFileId: '101' }))
|
||||
} finally { wrapper.unmount(); vi.restoreAllMocks() }
|
||||
})
|
||||
it('shows every photo in a category, stops at five and replaces the selected image only', async () => {
|
||||
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png` }))
|
||||
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png`, savedAt: `2026-10-26T11:49:0${index}.978960362Z` }))
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos }))
|
||||
const replacement = { ...photo, fileId: '999' }
|
||||
vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement)
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) }))
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined()
|
||||
for (const item of photos) expect(wrapper.text()).toContain(item.fileName)
|
||||
const input = wrapper.get('input[aria-label="更换photo-2.png"]')
|
||||
const rows = wrapper.findAll('.loading-photo-row')
|
||||
expect(rows).toHaveLength(5)
|
||||
rows.forEach((row, index) => {
|
||||
expect(row.attributes('aria-label')).toBe(`司机驾驶证第${index + 1}张`)
|
||||
expect(row.get('.photo-time').text()).toMatch(new RegExp(`保存时间:2026-10-26 \\d{2}:49:0${index}$`))
|
||||
expect(row.findAll('input[type="file"]')).toHaveLength(1)
|
||||
})
|
||||
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
|
||||
const input = rows[2]!.get('input[aria-label="更换司机驾驶证第3张"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' }))
|
||||
expect(wrapper.text()).toContain('photo-0.png'); expect(wrapper.text()).toContain('photo-4.png')
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(5)
|
||||
expect(wrapper.findAll('.loading-photo-row')[0]!.get('.photo-time').text()).toMatch(/:49:00$/)
|
||||
expect(wrapper.findAll('.loading-photo-row')[2]!.get('.photo-time').text()).toBe('保存时间:—')
|
||||
expect(wrapper.findAll('.loading-photo-row')[4]!.get('.photo-time').text()).toMatch(/:49:04$/)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('saves a multiple selection with fresh versions and notifies the parent once', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -35,28 +36,35 @@ async function previews() {
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||
try { accept(await getLoadingDocuments(number)); await previews(); return true }
|
||||
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function refresh() { if (await load()) emit('saved') }
|
||||
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
|
||||
function savedTime(value?: string | null) {
|
||||
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
|
||||
}
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) {
|
||||
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
|
||||
if (!files.length || busy.value || !editable.value) return
|
||||
const reviewRound = snapshot.value!.reviewRound
|
||||
const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length
|
||||
if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return }
|
||||
if (files.some(file => !['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
if (files.some(file => !(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return }
|
||||
busy.value = true; error.value = null
|
||||
let saved = 0, reconciled = false, bindingFailed = false
|
||||
let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false
|
||||
try {
|
||||
ensureScope()
|
||||
for (const file of files) {
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const uploadKey = crypto.randomUUID()
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, uploadKey, reviewRound); ensureScope()
|
||||
const latest = await getLoadingDocuments(number); accept(latest)
|
||||
if (latest.reviewRound !== reviewRound) { reconciled = true; roundChanged = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
try {
|
||||
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }))
|
||||
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }))
|
||||
} catch (cause) {
|
||||
// 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。
|
||||
bindingFailed = true
|
||||
@@ -72,7 +80,7 @@ async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: stri
|
||||
}
|
||||
ElMessage.success(`已保存${saved}张照片`)
|
||||
} catch (cause) {
|
||||
if (active) error.value = getApiErrorDetails(cause, bindingFailed
|
||||
if (active) error.value = getApiErrorDetails(cause, roundChanged ? '审核轮次已变化,请核对新车辆、司机后重新选择资料' : bindingFailed
|
||||
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
|
||||
: '图片上传失败,已保存照片保留,请重新选择图片')
|
||||
}
|
||||
@@ -84,33 +92,59 @@ onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<div v-loading="busy" class="loading-documents">
|
||||
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
|
||||
<p>司机驾驶证、车牌照片在理货审核通过后从本趟资料自动带入,可补充或更换;驾驶证支持 PDF。</p>
|
||||
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
|
||||
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="slot in loadingPhotoSlots" :key="slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传图片' }}<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>{{ loadingPhotoLabels[slot] }}<div>{{ photosFor(slot).length }}/5 张</div></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
||||
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||
<label v-if="editable" class="photo-upload">更换<input type="file" accept="image/jpeg,image/png" :aria-label="`更换${photo.fileName}`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" /></label>
|
||||
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<div class="loading-document-list" aria-label="装车照片">
|
||||
<section v-for="slot in loadingPhotoSlots" :key="slot" class="loading-document-category" :aria-label="loadingPhotoLabels[slot]">
|
||||
<header class="loading-document-heading">
|
||||
<h3>{{ loadingPhotoLabels[slot] }}</h3>
|
||||
<span class="photo-count">{{ photosFor(slot).length }}/5 张</span>
|
||||
<label v-if="editable" class="photo-upload category-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">
|
||||
{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传资料' }}
|
||||
<input type="file" multiple :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" />
|
||||
</label>
|
||||
</header>
|
||||
<ol v-if="photosFor(slot).length" class="loading-photo-list">
|
||||
<li v-for="(photo, index) in photosFor(slot)" :key="photo.fileId" class="loading-photo-row" :aria-label="`${loadingPhotoLabels[slot]}第${index + 1}张`">
|
||||
<span class="photo-index">{{ index + 1 }}</span>
|
||||
<div class="photo-preview">
|
||||
<a v-if="images[photo.fileId] && photo.contentType === 'application/pdf'" :href="images[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
|
||||
<el-image v-else-if="images[photo.fileId]" :src="images[photo.fileId]" :alt="`${loadingPhotoLabels[slot]}第${index + 1}张`" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||
</div>
|
||||
<span class="photo-time">保存时间:{{ savedTime(photo.savedAt) }}</span>
|
||||
<label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">
|
||||
更换
|
||||
<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${loadingPhotoLabels[slot]}第${index + 1}张`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" />
|
||||
</label>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="photo-empty">未上传</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.loading-documents-table { table-layout: fixed; }
|
||||
.loading-documents-table td { overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 120px; }
|
||||
.photo-preview { width: 140px; }
|
||||
.photo-time { width: 190px; }
|
||||
.loading-photo-image { display: block; width: 100px; height: 64px; }
|
||||
.loading-documents { display: flex; flex-direction: column; gap: 12px; }
|
||||
.loading-documents > p { margin: 0; }
|
||||
.loading-document-list { border: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.loading-document-category + .loading-document-category { border-top: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.loading-document-heading { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); }
|
||||
.loading-document-heading h3 { margin: 0; font-size: 14px; }
|
||||
.photo-count, .photo-time, .photo-index, .photo-empty { color: var(--el-text-color-secondary); }
|
||||
.category-upload { margin-left: auto; }
|
||||
.loading-photo-list { display: flex; flex-direction: column; margin: 0; padding: 0 12px; list-style: none; }
|
||||
.loading-photo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 8px 0; }
|
||||
.loading-photo-row + .loading-photo-row { border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.photo-index { width: 20px; text-align: center; }
|
||||
.photo-preview { display: flex; align-items: center; flex: 0 0 160px; min-height: 96px; }
|
||||
.loading-photo-image { display: block; width: 160px; height: 96px; }
|
||||
.photo-time { font-variant-numeric: tabular-nums; }
|
||||
.photo-empty { margin: 0; padding: 12px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
|
||||
@@ -3,22 +3,35 @@ import { ApiResponseError } from '@/shared/api/http'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn() }))
|
||||
import * as transportApi from '../api/transportTasks'
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
async function open(action: DispatchAction, allowed = true, overrides: Partial<DispatchDetail> = {}) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ ...overrides, actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, state: wrapper.vm as unknown as State }
|
||||
}
|
||||
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('shows only source pickup destinations in warehouse-transfer review submission', async () => {
|
||||
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', taskType: 'PICKUP', execution: {} as never, customerId: null, orderNo: null, pickups: [{ pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }, { pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }] as never, completionEvidence: [], dispatchRecordVersion: 0, resources: null })
|
||||
const { wrapper } = await open('SUBMIT_REVIEW', true, { taskType: 'PICKUP', dispatchInfo: { ...detail().dispatchInfo, route: '到仓取货 · 1单 · US-LAX-01' } })
|
||||
const text = wrapper.getComponent({ name: 'ElDialogContent' }).text()
|
||||
expect(text).toContain('取货目的地')
|
||||
expect(text).toContain('US-LAX-01 · 123 Warehouse Ave')
|
||||
expect(text).not.toContain('起运地')
|
||||
expect(transportApi.getTransport).toHaveBeenCalledExactlyOnceWith('PC/1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
||||
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
||||
@@ -90,6 +103,14 @@ it('refuses a route that drops an authoritative destination', async () => {
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
||||
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
||||
})
|
||||
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('requires saved vehicle and driver identities for %s review submission', async taskType => {
|
||||
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', pickups: [] } as unknown as Awaited<ReturnType<typeof transportApi.getTransport>>)
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW', true, { taskType })
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('车辆信息尚未维护完整')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports A0409 and immediately releases the form without a forced readback', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
||||
@@ -280,3 +301,45 @@ it('saves a cleared unavailable master vehicle as an empty draft resource', asyn
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not silently discard %s files when the master resource is missing', async kind => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
||||
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
||||
editor.driver.mode = 'MASTER'; editor.driver.driverId = null
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === kind)!
|
||||
expect(card.get('.evidence-upload-button').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('请先在“排车信息”')
|
||||
// A pending reference must also be guarded at save time, rather than being converted to null.
|
||||
card.vm.$emit('update:modelValue', ['301'])
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(card.props('modelValue')).toEqual(['301'])
|
||||
expect(wrapper.text()).toContain(kind === 'VEHICLE' ? '请先选择车辆' : '请先选择司机')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['MASTER', 'EXTERNAL'] as const)('keeps all three %s photo references after saving, reopening and saving again', async mode => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
||||
editor.vehicle.mode = mode; editor.vehicle.vehicleId = mode === 'MASTER' ? '11' : null
|
||||
editor.driver.mode = mode; editor.driver.driverId = mode === 'MASTER' ? '12' : null
|
||||
const expected = { VEHICLE: ['301'], DRIVER_PHOTO: ['302'], DRIVER: ['303'] }
|
||||
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) card.vm.$emit('update:modelValue', expected[card.props('kind') as keyof typeof expected])
|
||||
let saved: DispatchDetail | undefined
|
||||
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => {
|
||||
saved = detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: JSON.parse(JSON.stringify({ vehicle: body.vehicle, driver: body.driver, maintenance: body.maintenance })) })
|
||||
vi.mocked(api.getDispatch).mockImplementation(async () => JSON.parse(JSON.stringify(saved)))
|
||||
return command('EDIT', body.idempotencyKey)
|
||||
})
|
||||
await state.submit(); await flushPromises()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) expect(card.props('modelValue')).toEqual(expected[card.props('kind') as keyof typeof expected])
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toMatchObject({ vehicle: { evidenceFileIds: ['301'] }, driver: { evidence: { photoFileIds: ['302'], fileIds: ['303'] } } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { getTransport } from '../api/transportTasks'
|
||||
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
@@ -32,6 +33,7 @@ const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
|
||||
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
|
||||
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pickupDestinations = ref<string[]>([])
|
||||
const factsStale = ref(false)
|
||||
const preview = ref<LoadingPreview | null>(null)
|
||||
const loading = computed(() => working.value)
|
||||
@@ -39,6 +41,8 @@ const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; pa
|
||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const missingVehicle = computed(() => vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId)
|
||||
const missingDriver = computed(() => driver.value.mode === 'MASTER' && !driver.value.driverId)
|
||||
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
|
||||
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
|
||||
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
|
||||
@@ -66,7 +70,7 @@ const cargoEntryMessage = '当前入口不支持调整已建排车货物。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||
const submitting = ref(false)
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'SUBMIT_REVIEW' ? '820px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
@@ -75,7 +79,7 @@ async function load() {
|
||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
|
||||
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
|
||||
route.value = null; stops.value = []
|
||||
route.value = null; stops.value = []; pickupDestinations.value = []
|
||||
try {
|
||||
if (cargoAction.value) return
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
@@ -83,6 +87,13 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
// 仓储转运取货显示原单取货地址,不把排车摘要当作起运地。
|
||||
if (action === 'SUBMIT_REVIEW' && value.taskType === 'PICKUP') {
|
||||
const transport = await getTransport(no)
|
||||
if (sequence !== generation) return
|
||||
if (transport.dispatchNo !== no) throw new DispatchInputError('取货资料与排车单不一致')
|
||||
pickupDestinations.value = [...new Set(transport.pickups.map(row => row.pickupAddress?.trim()).filter((address): address is string => Boolean(address)))]
|
||||
}
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
fillDestinationAppointments(value)
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
@@ -125,7 +136,10 @@ function payload(): object {
|
||||
case 'MARK_EXCEPTION':
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT':
|
||||
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
|
||||
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
|
||||
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -138,7 +152,11 @@ function payload(): object {
|
||||
if (!measurements.value.length || measurements.value.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new DispatchInputError('托盘卸货站尚未匹配,请调度核对路线')
|
||||
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
|
||||
}
|
||||
case 'SUBMIT_REVIEW': return { ...common, remark: null }
|
||||
case 'SUBMIT_REVIEW':
|
||||
if (['PICKUP', 'LOCAL_TRUCK'].includes(detail.value.taskType || '') && (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId)) {
|
||||
throw new DispatchInputError('车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核')
|
||||
}
|
||||
return { ...common, remark: null }
|
||||
default: return { ...common, remark: form.remark || null }
|
||||
}
|
||||
}
|
||||
@@ -257,6 +275,18 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'SUBMIT_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="提交审核依据">
|
||||
<h3 class="density-dialog-section__title">审核依据</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">车牌号</th><td>{{ detail.vehicleInfo.vehicle?.plate || '未维护' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '未维护' }}</td><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
|
||||
<tr v-if="detail.taskType === 'PICKUP'"><th scope="row">取货目的地</th><td colspan="5"><div v-for="address in pickupDestinations" :key="address">{{ address }}</div><span v-if="!pickupDestinations.length">—</span></td></tr>
|
||||
<tr v-else><th scope="row">起运地</th><td>{{ detail.dispatchInfo.route?.split(' → ')[0] || '—' }}</td><th scope="row">目的地</th><td colspan="3">{{ detail.dispatchInfo.route?.split(' → ').slice(1).join(' → ') || '—' }}</td></tr>
|
||||
<tr><th scope="row">计划发车</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td><th scope="row">货量</th><td>{{ detail.counts.palletCount }} 托 / {{ detail.counts.packageCount }} 箱</td></tr>
|
||||
</tbody></table>
|
||||
<el-alert v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType || '') && (!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId)" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据">
|
||||
<h3 class="density-dialog-section__title">车辆与货物</h3>
|
||||
@@ -278,7 +308,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
|
||||
@@ -152,13 +152,14 @@ it('allows editing after unknown failure and submits a new command', async () =>
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(payload.idempotencyKey); expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(expect.objectContaining({ handlingResult: 'changed' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps a conflict visible and lets the operator refresh voluntarily', async () => {
|
||||
it('keeps a conflict visible and reloads when the operator reopens the detail', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重新读取详情')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('重新读取详情')
|
||||
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
|
||||
@@ -284,3 +285,21 @@ it('twenty-one historical files do not get resubmitted automatically with the ne
|
||||
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('moves processing controls into the dialog footer target', async () => {
|
||||
const footer = document.createElement('div')
|
||||
footer.id = 'exception-test-footer'
|
||||
document.body.append(footer)
|
||||
const wrapper = open()
|
||||
try {
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ actionsTarget: '#exception-test-footer' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
expect(footer.querySelector('[data-test="complete"]')).not.toBeNull()
|
||||
expect(wrapper.text()).not.toContain('重新读取详情')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
footer.remove()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { returnSourceKey, returnSourceReference } from '../returnSource'
|
||||
|
||||
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
|
||||
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean; actionsTarget?: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
|
||||
@@ -156,9 +156,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<template>
|
||||
<section v-loading="loading" aria-label="异常处理详情">
|
||||
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
|
||||
<el-button :disabled="busy" @click="load">重新读取详情</el-button>
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions class="density-descriptions exception-detail-fields" :column="2" border>
|
||||
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
|
||||
@@ -180,7 +179,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history workbench-evidence">
|
||||
<h3>退回实收与重派关联</h3>
|
||||
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions class="density-descriptions" :column="3" border>
|
||||
<el-descriptions-item label="退回收货号">{{ receipt.receiptNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
|
||||
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</el-descriptions-item>
|
||||
@@ -200,8 +199,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
</section>
|
||||
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
|
||||
<template v-if="canProcess">
|
||||
<el-form label-position="top" :disabled="locked">
|
||||
<el-form-item label="处理说明 / 取消原因(选填)"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
|
||||
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
|
||||
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item>
|
||||
<template v-if="allowed('COMPLETE_EXCEPTION')">
|
||||
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
|
||||
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
|
||||
@@ -219,15 +218,21 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
</el-form>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
|
||||
<div class="density-actions density-actions--footer">
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exception-processing-form { margin-top: var(--admin-space-5); }
|
||||
.exception-detail-fields :deep(.el-descriptions__table) { table-layout: fixed; }
|
||||
.exception-return-history { margin: 12px 0; }
|
||||
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
|
||||
|
||||
+27
@@ -51,6 +51,33 @@ it('keeps automatically loaded photos available after refreshing the open form',
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('loads local truck vehicle and driver archives while retaining the trip license upload requirement', async () => {
|
||||
const truck = facts()
|
||||
truck.taskType = 'LOCAL_TRUCK'
|
||||
truck.pickups = []
|
||||
truck.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(truck)
|
||||
vi.mocked(assignTransport).mockResolvedValue(truck)
|
||||
const wrapper = mountPanel()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(getResourcePhoto).toHaveBeenCalledWith('VEHICLE', '1')
|
||||
expect(getResourcePhoto).toHaveBeenCalledWith('DRIVER', '2')
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(cards[2]!.props('masterLicense')).toBeNull()
|
||||
cards[2]!.vm.$emit('update:modelValue', ['103'])
|
||||
await flushPromises()
|
||||
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({
|
||||
dispatchRecordVersion: 9, masterLicenseFileId: null,
|
||||
reviewPhotos: { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['103'] },
|
||||
}))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('resubmits saved snapshots when reopening maintenance even if archive photos change', async () => {
|
||||
const saved = facts()
|
||||
saved.execution.trip.recordVersion = 5
|
||||
|
||||
@@ -1,24 +1,126 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import TransportExecutionPanel from './TransportExecutionPanel.vue'
|
||||
import { assignTransport, commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
|
||||
import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
|
||||
import { getDriver } from '@/modules/overseas-master-data'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), downloadDriverLicense: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
|
||||
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } }))
|
||||
const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
|
||||
const options = { global: { plugins: [ElementPlus] } }
|
||||
it.each([false, true])('requires this truck stage own photo before footer completion (%s)', async arriving => {
|
||||
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({ close: vi.fn() }))
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const value = facts(arriving); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: arriving ? 'DELIVERING' : 'LOADING' } }); await flushPromises()
|
||||
expect(wrapper.vm.completionVisible).toBe(true)
|
||||
expect(wrapper.vm.completionDisabled).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => ['完成', '完成本单'].includes(button.text()))).toBe(false)
|
||||
await wrapper.vm.submitCompletion()
|
||||
expect(warning).toHaveBeenCalledWith(arriving ? '请至少上传一张送达照片' : '请至少上传一张取货照片')
|
||||
expect(confirm).not.toHaveBeenCalled(); expect(commandTransport).not.toHaveBeenCalled()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
await wrapper.vm.submitCompletion(); await flushPromises()
|
||||
expect(commandTransport).toHaveBeenCalledWith('PC1', `truck/${arriving ? 'complete' : 'depart'}`, expect.objectContaining({ recordVersion: 4, reason: arriving ? '确认已在 送达并完成本土卡车订单?' : '确认 取货完成', evidenceFileIds: ['88'] }))
|
||||
wrapper.unmount(); warning.mockRestore(); confirm.mockRestore()
|
||||
})
|
||||
it('confirms the B point action as pickup completion and explains the next stage', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
await wrapper.vm.submitCompletion(); await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith('', '确认取货完成', expect.any(Object))
|
||||
wrapper.unmount(); confirm.mockRestore()
|
||||
})
|
||||
beforeEach(() => {
|
||||
can.mockReturnValue(true)
|
||||
vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn(() => 'blob:truck-photo'), revokeObjectURL: vi.fn() })
|
||||
vi.mocked(getTransport).mockResolvedValue(facts())
|
||||
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
|
||||
vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
})
|
||||
it('keeps the footer action visible without departure permission and explains why submission is blocked', async () => {
|
||||
can.mockImplementation(code => code !== 'platform:overseas-warehouse:dispatch:depart')
|
||||
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({ close: vi.fn() }))
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
|
||||
expect(wrapper.vm.completionVisible).toBe(true)
|
||||
await wrapper.vm.submitCompletion()
|
||||
expect(warning).toHaveBeenLastCalledWith('请至少上传一张取货照片')
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
await wrapper.vm.submitCompletion()
|
||||
expect(warning).toHaveBeenLastCalledWith('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
|
||||
expect(commandTransport).not.toHaveBeenCalled()
|
||||
wrapper.unmount(); warning.mockRestore()
|
||||
})
|
||||
it('shows B point pickup photos inline and accepts multiple files', async () => {
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.dispatchRecordVersion = 9
|
||||
value.execution.trip.stops = []; value.execution.trip.vehicleId = '1'; value.execution.trip.driverId = '2'
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(uploadTransport).mockResolvedValueOnce({ fileId: '88', fileName: '现场1.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
.mockResolvedValueOnce({ fileId: '89', fileName: '现场2.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
expect(input.attributes('multiple')).toBeDefined()
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['1'], '现场1.jpg', { type: 'image/jpeg' }), new File(['2'], '现场2.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(wrapper.findAll('.truck-evidence-item img')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('现场1.jpg'); expect(wrapper.text()).toContain('现场2.jpg')
|
||||
expect(vi.mocked(uploadTransport)).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows B point evidence separately and keeps delivery uploads in the C point section', async () => {
|
||||
const value = facts(true); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
value.departureEvidence = [{ fileId: '70', fileName: '取货.jpg', contentType: 'image/jpeg', sizeBytes: 10 }]
|
||||
value.completionEvidence = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(downloadTransportEvidence).mockResolvedValue(new Blob(['photo'], { type: 'image/jpeg' }))
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'DELIVERING' } }); await flushPromises()
|
||||
const pickup = wrapper.get('[aria-label="取货照片"]')
|
||||
const delivery = wrapper.get('[aria-label="送达照片上传"]')
|
||||
expect(pickup.text()).toContain('取货.jpg')
|
||||
expect(pickup.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(pickup.findAll('.truck-evidence-delete')).toHaveLength(0)
|
||||
expect(delivery.text()).toContain('上传送达照片')
|
||||
expect(delivery.findAll('.truck-evidence-item')).toHaveLength(0)
|
||||
expect(delivery.get('input[type="file"]').attributes('multiple')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
const photoOptions = { global: { plugins: [ElementPlus], stubs: { DispatchEvidenceFiles: true } } }
|
||||
it('requires uploaded truck license photos and hides pickup evidence during vehicle maintenance', async () => {
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []; value.dispatchRecordVersion = 9
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(assignTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
|
||||
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(cards).toHaveLength(3)
|
||||
expect(cards[2]!.props('referenceIds')).toEqual([])
|
||||
expect(cards[2]!.props('modelValue')).toEqual([])
|
||||
expect(wrapper.text()).not.toContain('取货照片')
|
||||
cards[0]!.vm.$emit('update:modelValue', ['21']); cards[1]!.vm.$emit('update:modelValue', ['22']); await flushPromises()
|
||||
const save = wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
|
||||
expect(save.attributes('disabled')).toBeDefined()
|
||||
cards[2]!.vm.$emit('update:modelValue', ['23']); await flushPromises()
|
||||
await save.trigger('click'); await flushPromises()
|
||||
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ masterLicenseFileId: null, reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: ['23'] } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes the selected driver master license when reopening resource maintenance', async () => {
|
||||
const value = facts()
|
||||
value.resources = { vehicle: null, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['30'], masterLicenseFileId: '30' } } } as TransportDetail['resources']
|
||||
@@ -126,7 +228,7 @@ it('keeps pickup addresses and contacts on their own transfer rows', async () =>
|
||||
value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' })
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises()
|
||||
const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr')
|
||||
const rows = wrapper.get('[aria-label="取货明细"]').findAll('.pickup-detail-card')
|
||||
expect(rows[0]!.text()).toContain('100 Test Pickup Avenue')
|
||||
expect(rows[0]!.text()).toContain('取货联系人甲')
|
||||
expect(rows[0]!.text()).toContain('0000000001')
|
||||
@@ -137,3 +239,24 @@ it('keeps pickup addresses and contacts on their own transfer rows', async () =>
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders every pickup and arrival photo inline after completion and has no resource ending actions', async () => {
|
||||
const value = facts(true); value.execution.trip.status = 'COMPLETED'
|
||||
value.pickups[0]!.status = 'ARRIVED'
|
||||
value.pickups[0]!.evidence = [
|
||||
{ fileId: '101', fileName: '取货一.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'PICKUP' },
|
||||
{ fileId: '102', fileName: '取货二.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'PICKUP' },
|
||||
{ fileId: '103', fileName: '送达一.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
|
||||
{ fileId: '104', fileName: '送达二.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
|
||||
{ fileId: '105', fileName: '送达三.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
|
||||
]
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(downloadTransportEvidence).mockResolvedValue(new Blob(['photo'], { type: 'image/jpeg' }))
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'DELIVERED', readonly: true } }); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="取货照片"]').findAll('img')).toHaveLength(2)
|
||||
expect(wrapper.get('[aria-label="送达照片"]').findAll('img')).toHaveLength(3)
|
||||
for (const file of value.pickups[0]!.evidence) expect(downloadTransportEvidence).toHaveBeenCalledWith('PC1', file.fileId, expect.objectContaining({ customerId: 'C1', transferNo: 'WT1' }))
|
||||
expect(wrapper.text()).not.toContain('结束车辆任务'); expect(wrapper.text()).not.toContain('结束司机任务')
|
||||
const revoke = vi.mocked(URL.revokeObjectURL); revoke.mockClear()
|
||||
wrapper.unmount(); expect(revoke).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { Truck, Package, RefreshCw } from '@lucide/vue'
|
||||
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -12,11 +12,12 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import TransportPhotoGallery from './TransportPhotoGallery.vue'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; pickupStage?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
@@ -24,21 +25,51 @@ const vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licen
|
||||
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
|
||||
const masterPhotoAvailable = reactive({ vehicle: false, driver: false })
|
||||
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
|
||||
const activeMasterLicense = computed(() => licensePhotos.value.length ? null : masterLicense.value)
|
||||
const photosComplete = computed(() => detail.value?.taskType !== 'PICKUP' || !!((vehiclePhotos.value.length || masterPhotoAvailable.vehicle) && (driverPhotos.value.length || masterPhotoAvailable.driver) && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const activeMasterLicense = computed(() => detail.value?.taskType === 'PICKUP' && !licensePhotos.value.length ? masterLicense.value : null)
|
||||
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!((vehiclePhotos.value.length || masterPhotoAvailable.vehicle) && (driverPhotos.value.length || masterPhotoAvailable.driver) && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([])
|
||||
const truckPickupEvidence = ref<PickupEvidence[]>([]), truckDeliveryEvidence = ref<PickupEvidence[]>([])
|
||||
const truckPreviewUrls = ref<Record<string, string>>({})
|
||||
const arrivalMode = ref(false)
|
||||
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
|
||||
const editingResources = ref(props.editResources === true)
|
||||
const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase)
|
||||
const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中')
|
||||
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
|
||||
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
|
||||
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' } | null>(null)
|
||||
const trip = computed(() => detail.value?.execution.trip)
|
||||
const editable = computed(() => !props.readonly && access.can(permission.EDIT))
|
||||
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
|
||||
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && props.pickupStage !== 'LOADING' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
|
||||
const resourceFormOpen = computed(() => canChangeResources.value && editable.value && (editingResources.value || !trip.value?.vehicleId || !trip.value?.driverId))
|
||||
const locked = computed(() => busy.value || !!pending.value || licenseLoading.value || Object.values(photoBusy).some(Boolean))
|
||||
const completionAction = computed(() => {
|
||||
if (detail.value?.taskType !== 'LOCAL_TRUCK' || !editable.value || resourceFormOpen.value || !props.pickupApproved || !trip.value) return null
|
||||
if (props.pickupStage === 'LOADING' && trip.value.status === 'PLANNED') {
|
||||
return { action: 'depart' as const, label: '完成', missingPhotoMessage: '请至少上传一张取货照片' }
|
||||
}
|
||||
if (props.pickupStage === 'DELIVERING' && trip.value.status === 'IN_TRANSIT') {
|
||||
return { action: 'complete' as const, label: '完成本单', missingPhotoMessage: '请至少上传一张送达照片' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const completionVisible = computed(() => !!completionAction.value)
|
||||
const completionText = computed(() => completionAction.value?.label || '完成')
|
||||
const completionLoading = computed(() => busy.value && !!pending.value && ['truck/depart', 'truck/complete'].includes(pending.value.suffix))
|
||||
const completionDisabled = computed(() => locked.value || (completionAction.value?.action === 'depart' && (!trip.value?.vehicleId || !trip.value?.driverId)))
|
||||
async function submitCompletion() {
|
||||
const action = completionAction.value
|
||||
if (!action || completionDisabled.value) return
|
||||
const evidence = action.action === 'depart' ? truckPickupEvidence.value : truckDeliveryEvidence.value
|
||||
if (!evidence.length) {
|
||||
ElMessage.warning(action.missingPhotoMessage)
|
||||
return
|
||||
}
|
||||
if (action.action === 'depart' && !access.can(permission.DEPART)) {
|
||||
ElMessage.warning('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
|
||||
return
|
||||
}
|
||||
await command(action.action)
|
||||
}
|
||||
defineExpose({ completionVisible, completionText, completionLoading, completionDisabled, submitCompletion })
|
||||
/** 本车已保存的照片随资源带出;换车不能继续显示原车照片。 */
|
||||
function restoreVehiclePhotos() {
|
||||
const saved = detail.value?.resources?.vehicle
|
||||
@@ -53,8 +84,9 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
|
||||
let current = true
|
||||
onCleanup(() => { current = false })
|
||||
licenseLoading.value = false
|
||||
if (!open || detail.value?.taskType !== 'PICKUP') return
|
||||
const saved = detail.value.resources?.driver
|
||||
const transport = detail.value
|
||||
if (!open || !transport || !['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType)) return
|
||||
const saved = transport.resources?.driver
|
||||
const same = saved?.driverId === id ? saved.evidence : null
|
||||
driverPhotos.value = [...(same?.photoFileIds || [])]
|
||||
licensePhotos.value = (same?.fileIds || []).filter(file => file !== same?.masterLicenseFileId)
|
||||
@@ -67,9 +99,28 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
|
||||
} catch (cause) { if (current) error.value = getApiErrorDetails(cause, '驾驶证带出失败,可重新读取或上传照片') }
|
||||
finally { if (current) licenseLoading.value = false }
|
||||
}, { immediate: true })
|
||||
function clearTruckPreviewUrls() { Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = {} }
|
||||
async function refreshTruckPreviews(files: PickupEvidence[]) {
|
||||
const results = await Promise.allSettled(files.map(async file => [file.fileId, URL.createObjectURL(await downloadTransportEvidence(props.dispatchNo, file.fileId))] as const))
|
||||
const next: Record<string, string> = {}
|
||||
results.forEach(result => { if (result.status === 'fulfilled') next[result.value[0]] = result.value[1] })
|
||||
Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = next
|
||||
}
|
||||
function setTruckPreview(fileId: string, file: File) {
|
||||
const old = truckPreviewUrls.value[fileId]
|
||||
if (old) URL.revokeObjectURL(old)
|
||||
truckPreviewUrls.value = { ...truckPreviewUrls.value, [fileId]: URL.createObjectURL(file) }
|
||||
}
|
||||
function removeTruckEvidence(fileId: string, pickup: boolean) {
|
||||
if (pickup) truckPickupEvidence.value = truckPickupEvidence.value.filter(file => file.fileId !== fileId)
|
||||
else truckDeliveryEvidence.value = truckDeliveryEvidence.value.filter(file => file.fileId !== fileId)
|
||||
const url = truckPreviewUrls.value[fileId]
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
const next = { ...truckPreviewUrls.value }; delete next[fileId]; truckPreviewUrls.value = next
|
||||
}
|
||||
async function load() {
|
||||
error.value = null
|
||||
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; restoreVehiclePhotos() }
|
||||
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; truckPickupEvidence.value = [...(detail.value.departureEvidence || [])]; truckDeliveryEvidence.value = [...(detail.value.completionEvidence || [])]; clearTruckPreviewUrls(); await refreshTruckPreviews([...truckPickupEvidence.value, ...truckDeliveryEvidence.value]); restoreVehiclePhotos() }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
|
||||
}
|
||||
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
|
||||
@@ -85,9 +136,10 @@ async function candidates() {
|
||||
finally { candidateBusy.value = false }
|
||||
}
|
||||
async function assign() {
|
||||
if (!trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.value) return
|
||||
const transport = detail.value
|
||||
if (!transport || !trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机', ...(detail.value?.taskType === 'PICKUP' ? { dispatchRecordVersion: detail.value.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机', ...(['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType) ? { dispatchRecordVersion: transport.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
@@ -96,33 +148,37 @@ async function execute() {
|
||||
const request = pending.value; busy.value = true; error.value = null
|
||||
try {
|
||||
if (request.kind === 'cancel') await cancelTransport(props.dispatchNo, request.body)
|
||||
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
|
||||
else await commandTransport(props.dispatchNo, request.suffix, request.body)
|
||||
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
pending.value = null; pickupOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
if (!row && action === 'depart' && !truckPickupEvidence.value.length) { ElMessage.warning('请至少上传一张取货照片'); return }
|
||||
if (!row && action === 'complete' && !truckDeliveryEvidence.value.length) { ElMessage.warning('请至少上传一张送达照片'); return }
|
||||
const message = { depart: '', complete: '确认已在 送达并完成本土卡车订单?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
const title = action === 'depart' ? '确认取货完成' : '确认运输操作'
|
||||
try { await ElMessageBox.confirm(message, title, { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
|
||||
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
const commandReason = reason.value.trim() || (action === 'depart' && prefix === 'truck' ? '确认 取货完成' : message || '确认运输操作')
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: commandReason, ...(prefix === 'truck' && action === 'depart' ? { evidenceFileIds: truckPickupEvidence.value.map(file => file.fileId) } : {}), ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckDeliveryEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
await execute()
|
||||
}
|
||||
/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */
|
||||
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true }
|
||||
async function download(file: PickupEvidence, row?: PickupTask) {
|
||||
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
|
||||
}
|
||||
async function upload(event: Event, pickup: boolean) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file || locked.value) return
|
||||
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); if (!files.length || locked.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
|
||||
for (const file of files) {
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file)
|
||||
if (pickup && row) evidence.value.push(result)
|
||||
else if (trip.value?.status === 'PLANNED') { truckPickupEvidence.value.push(result); setTruckPreview(result.fileId, file) }
|
||||
else { truckDeliveryEvidence.value.push(result); setTruckPreview(result.fileId, file) }
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
|
||||
finally { pending.value = null; busy.value = false; input.value = '' }
|
||||
}
|
||||
@@ -132,12 +188,8 @@ async function picked() {
|
||||
const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) }
|
||||
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute()
|
||||
}
|
||||
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
|
||||
async function release() {
|
||||
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
|
||||
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
|
||||
}
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提交,确认离开?'))
|
||||
onBeforeUnmount(clearTruckPreviewUrls)
|
||||
</script>
|
||||
<template>
|
||||
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
|
||||
@@ -145,13 +197,10 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
|
||||
<template v-if="detail && trip">
|
||||
<header class="transport-toolbar">
|
||||
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
|
||||
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
|
||||
<div v-if="!readonly" class="density-actions transport-actions">
|
||||
<slot name="actions" />
|
||||
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="canChangeResources && access.can(permission.CANCEL)" type="danger" plain :disabled="locked" @click="command('cancel')">取消排车</el-button>
|
||||
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable" :disabled="locked || !truckEvidence.length" @click="command('complete')">确认运输完成</el-button>
|
||||
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
|
||||
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -168,7 +217,7 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
||||
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
|
||||
@@ -179,22 +228,36 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
</section>
|
||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
|
||||
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
|
||||
<el-table :data="detail.pickups" size="small" class="density-table" empty-text="暂无取货明细">
|
||||
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
|
||||
<el-table-column prop="transferNo" label="转运单号" width="210" />
|
||||
<el-table-column label="取货地址" min-width="280"><template #default="{ row }">{{ row.pickupAddress || '—' }}</template></el-table-column>
|
||||
<el-table-column label="联系人" min-width="120"><template #default="{ row }">{{ row.contactName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="联系电话" min-width="160"><template #default="{ row }">{{ row.contactPhone || '—' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="预报/实际取货(箱)" width="180" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="取货照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'PICKUP')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'PICKUP').length">—</span></template></el-table-column>
|
||||
<el-table-column label="到仓照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'ARRIVAL')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'ARRIVAL').length">—</span></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div>
|
||||
<div v-for="row in detail.pickups" :key="`${row.customerId}:${row.transferNo}`" class="pickup-detail-card">
|
||||
<div class="pickup-detail-heading"><strong>{{ row.transferNo }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></div>
|
||||
<dl class="transport-facts pickup-detail-facts">
|
||||
<div><dt>取货地址</dt><dd>{{ row.pickupAddress || '—' }}</dd></div><div><dt>联系人</dt><dd>{{ row.contactName || '—' }}</dd></div><div><dt>联系电话</dt><dd>{{ row.contactPhone || '—' }}</dd></div><div><dt>预报 / 实际箱数</dt><dd>{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</dd></div>
|
||||
<div><dt>取货照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'PICKUP')" label="取货照片" /></dd></div><div><dt>送达照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'ARRIVAL')" label="送达照片" /></dd></div>
|
||||
</dl>
|
||||
<div v-if="!readonly" class="pickup-detail-actions"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template></div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<el-button v-for="file in detail.completionEvidence || []" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button>
|
||||
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'LOADING' && trip.status === 'PLANNED'" class="truck-evidence-panel" aria-label="取货照片上传">
|
||||
<h2 class="workbench-section-title">取货照片</h2>
|
||||
<label class="truck-evidence-upload">上传取货照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
|
||||
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除取货照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, true)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
|
||||
</section>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && !props.readonly && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT' && truckPickupEvidence.length" class="truck-evidence-panel" aria-label="取货照片">
|
||||
<h2 class="workbench-section-title">取货照片</h2>
|
||||
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-readonly-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div>
|
||||
</div>
|
||||
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT'" class="truck-evidence-panel" aria-label="送达照片上传">
|
||||
<h2 class="workbench-section-title">送达照片</h2>
|
||||
<label class="truck-evidence-upload">上传送达照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
|
||||
<div class="truck-evidence-list"><figure v-for="file in truckDeliveryEvidence" :key="`delivery-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除送达照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, false)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
|
||||
</section>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && (props.readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)) && (detail.departureEvidence?.length || detail.completionEvidence?.length)" class="truck-evidence-panel">
|
||||
<template v-if="detail.departureEvidence?.length"><h2 class="workbench-section-title">取货照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.departureEvidence || []" :key="`departure-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure>
|
||||
</div></template><template v-if="detail.completionEvidence?.length"><h2 class="workbench-section-title">送达照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.completionEvidence || []" :key="`completion-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div></template>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="min(760px, calc(100vw - 40px))" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
|
||||
<p>{{ selected?.transferNo }}</p>
|
||||
<el-form label-position="left" label-width="150px" :disabled="locked">
|
||||
<el-form-item label="预报数量(箱)">{{ selected?.packageCount ?? '—' }}</el-form-item>
|
||||
@@ -207,7 +270,6 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
</el-form>
|
||||
<template #footer><el-button type="primary" :disabled="locked || !evidence.length || (!arrivalMode && !validPickupQuantity)" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="560px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="left" label-width="100px" :disabled="locked"><el-form-item label="实际结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认结束</el-button></template></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -227,5 +289,24 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
.transport-review-photos { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
|
||||
.transport-photo-hint, .transport-resource-save { grid-column: 1 / -1; }
|
||||
.transport-pickups { display: grid; gap: var(--admin-space-4); min-width: 0; }
|
||||
.pickup-detail-card { display: grid; gap: var(--admin-space-4); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); }
|
||||
.pickup-detail-heading, .pickup-detail-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
|
||||
.pickup-detail-facts { margin: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.pickup-detail-facts > div { align-items: start; }
|
||||
.pickup-detail-facts dd { display: flex; flex-wrap: wrap; gap: 4px 10px; }
|
||||
.truck-evidence-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-md); background: var(--cc-surface); }
|
||||
.truck-evidence-panel > h2 { flex: 0 0 100%; margin: 0; }
|
||||
.truck-evidence-panel label { display: inline-flex; align-items: center; gap: 10px; color: var(--cc-text); font-size: 13px; }
|
||||
.truck-evidence-upload { padding: 8px 14px; border: 1px solid var(--el-color-primary); border-radius: var(--cc-radius-sm); color: var(--el-color-primary) !important; cursor: pointer; }
|
||||
.truck-evidence-upload input[type='file'] { display: none; }
|
||||
.truck-evidence-list { display: flex; flex-wrap: wrap; gap: 12px; color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-evidence-item { width: 132px; margin: 0; }
|
||||
.truck-evidence-item { position: relative; }
|
||||
.truck-evidence-item .el-image { display: block; width: 132px; height: 92px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
|
||||
.truck-evidence-item img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.truck-evidence-delete { position: absolute; z-index: 2; top: 4px; right: 4px; width: 26px; height: 26px; color: var(--el-color-danger) !important; background: rgb(255 255 255 / 88%) !important; box-shadow: 0 1px 4px rgb(0 0 0 / 18%); }
|
||||
.truck-evidence-item figcaption { overflow: hidden; margin-top: 4px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.truck-evidence-item .el-button { padding: 2px 0; }
|
||||
@media (max-width: 760px) { .pickup-detail-facts { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 1100px) { .transport-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .transport-resource-form { grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { PickupEvidence, PickupTask } from '@/modules/overseas-master-data'
|
||||
import { downloadTransportEvidence } from '../api/transportTasks'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string; row: PickupTask; files: PickupEvidence[]; label: string }>()
|
||||
const urls = ref<Record<string, string>>({}), failed = ref<string[]>([])
|
||||
let generation = 0
|
||||
function clear() { Object.values(urls.value).forEach(url => URL.revokeObjectURL(url)); urls.value = {} }
|
||||
// 按客户及转运单读取每张原始照片;切单、刷新或卸载后丢弃迟到响应并释放预览。
|
||||
async function load() {
|
||||
const request = ++generation, number = props.dispatchNo, row = props.row
|
||||
clear(); failed.value = []
|
||||
await Promise.all(props.files.map(async file => {
|
||||
try {
|
||||
const blob = await downloadTransportEvidence(number, file.fileId, row)
|
||||
if (request === generation) urls.value[file.fileId] = URL.createObjectURL(blob)
|
||||
} catch { if (request === generation) failed.value.push(file.fileId) }
|
||||
}))
|
||||
}
|
||||
watch(() => [props.dispatchNo, props.row.customerId, props.row.transferNo, props.files.map(file => file.fileId).join(',')], load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; clear() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transport-photo-gallery" :aria-label="label">
|
||||
<figure v-for="file in files" :key="file.fileId">
|
||||
<el-image v-if="urls[file.fileId]" :src="urls[file.fileId]" :preview-src-list="files.map(item => urls[item.fileId]).filter((url): url is string => !!url)" :initial-index="files.filter(item => !!urls[item.fileId]).findIndex(item => item.fileId === file.fileId)" preview-teleported fit="contain" :alt="`${label}:${file.fileName}`" />
|
||||
<el-button v-else-if="failed.includes(file.fileId)" link @click="load">加载失败,点击重试</el-button>
|
||||
<span v-else>照片加载中</span>
|
||||
<figcaption :title="file.fileName">{{ file.fileName }}</figcaption>
|
||||
</figure>
|
||||
<span v-if="!files.length">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-photo-gallery { display: flex; flex-wrap: wrap; gap: 12px; min-width: 0; }
|
||||
figure { width: 132px; margin: 0; }
|
||||
.el-image { display: block; width: 132px; height: 92px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
|
||||
figcaption { overflow: hidden; margin-top: 4px; font-size: 12px; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -4,14 +4,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
|
||||
const reviewedTransfer = { id: '1', transferNo: 'WT-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [], cartons: [], pallets: [], relabelBindings: [] }
|
||||
const reviewedTransfer = { id: '1', transferNo: 'WT-1', operatingWarehouseId: 'WH-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [{ skuCode: 'SKU-1', packageCount: 2 }], cartons: [], pallets: [], relabelBindings: [] }
|
||||
|
||||
vi.mock('../api/warehouseTransfers', () => ({
|
||||
getWarehouseTransfer: vi.fn(),
|
||||
reviewWarehouseTransfer: vi.fn(),
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
|
||||
const AppDialogStub = defineComponent({
|
||||
name: 'AppDialog',
|
||||
@@ -33,17 +35,21 @@ function mountDialog() {
|
||||
}
|
||||
|
||||
describe('WarehouseTransferReviewDialog', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getWarehouseTransfer).mockResolvedValue(reviewedTransfer as never); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: 'WH-1', warehouseName: '洛杉矶操作仓' }] as never) })
|
||||
|
||||
it('shows a concise confirmation without fetching detail tables or review inputs', () => {
|
||||
it('shows concise review facts without a detail table or editable review inputs', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('仓储转运单审核 · WT-1')
|
||||
expect(wrapper.text()).toContain('测试客户')
|
||||
expect(wrapper.text()).toContain('预报箱数')
|
||||
expect(wrapper.text()).toContain('洛杉矶操作仓')
|
||||
expect(wrapper.text()).not.toContain('货物')
|
||||
expect(wrapper.text()).not.toContain('审核通过后,转运单将进入海外仓收货打托作业。')
|
||||
expect(wrapper.find('.back').text()).toBe('返回')
|
||||
expect(wrapper.find('.approve').text()).toBe('审核通过')
|
||||
expect(wrapper.find('textarea, input, table').exists()).toBe(false)
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('20001', 'WT-1')
|
||||
})
|
||||
|
||||
it('return closes without approving or rejecting the order', async () => {
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; customerId: string; customerName: string; transferNo: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; reviewed: [] }>()
|
||||
const open = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null)
|
||||
const operatingWarehouseName = ref('')
|
||||
|
||||
async function loadDetail() {
|
||||
if (!open.value || !props.customerId || !props.transferNo) return
|
||||
loading.value = true
|
||||
try { detail.value = await getWarehouseTransfer(props.customerId, props.transferNo) }
|
||||
catch { detail.value = null }
|
||||
try {
|
||||
const warehouseId = detail.value?.operatingWarehouseId
|
||||
if (warehouseId) {
|
||||
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
|
||||
operatingWarehouseName.value = warehouses.find(item => String(item.id) === String(warehouseId))?.warehouseName || ''
|
||||
} else operatingWarehouseName.value = ''
|
||||
} catch { operatingWarehouseName.value = '' }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => [open.value, props.customerId, props.transferNo] as const, ([visible]) => { if (visible) void loadDetail() }, { immediate: true })
|
||||
|
||||
const deliveryMethodLabel = computed(() => detail.value?.deliveryMethod === 'WAREHOUSE_PICKUP' ? '上门取货' : detail.value?.deliveryMethod === 'CUSTOMER_DELIVERY' ? '客户送货到仓' : detail.value?.deliveryMethod || '—')
|
||||
const destinationLabel = computed(() => detail.value?.destinationWarehouseName || detail.value?.destinationWarehouseCode || detail.value?.fbaWarehouseCode || '—')
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || !open.value || !props.customerId || !props.transferNo) return
|
||||
@@ -33,13 +57,35 @@ async function submit() {
|
||||
v-model="open"
|
||||
class="admin-density-dialog density-confirm-dialog admin-workbench is-dialog"
|
||||
:title="`仓储转运单审核 · ${transferNo || '—'}`"
|
||||
width="min(420px, calc(100vw - 28px))"
|
||||
width="min(680px, calc(100vw - 28px))"
|
||||
align-center
|
||||
cancel-text="返回"
|
||||
confirm-text="审核通过"
|
||||
:confirm-loading="saving"
|
||||
@confirm="submit"
|
||||
>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="转运审核对象"><p class="density-dialog-note">客户:{{ customerName || '—' }}</p></section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="转运审核明细">
|
||||
<div v-if="loading" class="density-dialog-note">正在读取转运明细…</div>
|
||||
<dl class="review-facts">
|
||||
<div><dt>客户</dt><dd>{{ customerName || '—' }}</dd></div>
|
||||
<div><dt>转运单号</dt><dd>{{ transferNo || '—' }}</dd></div>
|
||||
<div><dt>送货方式</dt><dd>{{ deliveryMethodLabel }}</dd></div>
|
||||
<div><dt>操作仓</dt><dd>{{ operatingWarehouseName || detail?.operatingWarehouseId || '—' }}</dd></div>
|
||||
<div><dt>目的仓</dt><dd>{{ destinationLabel }}</dd></div>
|
||||
<div><dt>预报箱数</dt><dd>{{ detail?.packageCount ?? '—' }} 箱</dd></div>
|
||||
<div><dt>换标</dt><dd>{{ detail?.needRelabel ? '需要' : '不需要' }}</dd></div>
|
||||
<div class="review-facts-wide"><dt>送货地址</dt><dd>{{ detail?.deliveryAddress || '—' }}</dd></div>
|
||||
<div class="review-facts-wide"><dt>备注</dt><dd>{{ detail?.remark || '—' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; margin: 0; }
|
||||
.review-facts > div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; min-width: 0; }
|
||||
.review-facts dt { color: var(--cc-muted); }
|
||||
.review-facts dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.review-facts-wide { grid-column: 1 / -1; }
|
||||
@media (max-width: 560px) { .review-facts { grid-template-columns: 1fr; } .review-facts-wide { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
||||
}
|
||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
SUBMIT_REVIEW: '提交审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
||||
}
|
||||
export const dispatchActionPermissions: Record<DispatchAction, string> = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import { FileUp, ClipboardList, Clock, CircleCheck } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -158,14 +158,27 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench">
|
||||
<AdminPage spacing="compact" class="customer-visits-page admin-density admin-workbench">
|
||||
<section class="visit-summary-grid" aria-label="回访统计">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><ClipboardList aria-hidden="true" /></span><h2 class="workbench-metric-label">可回访订单</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.visitable.toLocaleString('zh-CN') }}</strong><span>单</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock aria-hidden="true" /></span><h2 class="workbench-metric-label">待回访</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.pending.toLocaleString('zh-CN') }}</strong><span>单</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已完成回访</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.completed.toLocaleString('zh-CN') }}</strong><span>单</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<ListPageLayout aria-label="客户回访列表">
|
||||
|
||||
|
||||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>客户回访</h1></template>
|
||||
<template #filters>
|
||||
<dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
</template>
|
||||
<template #notices><template v-if="error"><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
|
||||
@@ -212,7 +225,8 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visit-summary { flex-wrap: wrap; }
|
||||
.admin-workbench.customer-visits-page.admin-page.is-spacing-compact { gap: 0; }
|
||||
.visit-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
|
||||
.visit-order-summary.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-5); }
|
||||
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); width: 100%; }
|
||||
.inbound-option { display: grid; min-width: 0; gap: var(--admin-space-2); padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); cursor: pointer; color: var(--workbench-muted); overflow-wrap: anywhere; }
|
||||
@@ -222,5 +236,5 @@ onMounted(load)
|
||||
.attachment-list, .attachment-empty { width: 100%; margin-top: var(--admin-space-3); }
|
||||
.attachment-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-2) 0; border-bottom: 1px solid var(--workbench-border); overflow-wrap: anywhere; }
|
||||
.attachment-list small, .attachment-empty { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
|
||||
@media (max-width: 720px) { .visit-summary-grid { grid-template-columns: 1fr; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
|
||||
</style>
|
||||
|
||||
@@ -74,7 +74,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<OverseasDetailShell workbench class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" />
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" :pickup-stage="detail.stage" />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
import ElementPlus, { ElMessageBox, ElSelect, ElTag } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
@@ -23,13 +24,31 @@ vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.f
|
||||
vi.mock('../api/transportTasks', () => ({ transportLabels: { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }, cancelTransport: vi.fn(), getTransport: vi.fn() }))
|
||||
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true, TransportExecutionPanel: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
|
||||
it('places truck completion next to close in the outer dialog footer', async () => {
|
||||
const submitCompletion = vi.fn()
|
||||
const panel = defineComponent({
|
||||
name: 'TransportExecutionPanel',
|
||||
setup(_, { expose }) {
|
||||
expose({ completionVisible: true, completionText: '完成', completionLoading: false, completionDisabled: false, submitCompletion })
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'LOADING', reviewState: 'APPROVED', actions: {} })]))
|
||||
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: panel } } }); await flushPromises()
|
||||
wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'transport'); await flushPromises()
|
||||
const footer = wrapper.get('.app-dialog__footer')
|
||||
expect(footer.findAll('button').map(button => button.text())).toEqual(['关闭', '完成'])
|
||||
await footer.findAll('button')[1]!.trigger('click')
|
||||
expect(submitCompletion).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers pickup detail, transport vehicle maintenance and the shared review workflow', async () => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交调度审核', '详情', '更多'])
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交审核', '详情', '更多'])
|
||||
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
|
||||
@@ -52,7 +71,7 @@ it('confirms cancelling pickup and linked transfers using the transport version'
|
||||
expect(listDispatches).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount(); confirm.mockRestore()
|
||||
})
|
||||
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
|
||||
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
@@ -338,11 +357,20 @@ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispa
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '运输操作' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
|
||||
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '维护车辆' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
|
||||
row.vm.$emit('action', 'detail'); await flushPromises()
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
|
||||
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
|
||||
})
|
||||
it('opens local truck delivery stage directly on the delivery photo node', async () => {
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'DELIVERING', reviewState: 'APPROVED' })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'transport', label: '上传送达照片' }), expect.objectContaining({ key: 'detail', label: '详情' })])
|
||||
row.vm.$emit('action', 'transport'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props()).toMatchObject({ dispatchNo: 'PC/1', pickupStage: 'DELIVERING', editResources: false })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
||||
@@ -474,3 +502,13 @@ it.each([
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('shows only details after %s delivery completes', async taskType => {
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType, stage: 'DELIVERED', reviewState: 'APPROVED', actions: {} })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
|
||||
row.vm.$emit('action', 'transport'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -35,6 +35,11 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
|
||||
const transportEditOpen = ref(false), transportEditNo = ref('')
|
||||
const transportEditTitle = ref('维护取货车辆信息'), transportResourceMode = ref(false)
|
||||
const transportPanel = ref<InstanceType<typeof TransportExecutionPanel> | null>(null)
|
||||
const transportCompletionVisible = computed(() => transportPanel.value?.completionVisible ?? false)
|
||||
const transportCompletionText = computed(() => transportPanel.value?.completionText ?? '完成')
|
||||
const transportCompletionLoading = computed(() => transportPanel.value?.completionLoading ?? false)
|
||||
const transportCompletionDisabled = computed(() => transportPanel.value?.completionDisabled ?? true)
|
||||
const activeTransport = computed(() => rows.value.find(row => row.dispatchNo === transportEditNo.value))
|
||||
const cancellingPickup = ref('')
|
||||
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
|
||||
@@ -125,7 +130,16 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
const review = section.value
|
||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
}
|
||||
if (row.taskType === 'LOCAL_TRUCK') return [...(row.status === 'ACTIVE' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: '运输操作', priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
if (row.taskType === 'LOCAL_TRUCK') {
|
||||
const reviewActions = row.status === 'ACTIVE'
|
||||
? rowActionOrder.filter(value => ['SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'CANCEL'].includes(value) && (value !== 'CANCEL' || row.stage === 'BEFORE_LOADING') && canDispatchAction(row.actions, value, access.can))
|
||||
: []
|
||||
return [
|
||||
...(row.status === 'ACTIVE' && row.stage !== 'DELIVERED' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传送达照片' : '维护车辆', priority: 700 }] : []),
|
||||
...reviewActions.map((value, index) => ({ key: value, label: dispatchActionLabels[value], ariaLabel: dispatchActionLabels[value], priority: 600 - index * 10 })),
|
||||
{ key: 'detail', label: '详情', priority: 500 },
|
||||
]
|
||||
}
|
||||
const available = rowActionOrder.filter(value => (value !== 'SUBMIT_REVIEW' || row.taskType === 'PICKUP') && canDispatchAction(row.actions, value, access.can))
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
const execution = pickupExecutionLabel(row)
|
||||
@@ -138,26 +152,27 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
|
||||
}
|
||||
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
|
||||
/** 已送达仅保留详情,取货和送达照片在对应作业阶段维护。 */
|
||||
function pickupExecutionLabel(row: DispatchListRow) {
|
||||
if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return ''
|
||||
if (row.stage === 'DELIVERED') return '结束资源任务'
|
||||
if (row.stage === 'DELIVERED') return ''
|
||||
if (row.reviewState !== 'APPROVED') return ''
|
||||
return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : ''
|
||||
}
|
||||
function openTransport(row: DispatchListRow, resources = false) {
|
||||
transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo
|
||||
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? '运输操作' : pickupExecutionLabel(row)
|
||||
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? (row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传送达照片' : '运输操作') : pickupExecutionLabel(row)
|
||||
transportEditOpen.value = true
|
||||
}
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
|
||||
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row); return }
|
||||
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row, row.taskType === 'LOCAL_TRUCK' && row.stage === 'BEFORE_LOADING'); return }
|
||||
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
|
||||
if (value === 'bol') return void openBol(row)
|
||||
if (!rowActions(row).some(item => item.key === value)) return
|
||||
rejectionError.value = null
|
||||
if (value === 'route-order' || value === 'SUBMIT_REVIEW' && row.taskType !== 'PICKUP') return
|
||||
if (value === 'route-order') return
|
||||
operation.value = undefined
|
||||
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
|
||||
const requested = value as DispatchAction
|
||||
@@ -233,7 +248,7 @@ onBeforeUnmount(() => { sequence++ })
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
|
||||
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="false" cancel-text="关闭"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" @saved="load" /></AppDialog>
|
||||
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="transportCompletionVisible" :confirm-text="transportCompletionText" :confirm-loading="transportCompletionLoading" :confirm-disabled="transportCompletionDisabled" cancel-text="关闭" @confirm="transportPanel?.submitCompletion()"><TransportExecutionPanel v-if="transportEditOpen" ref="transportPanel" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" :pickup-stage="activeTransport?.stage" @saved="load" /></AppDialog>
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
|
||||
|
||||
@@ -110,7 +110,8 @@ onMounted(load)
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
|
||||
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" @changed="load()" />
|
||||
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" actions-target="#exception-detail-actions" @changed="load()" />
|
||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">关闭</el-button><div id="exception-detail-actions"></div></div></template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { custom
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
|
||||
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
|
||||
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
|
||||
} }))
|
||||
|
||||
@@ -442,7 +442,7 @@ onMounted(load)
|
||||
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable clearable :loading="feeItemsLoading" :disabled="feeItemsLoading || !!feeItemsError" placeholder="请选择费用项目"><el-option v-for="item in feeItemOptions" :key="item.feeCode" :label="item.feeTypeName" :value="item.feeCode" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
|
||||
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>
|
||||
|
||||
@@ -220,7 +220,7 @@ onMounted(initialize)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
|
||||
.admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }
|
||||
.pallet-editor-table { table-layout: fixed; }
|
||||
.pallet-editor-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | null) {
|
||||
overCapacity: Boolean(raw?.overCapacity),
|
||||
planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
|
||||
bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
|
||||
availableBookedCapacityCbm: raw?.availableBookedCapacityCbm == null ? null : number(raw.availableBookedCapacityCbm),
|
||||
catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
|
||||
lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
|
||||
snapshotAt: raw?.snapshotAt ?? null,
|
||||
|
||||
@@ -19,13 +19,20 @@ vi.mock('../api/platformSailingCalendar', () => ({
|
||||
listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]),
|
||||
previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(),
|
||||
}))
|
||||
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
|
||||
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, availableBookedCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
|
||||
const calendar: SailingCalendarResponse = {
|
||||
nextRefreshAt: '2026-10-01T00:00:00',
|
||||
month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 },
|
||||
filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] },
|
||||
records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }],
|
||||
}
|
||||
let wrapper: VueWrapper | undefined
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function mountView() {
|
||||
wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -33,7 +40,7 @@ async function mountView() {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date(2026, 8, 30, 12))
|
||||
vi.setSystemTime(new Date('2026-09-30T12:00:00+08:00'))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.listSailingTemplates).mockResolvedValue([])
|
||||
vi.mocked(api.listSailingChannels).mockResolvedValue([])
|
||||
@@ -43,6 +50,141 @@ beforeEach(() => {
|
||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
|
||||
describe('SailingCalendarView integrated workspace', () => {
|
||||
it.each(['success', 'failure'] as const)('ignores a stale calendar %s after the next month has loaded', async (outcome) => {
|
||||
const view = await mountView()
|
||||
const previous = deferred<SailingCalendarResponse>()
|
||||
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise)
|
||||
const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]')
|
||||
await keyword.trigger('keyup', { key: 'Enter' })
|
||||
const october = { ...calendar, month: '2026-10', records: [{ ...calendar.records[0], id: 'october', sailingNo: 'SL-OCTOBER', etdAt: '2026-10-01T10:00:00' }] }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce(october)
|
||||
await view.get('button[aria-label="下个月"]').trigger('click')
|
||||
await flushPromises()
|
||||
if (outcome === 'success') previous.resolve(calendar)
|
||||
else previous.reject(new Error('旧月份请求失败'))
|
||||
await flushPromises()
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
|
||||
expect(view.get('.detail-panel').text()).toContain('SL-OCTOBER')
|
||||
expect(view.find('.load-error-state').exists()).toBe(false)
|
||||
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps the latest calendar request loading when an earlier request finishes first', async () => {
|
||||
const view = await mountView()
|
||||
const previous = deferred<SailingCalendarResponse>()
|
||||
const current = deferred<SailingCalendarResponse>()
|
||||
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise)
|
||||
await view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]').trigger('keyup', { key: 'Enter' })
|
||||
await view.get('button[aria-label="下个月"]').trigger('click')
|
||||
previous.resolve(calendar)
|
||||
await flushPromises()
|
||||
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('true')
|
||||
current.resolve({ ...calendar, month: '2026-10', records: [] })
|
||||
await flushPromises()
|
||||
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps the copied sailing selected when its date is later in the current month', async () => {
|
||||
vi.setSystemTime(new Date('2026-09-20T12:00:00+08:00'))
|
||||
const original = { ...calendar.records[0], etdAt: '2026-09-21T10:00:00', cutoffAt: '2026-09-20T18:00:00' }
|
||||
const copied = { ...original, id: 'copied', sailingNo: 'SL-COPIED', etdAt: '2026-09-28T10:00:00', cutoffAt: '2026-09-27T18:00:00' }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original] })
|
||||
const view = await mountView()
|
||||
await view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '21')!.trigger('click')
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
vi.mocked(api.copySailing).mockResolvedValueOnce({ sailing: copied, histories: [] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original, copied] })
|
||||
await clickAction(view, '复制下一班')
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-09-28')
|
||||
expect(view.get('.sailing-card.active').text()).toContain('SL-COPIED')
|
||||
expect(view.get('.detail-actions').text()).toContain('订柜建议')
|
||||
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-09-20')
|
||||
})
|
||||
|
||||
it('uses the Shanghai calendar date at the UTC day boundary', async () => {
|
||||
vi.setSystemTime(new Date('2026-09-29T16:00:00Z'))
|
||||
const view = await mountView()
|
||||
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09' }))
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-09-30')
|
||||
expect(view.get('.calendar-note').text()).toContain('仅今天及以后')
|
||||
})
|
||||
|
||||
it('refreshes at cutoff so the current cargo reference can roll forward', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T12:00:01' }] })
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
const emptyVolume = { ...volume, actualReceivedCbm: 0, catchableReceivedCbm: 0, recommendedContainerCount: 0, pendingContainerCount: 0 }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, summary: { ...emptyVolume, sailingCount: 1 }, records: [{ ...calendar.records[0], volumeSummary: emptyVolume }] })
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
expect(view.get('.volume-line').text()).toContain('建议 0 柜')
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes across midnight and month end and removes expired selection', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, month: '2026-10', records: [], summary: { ...calendar.summary, sailingCount: 0 } })
|
||||
await vi.advanceTimersByTimeAsync(1001)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
|
||||
expect(view.find('.sailing-card').exists()).toBe(false)
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
expect(view.get('.calendar-note').text()).toContain('共 0 个船期')
|
||||
const calls = vi.mocked(listSailingCalendar).mock.calls.length
|
||||
view.unmount(); wrapper = undefined
|
||||
await vi.advanceTimersByTimeAsync(86_400_000)
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('refreshes the next month at the server boundary even when the earlier sailing is hidden', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T13:00:00', etdAt: '2026-09-30T18:00:00' }] })
|
||||
const view = await mountView()
|
||||
const nextMonth = { ...calendar, month: '2026-10', nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], id: 'sailing-2', etdAt: '2026-10-02T10:00:00', cutoffAt: '2026-10-01T18:00:00' }] }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue(nextMonth)
|
||||
await view.get('button[aria-label="下个月"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...nextMonth, nextRefreshAt: '2026-10-01T00:00:00' })
|
||||
await vi.advanceTimersByTimeAsync(3_600_002)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
|
||||
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
|
||||
})
|
||||
|
||||
it('backs off after a refresh failure instead of repeatedly requesting an expired boundary', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
|
||||
await mountView()
|
||||
vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('网络错误'))
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(59_000)
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1001)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('reloads today and clears actions when the selected sailing is no longer returned', async () => {
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], summary: { ...calendar.summary, sailingCount: 0 } })
|
||||
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps one create entry and defaults the sailing to the selected calendar date', async () => {
|
||||
const view = await mountView()
|
||||
expect(view.find('.detail-header button').exists()).toBe(false)
|
||||
@@ -129,6 +271,44 @@ async function editWeeklyTemplate() {
|
||||
}
|
||||
|
||||
describe('SailingCalendarView dialog workflows', () => {
|
||||
it.each([
|
||||
['货量明细', false], ['货量明细', true], ['变更历史', false], ['变更历史', true], ['订柜建议', false], ['订柜建议', true],
|
||||
] as const)('keeps the latest %s response after reopening; switch sailing: %s', async (label, switchSailing) => {
|
||||
const nextSailing = { ...calendar.records[0], id: 'sailing-2', sailingNo: 'SAIL-NEXT' }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [calendar.records[0], nextSailing] })
|
||||
let finishOld!: () => void
|
||||
if (label === '货量明细') {
|
||||
const previous = deferred<Awaited<ReturnType<typeof api.getSailingVolumeDetail>>>()
|
||||
const result = (code: string): Awaited<ReturnType<typeof api.getSailingVolumeDetail>> => ({ summary: volume, assignments: [{ id: code, inboundCode: code, volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
|
||||
vi.mocked(api.getSailingVolumeDetail).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
|
||||
finishOld = () => previous.resolve(result('OLD-ONLY'))
|
||||
} else if (label === '变更历史') {
|
||||
const previous = deferred<Awaited<ReturnType<typeof api.getSailing>>>()
|
||||
const result = (reason: string): Awaited<ReturnType<typeof api.getSailing>> => ({ sailing: nextSailing, histories: [{ id: reason, action: 'UPDATE', reason, createdAt: '2026-09-30T12:00:00' }] })
|
||||
vi.mocked(api.getSailing).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
|
||||
finishOld = () => previous.resolve(result('OLD-ONLY'))
|
||||
} else {
|
||||
const previous = deferred<Awaited<ReturnType<typeof api.getSailingBookingAdvice>>>()
|
||||
const result = (code: string) => ({ actualReceivedCbm: 12, catchableTotalCbm: 12, planningCapacityCbm: 68, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: code, containerTypeName: code, capacityCbm: 68, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00', catchableReceivedCbm: 12, lateReceivedCbm: 0 })
|
||||
vi.mocked(api.getSailingBookingAdvice).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
|
||||
finishOld = () => previous.resolve(result('OLD-ONLY'))
|
||||
}
|
||||
const view = await mountView()
|
||||
await view.findAll('.sailing-card')[0].trigger('click')
|
||||
await clickAction(view, label)
|
||||
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith(label === '货量明细' ? '货量明细' : label === '变更历史' ? '船期变更历史' : '订柜建议'))!
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
if (switchSailing) await view.findAll('.sailing-card')[1].trigger('click')
|
||||
await clickAction(view, label)
|
||||
expect(document.body.textContent).toContain('NEW-ONLY')
|
||||
finishOld()
|
||||
await flushPromises()
|
||||
expect(dialog.props('title')).toContain(switchSailing ? 'SAIL-NEXT' : 'SAIL-TEST')
|
||||
expect(document.body.textContent).toContain('NEW-ONLY')
|
||||
expect(document.body.textContent).not.toContain('OLD-ONLY')
|
||||
})
|
||||
|
||||
it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => {
|
||||
const view = await mountView()
|
||||
await clickAction(view, '新增单个船次')
|
||||
@@ -194,6 +374,49 @@ describe('SailingCalendarView dialog workflows', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(['cutoff', 'midnight'] as const)('preserves the editing target, changes and version across a %s refresh', async (boundary) => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
if (boundary === 'midnight') vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: boundary === 'midnight' ? '2026-10-01T00:00:00' : '2026-09-30T12:00:01.001' })
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
await clickAction(view, '编辑')
|
||||
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
|
||||
await dialog.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00')
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], nextRefreshAt: '2026-10-02T00:00:00' })
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
|
||||
document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button').forEach(button => {
|
||||
if (button.textContent?.trim() === '保存') button.click()
|
||||
})
|
||||
await flushPromises()
|
||||
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({
|
||||
expectedVersion: 1, cutoffAt: '2026-09-28T16:30:00',
|
||||
}))
|
||||
expect(api.createSailing).not.toHaveBeenCalled()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the edited form when the server rejects a save after automatic refresh', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
await clickAction(view, '编辑')
|
||||
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
vi.mocked(api.updateSailing).mockRejectedValueOnce(new Error('版本冲突'))
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1 }))
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
|
||||
})
|
||||
|
||||
it('loads volume, history and booking advice as read-only data for the business-number title', async () => {
|
||||
vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
|
||||
vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] })
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user