优化
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
|
||||
|
||||
describe('container fee API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
it('passes accountId and exact controller DTOs', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, sourceVersion: 1 } })
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
|
||||
await listContainerFees('9', '11'); await createContainerFee('9', '11', values)
|
||||
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
|
||||
await adjustContainerFee('9', '11', '3', 2, values); await voidContainerFee('9', '11', '3', 3)
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', values, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, ...values }, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
|
||||
})
|
||||
|
||||
it('reads allocation rows from Finance and never calculates them', async () => {
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [{ id: 81, containerId: 99 }] })
|
||||
.mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', payableAmount: 300 }, allocations: [{ id: 1, orderId: 8, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120 }] } })
|
||||
const result = await listContainerCostDetails('11', '99')
|
||||
expect(result[0]).toMatchObject({ supplierName: '承运商A', allocations: [{ orderId: '8', allocatedAmount: 120 }] })
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries/81', { params: { customerId: '11' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type ContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type ContainerFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
export interface ContainerFeeResource {
|
||||
id: string
|
||||
loadingPlanId: string
|
||||
containerBookingId: string | null
|
||||
containerNo: string | null
|
||||
feeItemId: string
|
||||
feeItemName: string
|
||||
direction: ContainerFeeDirection
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
settlementIncluded: boolean
|
||||
reviewStatus: ContainerFeeReviewStatus
|
||||
sourceStatus: 'ACTIVE' | 'VOIDED'
|
||||
sourceVersion: number
|
||||
remark: string | null
|
||||
reviewerName: string | null
|
||||
reviewedAt: string | null
|
||||
reviewReason: string | null
|
||||
}
|
||||
|
||||
export interface ContainerFeeSaveRequest {
|
||||
feeItemId: string
|
||||
direction: ContainerFeeDirection
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
settlementIncluded: boolean
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface ApplicableFeeItem {
|
||||
id: string
|
||||
name: string
|
||||
warehouseReceivable: boolean
|
||||
warehousePayable: boolean
|
||||
pickupReceivable: boolean
|
||||
pickupPayable: boolean
|
||||
}
|
||||
|
||||
export interface ContainerCostAllocation {
|
||||
id: string
|
||||
orderId: string
|
||||
inboundId: string | null
|
||||
volumeCbm: number
|
||||
totalVolumeCbm: number
|
||||
allocationRate: number
|
||||
allocatedAmount: number
|
||||
roundingAdjustment: boolean
|
||||
}
|
||||
|
||||
export interface ContainerCostDetail {
|
||||
id: string
|
||||
containerId: string | null
|
||||
supplierName: string | null
|
||||
feeItemName: string
|
||||
amount: number
|
||||
allocations: ContainerCostAllocation[]
|
||||
}
|
||||
|
||||
const id = (value: unknown) => value == null ? '' : String(value)
|
||||
const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
|
||||
const base = (planId: string) => `/platform/v1/loading-plans/${encodeURIComponent(planId)}/fee-details`
|
||||
const params = (accountId: string) => ({ accountId })
|
||||
|
||||
function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource {
|
||||
return {
|
||||
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId),
|
||||
containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId),
|
||||
feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection,
|
||||
businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY',
|
||||
settlementIncluded: Boolean(raw.settlementIncluded), reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus,
|
||||
sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion),
|
||||
remark: raw.remark == null ? null : String(raw.remark), reviewerName: raw.reviewerName == null ? null : String(raw.reviewerName),
|
||||
reviewedAt: raw.reviewedAt == null ? null : String(raw.reviewedAt), reviewReason: raw.reviewReason == null ? null : String(raw.reviewReason),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listContainerFees(planId: string, accountId: string) {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(base(planId), { params: params(accountId) })
|
||||
return unwrapApiResponse(response).map(normalizeFee)
|
||||
}
|
||||
|
||||
export async function createContainerFee(planId: string, accountId: string, payload: ContainerFeeSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(base(planId), payload, { params: params(accountId) })
|
||||
return normalizeFee(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function approveContainerFee(planId: string, accountId: string, feeId: string) {
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/approve`, undefined, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function rejectContainerFee(planId: string, accountId: string, feeId: string, reason: string) {
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/reject`, { reason }, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function adjustContainerFee(planId: string, accountId: string, feeId: string, expectedSourceVersion: number, values: ContainerFeeSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(`${base(planId)}/${encodeURIComponent(feeId)}/adjust`, { expectedSourceVersion, ...values }, { params: params(accountId) })
|
||||
return normalizeFee(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function voidContainerFee(planId: string, accountId: string, feeId: string, expectedSourceVersion: number) {
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/void`, { expectedSourceVersion }, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function listApplicableFeeItems() {
|
||||
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } })
|
||||
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
|
||||
}
|
||||
|
||||
export async function listContainerCostDetails(customerId: string, containerId: string): Promise<ContainerCostDetail[]> {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>('/platform/finance/v1/cost-entries', { params: { customerId } })
|
||||
const costs = unwrapApiResponse(response).filter((raw) => id(raw.containerId) === containerId)
|
||||
return Promise.all(costs.map(async (raw) => {
|
||||
const costId = id(raw.id)
|
||||
const detailResponse = await http.get<ApiEnvelope<{ cost: Record<string, unknown>; allocations: Record<string, unknown>[] }>, ApiEnvelope<{ cost: Record<string, unknown>; allocations: Record<string, unknown>[] }>>(`/platform/finance/v1/cost-entries/${encodeURIComponent(costId)}`, { params: { customerId } })
|
||||
const detail = unwrapApiResponse(detailResponse)
|
||||
return { id: costId, containerId: detail.cost.containerId == null ? null : id(detail.cost.containerId), supplierName: detail.cost.supplierName == null ? null : String(detail.cost.supplierName), feeItemName: String(detail.cost.feeItemName || ''), amount: numberValue(detail.cost.payableAmount), allocations: (detail.allocations || []).map((item) => ({ id: id(item.id), orderId: id(item.orderId), inboundId: item.inboundId == null ? null : id(item.inboundId), volumeCbm: numberValue(item.volumeCbm), totalVolumeCbm: numberValue(item.totalVolumeCbm), allocationRate: numberValue(item.allocationRate), allocatedAmount: numberValue(item.allocatedAmount), roundingAdjustment: Boolean(item.roundingAdjustment) })) }
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
|
||||
describe('ContainerFeesPanel contract', () => {
|
||||
it('is exposed from loading-list detail and derives customer context from real items', () => {
|
||||
expect(detail).toContain('<ContainerFeesPanel :plan-id="summary.planId" :customers="feeCustomers" />')
|
||||
expect(detail).toContain('item.accountId')
|
||||
expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户')
|
||||
expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用')
|
||||
})
|
||||
|
||||
it('covers create, review, reject, approved adjustment and void actions', () => {
|
||||
expect(panel).toContain('新增费用')
|
||||
expect(panel).toContain("row.reviewStatus === 'PENDING'")
|
||||
expect(panel).toContain('approveContainerFee')
|
||||
expect(panel).toContain('rejectContainerFee')
|
||||
expect(panel).toContain("row.reviewStatus === 'APPROVED'")
|
||||
expect(panel).toContain('adjustContainerFee')
|
||||
expect(panel).toContain('voidContainerFee')
|
||||
expect(panel).toContain('调整后版本递增并重新进入待审核状态')
|
||||
})
|
||||
|
||||
it('shows only server allocations and supplier snapshots', () => {
|
||||
expect(panel).toContain('listContainerCostDetails')
|
||||
expect(panel).toContain('仅展示 Finance 已持久化结果')
|
||||
expect(panel).toContain("cost.supplierName || '服务端未返回'")
|
||||
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
|
||||
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Check, CircleX, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeDirection, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
|
||||
const props = defineProps<{ planId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
const feeItems = ref<ApplicableFeeItem[]>([])
|
||||
const costs = ref<ContainerCostDetail[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const rejectOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const target = ref<ContainerFeeResource | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
const rejection = ref('')
|
||||
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
|
||||
const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review'))
|
||||
const customerOptions = computed(() => {
|
||||
const unique = new Map<string, string>()
|
||||
for (const item of props.customers) if (item.accountId) unique.set(item.accountId, item.customerName || '客户信息缺失')
|
||||
return [...unique].map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
|
||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
|
||||
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
|
||||
const containerId = computed(() => fees.value.find((item) => item.containerBookingId)?.containerBookingId || null)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 300, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
||||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||||
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
|
||||
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
|
||||
{ key: 'sourceStatus', label: '来源状态', width: 100, slotName: 'sourceStatus' },
|
||||
{ key: 'settlementIncluded', label: '参与结算', width: 100, slotName: 'settlementIncluded' },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
|
||||
]
|
||||
const allocationColumns: DataTableColumn[] = [
|
||||
{ key: 'orderId', prop: 'orderId', label: '订单', fixed: 'left', minWidth: 150 },
|
||||
{ key: 'inboundId', prop: 'inboundId', label: '入库单', minWidth: 150 },
|
||||
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
|
||||
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
|
||||
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
|
||||
]
|
||||
const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }], businessDate: [{ required: true, message: '请选择业务日期', trigger: 'change' }], amount: [{ type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }] }
|
||||
|
||||
function resetForm(item?: ContainerFeeResource) {
|
||||
target.value = item || null
|
||||
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark }
|
||||
: { feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!accountId.value || !canView.value) return
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, accountId.value), listApplicableFeeItems()])
|
||||
fees.value = feeRows; feeItems.value = items
|
||||
costs.value = []
|
||||
const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId
|
||||
if (currentContainerId) costs.value = await listContainerCostDetails(accountId.value, currentContainerId)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { resetForm(); editorOpen.value = true }
|
||||
function openAdjust(item: ContainerFeeResource) { resetForm(item); editorOpen.value = true }
|
||||
async function save() {
|
||||
if (!accountId.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form })
|
||||
else await createContainerFee(props.planId, accountId.value, { ...form })
|
||||
editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load()
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function approve(item: ContainerFeeResource) { await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
|
||||
function openReject(item: ContainerFeeResource) { target.value = item; rejection.value = ''; rejectOpen.value = true }
|
||||
async function reject() {
|
||||
if (!target.value || !rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
|
||||
saving.value = true
|
||||
try { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
|
||||
}
|
||||
async function voidFee(item: ContainerFeeResource) {
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
|
||||
await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
|
||||
}
|
||||
function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
|
||||
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
|
||||
watch(customerOptions, initializeCustomer, { immediate: true })
|
||||
watch(accountId, () => { fees.value = []; costs.value = []; void load() })
|
||||
watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' })
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
|
||||
<header class="fees-header"><div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div><el-button v-if="canEdit && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button></header>
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||
<template v-else>
|
||||
<div v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></div>
|
||||
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
|
||||
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template><template #settlementIncluded="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template>
|
||||
</DataTable>
|
||||
|
||||
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>往来单位:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }]" /></el-form-item><el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="rejectOpen" title="驳回柜次费用" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fees-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px; }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: 16px; }.fees-header p, .allocation-section p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.customer-context { display: flex; align-items: center; gap: 12px; padding: 0 18px 16px; }.customer-context > span { color: var(--cc-muted); font-size: 13px; }.customer-context :deep(.el-select) { width: min(320px, 100%); }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 12px 20px; padding: 14px 18px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { LoadingDetail, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||
@@ -19,7 +19,6 @@ const props = defineProps<{
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ retry: []; edit: [] }>()
|
||||
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
@@ -43,6 +42,11 @@ const itemColumns: DataTableColumn[] = [
|
||||
|
||||
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
|
||||
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
|
||||
const feeCustomers = computed(() => {
|
||||
const customers = new Map<string, string | null>()
|
||||
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||
})
|
||||
const title = computed(() => {
|
||||
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
|
||||
const number = props.mode === 'preload'
|
||||
@@ -70,14 +74,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDrawer
|
||||
v-model="open"
|
||||
:title="title"
|
||||
size="min(1180px, 96vw)"
|
||||
:loading="loading"
|
||||
:show-footer="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
@@ -122,6 +119,8 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<ContainerFeesPanel :plan-id="summary.planId" :customers="feeCustomers" />
|
||||
|
||||
<section class="history-section surface-panel" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
|
||||
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
@@ -137,11 +136,11 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-layout { display: grid; gap: 16px; }
|
||||
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.detail-summary > div { display: flex; min-width: 0; min-height: 78px; flex-direction: column; justify-content: center; gap: 7px; padding: 14px 16px; background: #fff; }
|
||||
.detail-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListsView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
const preloadDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
|
||||
describe('LoadingListsView', () => {
|
||||
it('keeps the operation column first and fixed left in both lists', () => {
|
||||
@@ -24,13 +25,31 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
})
|
||||
|
||||
it('uses the shared query, table, drawer, dialog and error components', () => {
|
||||
it('uses the shared query, table, dialog and error components without a detail drawer', () => {
|
||||
expect(source).toContain('QueryToolbar')
|
||||
expect(source).toContain('DataTable')
|
||||
expect(source).toContain('AppDialog')
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('ApiErrorMeta')
|
||||
expect(detailSource).toContain('AppDrawer')
|
||||
expect(detailSource).not.toContain('AppDrawer')
|
||||
expect(detailSource).toContain('ContainerFeesPanel')
|
||||
})
|
||||
|
||||
it('opens preload detail as a route and confirmed detail inline on the list page', () => {
|
||||
expect(source).toContain("name: 'preload-plan-detail'")
|
||||
expect(source).toContain('v-if="!isPreload && (detailLoading || detailError || detail)"')
|
||||
expect(source).toContain('class="confirmed-inline-detail surface-panel"')
|
||||
expect(preloadDetailSource).toContain('返回列表')
|
||||
expect(preloadDetailSource).toContain('router.back()')
|
||||
})
|
||||
|
||||
it('keeps the real transport edit contract in a 620px centered modal', () => {
|
||||
expect(source).toContain('class="loading-transport-dialog"')
|
||||
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||
expect(preloadDetailSource).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||
expect(source).not.toContain('备注信息(仅自己可见)')
|
||||
expect(source).not.toContain('备注信息(客户可见)')
|
||||
expect(source).not.toContain('备注信息(仓库可见)')
|
||||
})
|
||||
|
||||
it('only exposes transport editing for LOADING preload details', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
|
||||
const props = defineProps<{ mode: LoadingListMode }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<LoadingListSummary[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -39,7 +40,6 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const query = reactive<QueryModel>({ keyword: '', createdRange: [] })
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<LoadingDetail | null>(null)
|
||||
@@ -150,11 +150,14 @@ async function openDeepLinkedDetail() {
|
||||
const key = `${props.mode}:${planId}:${warehouseId}`
|
||||
if (handledDeepLink.value === key) return
|
||||
handledDeepLink.value = key
|
||||
if (isPreload.value) {
|
||||
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
|
||||
return
|
||||
}
|
||||
selectedPlanId.value = planId
|
||||
selectedWarehouseId.value = warehouseId
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
const expectedStatuses = isPreload.value ? ['EDITING', 'LOADING'] : ['COMPLETED']
|
||||
const loadedDetail = detail.value as LoadingDetail | null
|
||||
@@ -198,11 +201,14 @@ async function loadDetail() {
|
||||
|
||||
function openDetail(rowValue: object) {
|
||||
const row = rowValue as LoadingListSummary
|
||||
if (isPreload.value) {
|
||||
void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
||||
return
|
||||
}
|
||||
selectedPlanId.value = String(row.planId)
|
||||
selectedWarehouseId.value = String(row.warehouseId || '')
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
void loadDetail()
|
||||
}
|
||||
|
||||
@@ -213,7 +219,6 @@ async function openEditFromRow(rowValue: object) {
|
||||
selectedWarehouseId.value = String(row.warehouseId || '')
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
const loadedDetail = detail.value as LoadingDetail | null
|
||||
if (loadedDetail && ['EDITING', 'LOADING'].includes(loadedDetail.summary.status)) openEdit()
|
||||
@@ -317,7 +322,8 @@ onMounted(() => {
|
||||
</ListPageLayout>
|
||||
|
||||
<LoadingListDetailDrawer
|
||||
v-model="detailOpen"
|
||||
v-if="!isPreload && (detailLoading || detailError || detail)"
|
||||
class="confirmed-inline-detail surface-panel"
|
||||
:mode="mode"
|
||||
:loading="detailLoading"
|
||||
:detail="detail"
|
||||
@@ -329,7 +335,9 @@ onMounted(() => {
|
||||
|
||||
<AppDialog
|
||||
v-model="editOpen"
|
||||
class="loading-transport-dialog"
|
||||
title="编辑预装运输信息"
|
||||
width="min(620px, calc(100vw - 28px))"
|
||||
confirm-text="保存"
|
||||
:confirm-loading="editSaving"
|
||||
:confirm-disabled="!canEdit"
|
||||
@@ -352,7 +360,9 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.confirmed-inline-detail { padding: 20px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
@media (max-width: 640px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
@media (max-width: 720px) { .confirmed-inline-detail { padding: 12px; } .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
|
||||
describe('PreloadPlanDetailView prototype contract', () => {
|
||||
it('is a formal responsive detail page with a return action', () => {
|
||||
expect(source).toContain('<AdminPage>')
|
||||
expect(source).toContain('返回列表')
|
||||
expect(source).toContain('<ArrowLeft')
|
||||
expect(source).toContain("router.push({ name: 'loading-plans' })")
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
})
|
||||
|
||||
it('requires the real warehouse context and preserves status boundaries', () => {
|
||||
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
expect(source).toContain("['EDITING', 'LOADING'].includes(result.summary.status)")
|
||||
expect(source).toContain('MODE_STATUS_MISMATCH')
|
||||
})
|
||||
|
||||
it('uses the real optimistic-lock transport endpoint without inventing note writes', () => {
|
||||
expect(source).toContain('updatePreloadTransport')
|
||||
expect(source).toContain('expectedVersion: detail.value.summary.version')
|
||||
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||
expect(source).not.toContain('saveMfNote')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import { getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const detail = ref<LoadingDetail<PreloadSummary> | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const editOpen = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editError = ref<ApiErrorDetails | null>(null)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
|
||||
const editRules: FormRules = {
|
||||
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
|
||||
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
|
||||
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!planId.value || !warehouseId.value) {
|
||||
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少所属仓库信息,无法安全查询详情,请返回列表重新进入。' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await getPreloadPlan(planId.value, warehouseId.value)
|
||||
if (!['EDITING', 'LOADING'].includes(result.summary.status)) {
|
||||
detail.value = null
|
||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划不属于预装清单状态,请从对应清单入口查看。' }
|
||||
return
|
||||
}
|
||||
detail.value = result
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else void router.push({ name: 'loading-plans' })
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
|
||||
editForm.containerNo = detail.value.summary.containerNo || ''
|
||||
editForm.voyageNo = detail.value.summary.voyageNo || ''
|
||||
editForm.operationReason = ''
|
||||
editError.value = null
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function saveTransport() {
|
||||
if (!detail.value || !(await editFormRef.value?.validate().catch(() => false))) return
|
||||
editSaving.value = true
|
||||
editError.value = null
|
||||
try {
|
||||
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
|
||||
containerNo: editForm.containerNo.trim() || null,
|
||||
voyageNo: editForm.voyageNo.trim() || null,
|
||||
operationReason: editForm.operationReason.trim() || null,
|
||||
expectedVersion: detail.value.summary.version,
|
||||
})
|
||||
editOpen.value = false
|
||||
ElMessage.success('运输信息已更新')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
|
||||
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
|
||||
await loadDetail()
|
||||
editError.value = { ...details, message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。' }
|
||||
} else editError.value = details
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page">
|
||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" @retry="loadDetail" @edit="openEdit" />
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog" title="编辑预装运输信息" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
||||
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入集装箱号" /></el-form-item>
|
||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preload-detail-page { display: grid; min-width: 0; max-width: 1180px; gap: 16px; }
|
||||
.back-button { width: fit-content; }
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { loadingListRoutes } from './routes'
|
||||
|
||||
describe('loading list routes', () => {
|
||||
it('declares separate preload and completed list entries with backend permissions', () => {
|
||||
it('declares preload list/detail and completed list entries with backend permissions', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:view'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -11,6 +11,16 @@ export const loadingListRoutes = [
|
||||
permission: 'platform:firstleg:preload:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'loading-plans/:planId',
|
||||
name: 'preload-plan-detail',
|
||||
component: () => import('./pages/PreloadPlanDetailView.vue'),
|
||||
meta: {
|
||||
title: '预装清单详情',
|
||||
description: '查看预装清单基本信息、装柜货物明细和操作历史',
|
||||
permission: 'platform:firstleg:preload:view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'loading-lists',
|
||||
name: 'loading-lists',
|
||||
|
||||
Reference in New Issue
Block a user