This commit is contained in:
2026-08-11 18:43:56 +08:00
parent 6456278ec8
commit 6622815d6c
37 changed files with 739 additions and 205 deletions
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Pencil, RefreshCw, Save } from '@lucide/vue'
import { Pencil, Plus, RefreshCw, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from './overseasStorageFee'
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
@@ -48,19 +49,12 @@ async function closeEditor() {
visible.value = false
}
function validate() {
if (storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('仓租必须为5档,退货仓储费必须为4档'); return false }
for (const [label, tiers] of [['仓租', storageTiers.value], ['退货仓储费', returnTiers.value] ] as const) {
let expected = 1
for (const [index, tier] of tiers.entries()) {
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) { ElMessage.error(`${label}第${index + 1}档天数必须连续且从第1天开始`); return false }
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) { ElMessage.error(`${label}第${index + 1}档价格不能小于0`); return false }
if (index === tiers.length - 1) { if (tier.endDay !== null) { ElMessage.error(`${label}最后一档必须为“以上”`); return false } }
else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) { ElMessage.error(`${label}第${index + 1}档结束天数无效`); return false }
if (tier.endDay !== null) expected = tier.endDay + 1
}
}
const message = validateFeeTiers(form.tiers)
if (message) { ElMessage.error(message); return false }
return true
}
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }
async function save() {
if (!config.value || !validate()) return
saving.value = true
@@ -85,7 +79,7 @@ onMounted(load)
</template>
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><table><thead><tr><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(tier)"><Trash2 :size="15" />删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存费用</el-button></div></template>
</AppDialog>
</section>
@@ -93,5 +87,6 @@ onMounted(load)
<style scoped>
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups :deep(.el-input-number) { width:130px; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups { overflow:auto; }.edit-groups > div { min-width:620px; }.panel-heading :deep(.el-button) { min-height:44px; } }
</style>
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
import type { OverseasFeeType, OverseasStorageFeeTier } from './overseasStorageFee'
function tier(id: string, feeType: OverseasFeeType, startDay: number, endDay: number | null): OverseasStorageFeeTier {
return { id, feeType, billingUnit: feeType === 'STORAGE' ? 'CBM' : 'PALLET', startDay, endDay, unitPrice: 1, sortNo: 1 }
}
describe('overseas storage fee tier editing', () => {
it('adds a new open-ended tier and closes the previous tail', () => {
const tiers = [tier('s1', 'STORAGE', 1, null), tier('r1', 'RETURN_STORAGE', 1, null)]
expect(addFeeTier(tiers, 'STORAGE')).toBeNull()
expect(tiers.filter((item) => item.feeType === 'STORAGE').map(({ startDay, endDay }) => ({ startDay, endDay })))
.toEqual([{ startDay: 1, endDay: 1 }, { startDay: 2, endDay: null }])
expect(validateFeeTiers(tiers)).toBeNull()
})
it('removes a middle tier and extends the previous range', () => {
const tiers = [
tier('s1', 'STORAGE', 1, 7), tier('s2', 'STORAGE', 8, 30), tier('s3', 'STORAGE', 31, null),
tier('r1', 'RETURN_STORAGE', 1, null),
]
expect(removeFeeTier(tiers, tiers[1]!)).toBeNull()
expect(tiers.slice(0, 2).map(({ startDay, endDay }) => ({ startDay, endDay })))
.toEqual([{ startDay: 1, endDay: 30 }, { startDay: 31, endDay: null }])
expect(validateFeeTiers(tiers)).toBeNull()
})
it('does not delete the only tier of a fee type', () => {
const tiers = [tier('s1', 'STORAGE', 1, null), tier('r1', 'RETURN_STORAGE', 1, null)]
expect(removeFeeTier(tiers, tiers[0]!)).toBe('每种费用至少需要保留1档')
})
})
@@ -0,0 +1,61 @@
import type { OverseasFeeType, OverseasStorageFeeTier } from './overseasStorageFee'
let temporaryTierId = 0
export function tiersByType(tiers: OverseasStorageFeeTier[], feeType: OverseasFeeType) {
return tiers.filter((tier) => tier.feeType === feeType)
}
export function addFeeTier(tiers: OverseasStorageFeeTier[], feeType: OverseasFeeType) {
if (tiers.length >= 100) return '阶梯费用明细最多为100组'
const group = tiersByType(tiers, feeType)
const last = group.at(-1)
if (!last) return '每种费用至少需要保留1档'
if (last.startDay >= 36500) return '当前尾档已从第36500天开始,无法继续新增'
const startDay = last.startDay + 1
last.endDay = startDay - 1
const newTier: OverseasStorageFeeTier = {
...last,
id: `new-${feeType}-${++temporaryTierId}`,
startDay,
endDay: null,
sortNo: last.sortNo + 1,
}
const lastIndex = tiers.indexOf(last)
tiers.splice(lastIndex + 1, 0, newTier)
return null
}
export function removeFeeTier(tiers: OverseasStorageFeeTier[], tier: OverseasStorageFeeTier) {
const group = tiersByType(tiers, tier.feeType)
if (group.length <= 1) return '每种费用至少需要保留1档'
const groupIndex = group.indexOf(tier)
const previous = group[groupIndex - 1]
const next = group[groupIndex + 1]
if (previous) previous.endDay = tier.endDay
else if (next) next.startDay = 1
tiers.splice(tiers.indexOf(tier), 1)
return null
}
export function validateFeeTiers(tiers: OverseasStorageFeeTier[]) {
if (tiers.length < 2 || tiers.length > 100) return '仓租和退货仓储费必须各保留至少1档,合计不超过100档'
for (const [feeType, label] of [['STORAGE', '仓租'], ['RETURN_STORAGE', '退货仓储费']] as const) {
const group = tiersByType(tiers, feeType)
if (group.length === 0) return `${label}至少需要保留1档`
let expected = 1
for (const [index, tier] of group.entries()) {
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) return `${label}第${index + 1}档天数必须连续且从第1天开始`
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) return `${label}第${index + 1}档价格不能小于0`
if (index === group.length - 1) {
if (tier.endDay !== null) return `${label}最后一档必须为“以上”`
} else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) {
return `${label}第${index + 1}档结束天数无效`
}
if (tier.endDay !== null) expected = tier.endDay + 1
}
}
return null
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Pencil } from '@lucide/vue'
import { Pencil, Plus, Trash2 } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
@@ -11,6 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from '../overseasStorageFee'
import { addFeeTier, removeFeeTier, validateFeeTiers } from '../overseasStorageFeeTiers'
const access = useAccessPreviewStore()
const loading = ref(false)
@@ -49,8 +50,12 @@ async function load() {
}
function search() { keyword.value = filters.keyword; page.value = 1; void load() }
function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true }
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }
async function save() {
if (!editing.value || storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('费用配置必须包含仓租5档和退货仓储费4档'); return }
if (!editing.value) return
const validationMessage = validateFeeTiers(form.tiers)
if (validationMessage) { ElMessage.error(validationMessage); return }
saving.value = true
try { await updateCustomerOverseasStorageFees(editing.value.customerId, { customerId: editing.value.customerId, customerName: editing.value.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) }); dialogOpen.value = false; ElMessage.success('海外仓费用已保存'); await load() }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '保存失败,请刷新后重试').message) }
@@ -75,7 +80,7 @@ onMounted(() => { void load() })
</section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><el-table :data="group" size="small"><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><el-table :data="group" size="small"><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)"><Trash2 :size="15" />删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
</AppDialog>
</div>
</AdminPage>
@@ -89,5 +94,7 @@ onMounted(() => { void load() })
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
.groups h3 { margin:0 0 10px; font-size:15px; }
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
.group-heading h3 { margin:0; }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .groups,.tier-list { grid-template-columns:1fr; } }
</style>
@@ -5,6 +5,15 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/Cu
describe('CustomsCasesView', () => {
it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") })
it('supports create, detail, history and agent submission', () => { expect(source).toContain('createCustomsCase'); expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') })
it('localizes backend statuses and timeline actions throughout the page', () => {
expect(source).toContain('customsCaseStatusOptions')
expect(source).toContain(':status="customsStatusLabel(row.status)"')
expect(source).toContain(':status="customsStatusLabel(detail.customsCase.status)"')
expect(source).toContain('customsHistoryActionLabel(entry.action)')
expect(source).toContain('customsStatusLabel(entry.fromStatus)')
expect(source).toContain("detail.value?.customsCase.status === 'READY_TO_SUBMIT'")
expect(source).not.toContain("['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED']")
})
it('selects business resources instead of asking operators for customer and order ids', () => {
expect(source).toContain('listPlatformTenants')
expect(source).toContain('listPlatformUnifiedOrders')
@@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from '../status'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -19,7 +20,7 @@ import { createCustomsCase, getCustomsCase, listCustomsCases, submitCustomsCase
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCaseResource } from '../types'
const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' }); const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID', width: 170 }, { key: 'inboundId', type: 'input', label: '入库单 ID', width: 170 }, { key: 'status', type: 'select', label: '状态', options: ['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED'].map((value) => ({ label: value, value })) }]
const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' }); const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID', width: 170 }, { key: 'inboundId', type: 'input', label: '入库单 ID', width: 170 }, { key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) }]
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单 ID', minWidth: 155 }, { prop: 'inboundId', label: '入库单 ID', minWidth: 155 }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
async function load() { loading.value = true; error.value = null; try { const result = await listCustomsCases({ page: page.value, size: pageSize.value, orderId: String(filters.orderId || '') || undefined, inboundId: String(filters.inboundId || '') || undefined, status: String(filters.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
@@ -46,12 +47,12 @@ async function selectCustomer(customerId: string) {
function selectOrder(orderId: string) { createForm.orderId = orderId; createForm.inboundId = String(orders.value.find((item) => String(item.orderId) === orderId)?.inboundId || '') }
function openCreate() { Object.assign(createForm, { customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); createError.value = null; orders.value = []; createOpen.value = true; void loadCreateOptions() }
async function create() { if (!await formRef.value?.validate().catch(() => false)) return; createSaving.value = true; createError.value = null; try { await createCustomsCase({ ...createForm }); ElMessage.success('报关协同已创建'); createOpen.value = false; await load() } catch (cause) { createError.value = getApiErrorDetails(cause) } finally { createSaving.value = false } }
const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value && access.can('platform:firstleg:customs:submit') && !['SUBMITTED', 'RELEASED'].includes(detail.value.customsCase.status))
const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value?.customsCase.status === 'READY_TO_SUBMIT' && access.can('platform:firstleg:customs:submit'))
async function openDetail(id: string) { detailOpen.value = true; detailLoading.value = true; actionError.value = null; try { detail.value = await getCustomsCase(id) } catch (cause) { actionError.value = getApiErrorDetails(cause) } finally { detailLoading.value = false } }
async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } }
watch([page, pageSize], () => void load()); onMounted(load)
</script>
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #toolbar-actions><el-button v-if="access.can('platform:firstleg:customs:manage')" type="primary" @click="openCreate"><Plus :size="16" />新增报关</el-button></template><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="row.status" /></template></DataTable></section></ListPageLayout>
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #toolbar-actions><el-button v-if="access.can('platform:firstleg:customs:manage')" type="primary" @click="openCreate"><Plus :size="16" />新增报关</el-button></template><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="createOpen" title="新增报关协同" width="720px" :confirm-loading="createSaving" @confirm="create"><el-alert v-if="createError" type="error" :closable="false"><ApiErrorMeta :details="createError" /></el-alert><el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading || ordersLoading" @click="reloadCreateOptions">重新加载选项</el-button></el-alert><el-form ref="formRef" :model="createForm" :rules="rules" label-position="top"><el-row :gutter="16"><el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="createForm.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订单 / 入库单" prop="orderId"><el-select :model-value="createForm.orderId" filterable placeholder="请先选择客户,再选择已入库订单" :loading="ordersLoading" :disabled="!createForm.customerId" style="width:100%" @update:model-value="selectOrder(String($event || ''))"><el-option v-for="order in orders" :key="String(order.orderId)" :label="`${order.orderNo || order.orderId} / ${order.inboundCode || order.inboundId}`" :value="String(order.orderId)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select v-model="createForm.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%"><el-option v-for="container in containers" :key="container.id" :label="`${container.cabinetNo || container.bookingNo}${container.containerNo ? ` / ${container.containerNo}` : ''}`" :value="container.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="资料清单版本 ID" prop="checklistVersionId"><el-input v-model="createForm.checklistVersionId" placeholder="请输入已发布的资料清单版本 ID" /><small>当前服务端尚未提供资料清单版本查询接口。</small></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" placeholder="请输入报关协同备注(可选)" /></el-form-item></el-col></el-row></el-form></AppDialog>
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="detail.customsCase.status" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" placeholder="请输入线下报关代理名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" placeholder="请输入线下报关代理名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from './status'
describe('customs status labels', () => {
it('matches the backend customs case state machine', () => {
expect(customsCaseStatusOptions).toEqual([
{ value: 'DOCUMENTS_PENDING', label: '待补资料' },
{ value: 'REVIEWING', label: '审核中' },
{ value: 'SUPPLEMENT_REQUIRED', label: '需补件' },
{ value: 'READY_TO_SUBMIT', label: '待提交代理' },
{ value: 'RECEIPT_PENDING', label: '待回执' },
{ value: 'RECEIPT_AVAILABLE', label: '已有回执' },
{ value: 'CLEARANCE_COMPLETED', label: '已放行' },
{ value: 'CANCELLED', label: '已取消' },
])
})
it('localizes status transitions and does not expose backend codes', () => {
expect(customsStatusLabel('READY_TO_SUBMIT')).toBe('待提交代理')
expect(customsStatusLabel(null)).toBe('—')
expect(customsStatusLabel('FUTURE_STATUS')).toBe('未知状态')
expect(customsHistoryActionLabel('REVIEW_DOCUMENT_RETURN')).toBe('退回补件')
expect(customsHistoryActionLabel('SUBMIT_AGENT_BATCH')).toBe('提交报关代理')
expect(customsHistoryActionLabel('FUTURE_ACTION')).toBe('状态更新')
})
})
+35
View File
@@ -0,0 +1,35 @@
export const customsCaseStatusOptions = [
{ value: 'DOCUMENTS_PENDING', label: '待补资料' },
{ value: 'REVIEWING', label: '审核中' },
{ value: 'SUPPLEMENT_REQUIRED', label: '需补件' },
{ value: 'READY_TO_SUBMIT', label: '待提交代理' },
{ value: 'RECEIPT_PENDING', label: '待回执' },
{ value: 'RECEIPT_AVAILABLE', label: '已有回执' },
{ value: 'CLEARANCE_COMPLETED', label: '已放行' },
{ value: 'CANCELLED', label: '已取消' },
] as const
const statusLabels = Object.fromEntries(customsCaseStatusOptions.map((option) => [option.value, option.label])) as Record<string, string>
const historyActionLabels: Record<string, string> = {
CREATE: '创建报关协同',
CREATE_CASE: '创建报关协同',
UPLOAD_DOCUMENT: '上传报关资料',
REPLACE_DOCUMENT: '替换报关资料',
REVIEW_DOCUMENT_APPROVE: '资料审核通过',
REVIEW_DOCUMENT_RETURN: '退回补件',
SUBMIT_AGENT_BATCH: '提交报关代理',
UPLOAD_RECEIPT: '上传报关回执',
CANCEL: '取消报关协同',
CANCEL_CASE: '取消报关协同',
}
export function customsStatusLabel(status: string | null | undefined) {
if (!status) return '—'
return statusLabels[status] || '未知状态'
}
export function customsHistoryActionLabel(action: string | null | undefined) {
if (!action) return '状态更新'
return historyActionLabels[action] || '状态更新'
}
+19 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
@@ -18,6 +18,24 @@ describe('finance api', () => {
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 })
})
it('uses the formal G1 account list path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
const result = await listFinanceAccounts({ page: 2, size: 20, keyword: '客户A', status: 'ACTIVE' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, keyword: '客户A', status: 'ACTIVE' } })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' }], page: { page: 1, pageSize: 50, total: 1 } })
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/ledger', { params: { page: 1, pageSize: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' } })
expect(result.records[0]).toEqual({ ledgerId: '81', occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: '91', documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' })
})
it('uses fixed read-model paths for all five finance pages', async () => {
const get = vi.spyOn(http, 'get')
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } })
+17 -14
View File
@@ -3,8 +3,8 @@ import type { FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, Fin
const basePath = '/platform/finance/v1'
export const financePhase1Capabilities = {
accountList: false,
accountLedger: false,
accountList: true,
accountLedger: true,
feeSourceDetail: false,
feeSourceReview: false,
feeSourceSettlementInclusion: false,
@@ -74,24 +74,27 @@ export async function listFinanceFeePool(customerId: string, direction?: 'RECEIV
}
export async function listFinanceAccounts(params: { page?: number; size?: number; keyword?: string; status?: string } = {}): Promise<{ records: FinanceAccountSummary[]; total: number; pages: number }> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts`, { params })
const data = unwrapApiResponse(response) as RawResource
const records = (Array.isArray(data.records) ? data.records : Array.isArray(data.items) ? data.items : []).map((item) => ({ customerId: stringValue(item.customerId ?? item.customer_id), customerName: stringValue(item.customerName ?? item.customer_name), customerStatus: stringValue(item.customerStatus ?? item.status), currency: 'CNY' as const, totalReceivable: numberValue(item.totalReceivable ?? item.total_receivable), receivedAmount: numberValue(item.receivedAmount ?? item.totalReceived ?? item.received_amount), outstandingAmount: numberValue(item.outstandingAmount ?? item.outstanding_amount), creditDays: numberValue(item.creditDays ?? item.credit_days ?? 30) }))
return { records, total: numberValue(data.total ?? records.length), pages: numberValue(data.pages ?? 1) || 1 }
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, keyword: params.keyword || undefined, status: params.status || undefined }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
const data = unwrapApiResponse(response)
const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function listFinanceAccountLedger(customerId: string, params: { page?: number; size?: number; businessType?: string; startDate?: string; endDate?: string } = {}): Promise<{ records: FinanceLedgerEntry[]; total: number; pages: number }> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${customerId}/ledger`, { params })
const data = unwrapApiResponse(response) as RawResource
const records = (Array.isArray(data.records) ? data.records : Array.isArray(data.items) ? data.items : []).map((item) => ({ entryId: stringValue(item.entryId ?? item.id), businessType: stringValue(item.businessType), resourceId: stringValue(item.resourceId), resourceNo: stringValue(item.resourceNo), occurredAt: stringValue(item.occurredAt), debitAmount: numberValue(item.debitAmount), creditAmount: numberValue(item.creditAmount), balance: numberValue(item.balance), currency: 'CNY' as const, status: stringValue(item.status) }))
return { records, total: numberValue(data.total ?? records.length), pages: numberValue(data.pages ?? 1) || 1 }
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, businessType: params.businessType || undefined, startDate: params.startDate || undefined, endDate: params.endDate || undefined }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts/${customerId}/ledger`, { params: query })
const data = unwrapApiResponse(response)
const records = data.map((item) => ({ ledgerId: stringValue(item.ledgerId), occurredAt: stringValue(item.occurredAt), businessType: stringValue(item.businessType), documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo), receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange), outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark) }))
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function getFinanceFeeSource(feeSourceId: string): Promise<FinanceFeeSourceDetail> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`)
const item = unwrapApiResponse(response)
const pool = (await Promise.resolve([item])).map((raw) => ({ poolItemId: stringValue(raw.poolItemId ?? raw.id), direction: stringValue(raw.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(raw.sourceType), sourceId: stringValue(raw.sourceId), sourceVersion: numberValue(raw.sourceVersion) || 1, orderId: raw.orderId == null ? null : stringValue(raw.orderId), inboundId: raw.inboundId == null ? null : stringValue(raw.inboundId), containerId: raw.containerId == null ? null : stringValue(raw.containerId), feeItemName: stringValue(raw.feeItemName), amount: numberValue(raw.amount), currency: 'CNY' as const, status: stringValue(raw.status), createdAt: stringValue(raw.createdAt) }))[0]
return { ...pool, reviewStatus: stringValue(item.reviewStatus), settlementIncluded: Boolean(item.settlementIncluded), sourceStatus: stringValue(item.sourceStatus), reviewReason: stringValue(item.reviewReason), settlementReason: stringValue(item.settlementReason), sourceHash: stringValue(item.sourceHash), operationLogs: Array.isArray(item.operationLogs) ? item.operationLogs as FinanceFeeSourceDetail['operationLogs'] : [], version: numberValue(item.version) }
throw new FinanceCapabilityBlockedError('getFinanceFeeSource')
}
export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('reviewFinanceFeeSource') }
@@ -45,6 +45,18 @@ describe('finance five-page delivery contract', () => {
expect(api).not.toContain('function listFinanceReceipts')
expect(api).not.toContain('function listFinanceCosts')
})
it('connects G1 customer accounts to the formal list and ledger operations', () => {
const page = source('src/modules/finance/pages/FinanceCustomerAccountsView.vue')
expect(api).toContain('`${basePath}/accounts`')
expect(api).toContain('`${basePath}/accounts/${customerId}/ledger`')
expect(page).toContain('listFinanceAccounts')
expect(page).toContain('listFinanceAccountLedger')
expect(page).not.toContain('getFinanceCustomerAccount')
expect(page).not.toContain('接口尚未提供')
expect(page.indexOf("key: 'actions'")).toBeLessThan(page.indexOf("key: 'customerName'"))
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
})
})
function basePathMarker(endpoint: string) {
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -13,128 +12,120 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getFinanceCustomerAccount, getFinanceCustomerTerms, updateFinanceCustomerTerms } from '../api/finance'
import type { FinanceAccountEntry, FinanceCustomerTerms } from '../types'
interface AccountRow {
id: string
customerName: string
customerCode: string
creditDays: number
totalReceivable: number
totalReceived: number
outstandingAmount: number
entryCount: number
entries: FinanceAccountEntry[]
status: string
}
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = reactive<QueryModel>({ customerId: '' })
const rows = ref<AccountRow[]>([])
const filters = reactive<QueryModel>({ keyword: '', status: '' })
const rows = ref<FinanceAccountSummary[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const drawerOpen = ref(false)
const editing = ref(false)
const saving = ref(false)
const selected = ref<AccountRow | null>(null)
const selected = ref<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
const canView = computed(() => access.can('platform:finance:bill:view') && access.can('platform:finance:customer-terms:view'))
const canUpdate = computed(() => access.can('platform:finance:customer-terms:update'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: `${item.tenantName} (${item.tenantCode})`, value: String(item.id), disabled: item.status !== 'ACTIVE' })) }])
const canView = computed(() => access.can('platform:finance:account:view'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'customerCode', label: '客户编号', width: 150 },
{ key: 'creditDays', label: '账期(天)', width: 100 }, { key: 'totalReceivable', label: '累计应收(CNY)', width: 140 },
{ key: 'totalReceived', label: '累计已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 },
{ key: 'entryCount', label: '账户流水数', width: 120 },
{ key: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerCode', label: '客户编号', width: 150 },
{ key: 'contactName', label: '联系人', width: 120 },
{ key: 'contactPhone', label: '联系电话', width: 140 },
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
]
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters) {
Object.assign(filters, model)
const customerId = String(filters.customerId || '')
rows.value = []; loadError.value = null
if (!customerId) return
const customer = customers.value.find((item) => String(item.id) === customerId)
if (!customer) return
if (!canView.value) return
loading.value = true
loadError.value = null
try {
const [terms, account] = await Promise.all([getFinanceCustomerTerms(customerId), getFinanceCustomerAccount(customerId)])
rows.value = [{ id: customerId, customerName: customer.tenantName, customerCode: customer.tenantCode, creditDays: terms.creditDays, totalReceivable: account.totalReceivable, totalReceived: account.totalReceived, outstandingAmount: account.outstandingAmount, entryCount: account.entries.length, entries: account.entries, status: customer.status }]
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.keyword || '').trim(), status: String(filters.status || '') })
rows.value = result.records
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
finally { loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
async function openAccount(row: Record<string, unknown>, edit: boolean) {
const account = row as unknown as AccountRow
selected.value = account; editing.value = edit; drawerOpen.value = true; loadError.value = null
try { selectedTerms.value = await getFinanceCustomerTerms(account.id); creditDays.value = selectedTerms.value.creditDays }
catch (error) { loadError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
selected.value = row as unknown as FinanceAccountSummary
editing.value = edit
drawerOpen.value = true
ledger.value = []
ledgerError.value = null
selectedTerms.value = null
if (edit) {
if (!canViewTerms.value) return
try { selectedTerms.value = await getFinanceCustomerTerms(selected.value.customerId); creditDays.value = selectedTerms.value.creditDays }
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
return
}
ledgerLoading.value = true
try {
const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 })
ledger.value = result.records
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
finally { ledgerLoading.value = false }
}
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true
try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.id, { customerName: selected.value.customerName, creditDays: creditDays.value }); editing.value = false; ElMessage.success('客户账期已保存'); await load() }
catch (error) { loadError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(loadError.value.message) }
ledgerError.value = null
try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.customerId, { customerName: selected.value.customerName, creditDays: creditDays.value }); ElMessage.success('客户账期已保存') }
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(ledgerError.value.message) }
finally { saving.value = false }
}
function money(value: unknown) { return Number(value || 0).toFixed(2) }
onMounted(() => void loadCustomers())
onMounted(() => void load())
</script>
<template>
<AdminPage as="div" class="finance-account-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通账单和客户账期查看权限" />
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else aria-label="客户账户">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="load" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable :data="rows" :columns="columns" :loading="loading" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="id" empty-text="请选择客户查询账户">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdate" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="客户账户加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left>币种:CNY · 账户由对账单与收款记录生成</template>
<template #pagination-left>币种:CNY · 账户数据由对账单、收付款和费用来源派生</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(680px, 94vw)" :confirm-loading="saving">
<template v-if="selected">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="累计应收">{{ money(selected.totalReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="累计已收">{{ money(selected.totalReceived) }} CNY</el-descriptions-item><el-descriptions-item label="未收">{{ money(selected.outstandingAmount) }} CNY</el-descriptions-item><el-descriptions-item label="账户模式">人工应收台账</el-descriptions-item></el-descriptions>
<FormSection title="账户流水" description="由对账单和收款记录生成。"><div v-for="entry in selected.entries" :key="`${entry.entryType}-${entry.resourceId}`" class="account-entry"><span>{{ entry.entryType === 'RECEIPT' ? '收款' : '应收' }} · {{ entry.resourceNo }}</span><span>{{ entry.occurredAt }}</span><b>{{ entry.entryType === 'RECEIPT' ? entry.creditAmount : entry.debitAmount }} CNY</b></div><el-empty v-if="!selected.entries.length" description="暂无账户流水" /></FormSection>
<FormSection title="账期设置" description="每个客户独立设置,默认 30 天;新账单按该账期计算到期日。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" :disabled="!editing" /><span class="term-unit">天</span></FormSection>
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(760px, 94vw)" :loading="ledgerLoading" :confirm-loading="saving">
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ entry.businessType }} · {{ entry.documentType }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdate" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-account-page { min-width: 0; }
.row-actions, .drawer-footer { display: flex; align-items: center; gap: 8px; }
.drawer-footer { justify-content: flex-end; }
.row-actions .el-button { margin-left: 0; }
.account-detail { margin-bottom: 16px; }
.term-unit { margin-left: 8px; color: var(--cc-muted); }
.account-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.account-entry span:nth-child(2) { color: var(--cc-muted); }
.account-entry b { white-space: nowrap; }
@media (max-width: 720px) { .drawer-footer .el-button { min-height: 44px; } :deep(.account-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { .drawer-footer .el-button { flex: 1 1 120px; } .account-entry { grid-template-columns: 1fr; } :deep(.account-detail .el-descriptions__cell) { display: block; width: 100%; } }
.finance-account-page{min-width:0}.row-actions,.drawer-footer{display:flex;align-items:center;gap:8px}.drawer-footer{justify-content:flex-end}.row-actions .el-button{margin-left:0}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}.ledger-entry{display:grid;grid-template-columns:minmax(160px,1fr) auto minmax(210px,auto);gap:8px 16px;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.ledger-entry>div:first-child,.ledger-money{display:grid;gap:3px}.ledger-entry span,.ledger-entry small{color:var(--cc-muted)}.ledger-entry small{grid-column:1/-1}.ledger-money{text-align:right}.ledger-money b{white-space:nowrap}@media(max-width:720px){.drawer-footer .el-button{min-height:44px}.ledger-entry{grid-template-columns:1fr 1fr}.ledger-money{grid-column:1/-1;text-align:left}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.drawer-footer .el-button{flex:1 1 120px}.ledger-entry{grid-template-columns:1fr}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -43,6 +43,9 @@ const page = ref(1)
const pageSize = ref(20)
const canView = computed(() => access.can('platform:finance:bill:view'))
const canViewSource = computed(() => access.can('platform:finance:fee-source:view'))
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })) },
@@ -93,18 +96,20 @@ onMounted(() => void loadCustomers())
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付">
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :disabled="!canViewSource" :title="canViewSource ? '费用来源详情接口尚未提供' : '无费用来源查看权限'" @click="openDetail(row)"><Eye :size="15" />摘要</el-button><el-button v-if="canReviewSource" link type="primary" disabled title="审核接口尚未提供,暂不可操作"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" disabled title="参与结算接口尚未提供,暂不可操作"><Link2 :size="15" />结算</el-button></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" title="应收应付详情" size="min(680px, 94vw)"><el-descriptions v-if="selected" :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="费用单据">{{ selected.documentNo }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ selected.sourceType }}</el-descriptions-item><el-descriptions-item label="来源追溯">{{ selected.sourceId }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ selected.feeName }}</el-descriptions-item><el-descriptions-item label="金额">{{ selected.amount }} CNY</el-descriptions-item><el-descriptions-item label="发生日期">{{ selected.occurredAt }}</el-descriptions-item></el-descriptions><template #footer><el-button @click="drawerOpen = false">关闭</el-button></template></AppDrawer>
<AppDrawer v-model="drawerOpen" title="费用来源摘要" size="min(680px, 94vw)"><el-descriptions v-if="selected" :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="费用单据">{{ selected.documentNo }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ selected.sourceType }}</el-descriptions-item><el-descriptions-item label="来源追溯">{{ selected.sourceId }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ selected.feeName }}</el-descriptions-item><el-descriptions-item label="金额">{{ selected.amount }} CNY</el-descriptions-item><el-descriptions-item label="发生日期">{{ selected.occurredAt }}</el-descriptions-item></el-descriptions><el-alert type="info" :closable="false" title="费用来源详情、审核和参与结算接口尚未提供,当前仅展示列表摘要。" /><template #footer><el-button @click="drawerOpen = false">关闭</el-button></template></AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-arap-page { min-width: 0; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.arap-detail { margin-bottom: 16px; }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
@@ -1,2 +1,26 @@
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
<template><FinanceManualPage mode="bills" /></template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getFinanceBill, listFinanceStatements } from '../api/finance'
import type { FinanceBillDetail, FinanceStatementReadModel } from '../types'
const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = reactive<QueryModel>({ customerId: '' }); const rows = ref<FinanceStatementReadModel[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const customerError = ref<ApiErrorDetails | null>(null); const drawerOpen = ref(false); const detail = ref<FinanceBillDetail | null>(null); const detailError = ref<ApiErrorDetails | null>(null)
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } }
async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #toolbar-actions><el-button type="primary" disabled title="接口尚未提供,暂不可操作"><Plus :size="16" />生成对账单</el-button></template><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #pagination-left>币种:CNY · 生成、出账、调整、作废待后端 G1-G4 契约冻结</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions><el-alert class="blocked-alert" type="info" :closable="false" title="对账单生成与状态操作接口尚未提供,当前仅支持真实详情查询。" /></template></AppDrawer></AdminPage>
</template>
<style scoped>.finance-statements-page{min-width:0}.blocked-alert{margin-top:16px}</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { financePhase1Capabilities, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = reactive<QueryModel>({ customerId: '' })
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const selected = ref<FinanceReceivableSettlement | FinancePayableSettlement | null>(null)
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const canView = computed(() => access.can(viewPermission.value))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const blocked = computed(() => props.kind === 'receivable' ? !financePhase1Capabilities.receivableSettlementCreate : !financePhase1Capabilities.payableSettlementCreate)
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }])
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.customerId)) : await listFinancePayableSettlements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } }
function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true }
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<ListPageLayout v-else :aria-label="title">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" /></template>
<template v-if="customerError || error" #state><LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
<section v-if="!customerError && !error" class="surface-panel table-panel">
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
<template #toolbar-actions><el-button type="primary" disabled :title="blocked ? '接口尚未提供,暂不可操作' : ''"><Plus :size="16" />生成{{ title }}</el-button></template>
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template>
<template #pagination-left>币种:CNY · 生成接口尚未提供时仅展示真实查询结果</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="`${title}详情`" size="min(680px, 94vw)">
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerId }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selected.status }}</el-descriptions-item></el-descriptions>
<el-alert class="blocked-alert" type="info" :closable="false" title="结算单详情/收付款接口尚未提供,当前不显示不可执行的提交按钮。" />
</AppDrawer>
</AdminPage>
</template>
<style scoped>.finance-settlement-page{min-width:0}.blocked-alert{margin-top:16px}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style>
@@ -1,2 +1,2 @@
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
<template><FinanceManualPage mode="costs" /></template>
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
<template><FinanceFormalSettlementPage kind="payable" /></template>
@@ -1,2 +1,2 @@
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
<template><FinanceManualPage mode="receipts" /></template>
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
<template><FinanceFormalSettlementPage kind="receivable" /></template>
+19 -49
View File
@@ -7,40 +7,6 @@ export type FinanceReconciliationStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED
export type FinanceSettlementStatus = 'ISSUED' | 'VOIDED'
export type FinancePaymentStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'
export interface FinancePageMeta { page: number; pageSize: number; total: number }
export interface FinanceAccountSummary {
customerId: string; customerCode: string; customerName: string; contactName?: string; contactPhone?: string
outstandingReceivable: number; pendingBilling: number; receivedInPeriod: number; outstandingPayable: number
status: 'ACTIVE' | 'DISABLED'; lastChangedAt: string; email?: string; creditDays?: number
}
export interface FinanceLedgerEntry {
ledgerId: string; occurredAt: string; businessType: string; documentType: string; documentId: string; documentNo?: string
receivableChange: number; payableChange: number; outstandingReceivableAfter: number; operatorName: string; remark?: string
}
export interface FinanceFeeSource {
feeSourceId: string; sourceType: string; sourceFeeId: string; sourceVersion: number; direction: FinanceFeeDirection
customerId: string; customerName?: string; supplierId?: string; supplierName?: string; inboundId?: string; inboundCode?: string
containerId?: string; containerNo?: string; businessDate: string; feeItemName: string; quantity?: number; unitPrice?: number; amount: number
currency: 'CNY'; reviewStatus: FinanceReviewStatus; settlementIncluded: boolean; sourceStatus: string; statementGenerated: boolean
}
export interface FinanceFeeSourceDetail extends FinanceFeeSource { reviewReason?: string; settlementReason?: string; sourceHash?: string; operationLogs?: FinanceOperationLog[] }
export interface FinanceOperationLog { occurredAt: string; action: string; operatorName: string; result: string; remark?: string }
export interface FinanceReconciliationSummary {
statementId: string; statementNo: string; customerId: string; customerName: string; periodStart: string; periodEnd: string; creditDays: number
currency: 'CNY'; totalReceivable: number; receivedAmount: number; outstandingAmount: number; lastReceiptAt?: string; settled: boolean
status: FinanceReconciliationStatus; version: number; createdAt: string; updatedAt: string
}
export interface FinanceReconciliationLine { statementLineId: string; feeSourceId: string; sourceVersion: number; sourceHash?: string; inboundId?: string; containerId?: string; feeItemName: string; sourceAmount: number; statementAmount: number; currency: 'CNY' }
export interface FinanceReconciliationDetail extends FinanceReconciliationSummary { remark?: string; lines: FinanceReconciliationLine[]; paymentHistory: FinanceSettlementTransaction[]; revisions: FinanceReconciliationRevision[] }
export interface FinanceReconciliationRevision { revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; occurredAt: string }
export interface FinanceSettlementSummary {
settlementId: string; settlementNo: string; direction: FinanceFeeDirection; customerId?: string; customerName?: string; supplierId?: string; supplierName?: string
reconciliationStatementId?: string; businessPeriod?: string; currency: 'CNY'; totalAmount: number; paidAmount: number; outstandingAmount: number
generatedDate: string; dueDate: string; paymentStatus: FinancePaymentStatus; status: FinanceSettlementStatus; version: number
}
export interface FinanceSettlementTransaction { transactionId: string; transactionType: 'RECEIPT' | 'PAYMENT'; transactionDate: string; amount: number; method: FinancePaymentMethod; referenceNo?: string; voucherFileIds?: string[]; createdBy: string; createdAt: string; remark?: string }
export interface FinanceSettlementDetail extends FinanceSettlementSummary { lines: FinanceFeeSource[]; transactions: FinanceSettlementTransaction[]; revisions: FinanceReconciliationRevision[] }
export interface FinanceVoucherResource {
fileId: string
customerId: string
@@ -143,26 +109,30 @@ export interface FinancePayableSettlement {
export interface FinanceAccountSummary {
customerId: string
customerCode: string
customerName: string
customerStatus: string
currency: 'CNY'
totalReceivable: number
receivedAmount: number
outstandingAmount: number
creditDays: number
contactName: string
contactPhone: string
outstandingReceivable: number
pendingBilling: number
receivedInPeriod: number
outstandingPayable: number
status: string
lastChangedAt: string
}
export interface FinanceLedgerEntry {
entryId: string
businessType: string
resourceId: string
resourceNo: string
ledgerId: string
occurredAt: string
debitAmount: number
creditAmount: number
balance: number
currency: 'CNY'
status: string
businessType: string
documentType: string
documentId: string
documentNo: string
receivableChange: number
payableChange: number
outstandingReceivableAfter: number
operatorName: string
remark: string
}
export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
+1
View File
@@ -1,3 +1,4 @@
export { oceanBookingRoutes } from './routes'
export { listOceanBookings } from './api/platformOceanBookings'
export * from './status'
export * from './types'
@@ -22,6 +22,11 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('renders the translated status once in the detail heading', () => {
expect(source).toContain('<StatusBadge :status="statusLabel(detail.status)" />')
expect(source).not.toContain('<StatusBadge :status="detail.status" />')
})
it('provides guidance for every free-text booking field', () => {
for (const placeholder of [
'请输入提单号(可选)', '请输入船名', '请输入航次', '例如:CNNGB', '例如:宁波港',
@@ -29,11 +29,8 @@ import type {
OceanBookingSaveRequest,
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
const statusLabels: Record<OceanBookingStatus, string> = {
DRAFT: '草稿', SUBMITTED: '已提交', CONFIRMED: '已确认', RELEASED: '已放舱',
FAILED: '订舱失败', CHANGE_PENDING: '变更中', CANCELLED: '已取消',
}
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
{ value: 'CONSIGNEE', label: '收货人' },
@@ -103,7 +100,7 @@ const changeDialogOpen = ref(false)
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置'
return oceanBookingStatusLabel(status)
}
async function loadOptions() {
optionsLoading.value = true
@@ -235,7 +232,7 @@ onMounted(() => { void load(); void loadOptions() })
<div class="detail-heading">
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '—'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="detail.status" /><span>{{ statusLabel(detail.status) }}</span></div></template>
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
</PageToolbar>
</div>
</template>
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }) })
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('renders backend booking statuses through the domain translation contract', () => { expect(source).toContain('options: oceanBookingStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
@@ -13,6 +13,7 @@ import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
import type { OceanBookingQuery, OceanBookingStatus } from '../types'
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from '../status'
const router = useRouter()
const access = useAccessPreviewStore()
@@ -23,13 +24,9 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const query = reactive<QueryModel>({ keyword: '', status: '' })
const statuses: Array<{ label: string; value: string }> = [
{ label: '草稿', value: 'DRAFT' }, { label: '已提交', value: 'SUBMITTED' }, { label: '已确认', value: 'CONFIRMED' },
{ label: '已放舱', value: 'RELEASED' }, { label: '失败', value: 'FAILED' }, { label: '变更中', value: 'CHANGE_PENDING' }, { label: '已取消', value: 'CANCELLED' },
]
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号/客户', width: 220 },
{ key: 'status', type: 'select', label: '状态', options: statuses },
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
]
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
@@ -54,7 +51,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" link type="primary" @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" link @click="open(row.id)"><Pencil :size="15" />编辑</el-button></template>
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
<template #status="{ row }"><StatusBadge :status="row.status" /></template>
<template #status="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.status)" /></template>
</DataTable>
</section>
</ListPageLayout>
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from './status'
describe('ocean booking status labels', () => {
it('translates every backend status for selectors and badges', () => {
expect(oceanBookingStatusOptions).toEqual([
{ value: 'DRAFT', label: '草稿' },
{ value: 'SUBMITTED', label: '已提交' },
{ value: 'CONFIRMED', label: '已确认' },
{ value: 'RELEASED', label: '已放舱' },
{ value: 'FAILED', label: '订舱失败' },
{ value: 'CHANGE_PENDING', label: '变更中' },
{ value: 'CANCELLED', label: '已取消' },
])
})
it('does not leak unknown backend enum values to users', () => {
expect(oceanBookingStatusLabel('NEW_BACKEND_STATUS')).toBe('未知状态')
expect(oceanBookingStatusLabel(null)).toBe('未设置')
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { OceanBookingStatus } from './types'
export const oceanBookingStatusLabels: Record<OceanBookingStatus, string> = {
DRAFT: '草稿',
SUBMITTED: '已提交',
CONFIRMED: '已确认',
RELEASED: '已放舱',
FAILED: '订舱失败',
CHANGE_PENDING: '变更中',
CANCELLED: '已取消',
}
export const oceanBookingStatusOptions = Object.entries(oceanBookingStatusLabels).map(([value, label]) => ({
value: value as OceanBookingStatus,
label,
}))
export function oceanBookingStatusLabel(status: string | null | undefined, emptyLabel = '未设置') {
if (!status) return emptyLabel
return oceanBookingStatusLabels[status as OceanBookingStatus] || '未知状态'
}
@@ -22,7 +22,7 @@ const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户',
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
const container = { id: '40001', bookingNo: 'BK-001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', containerNo: 'MSCU-001', dispatchStatus: 'WAITING', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', containerNo: 'MSCU-001', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
enableAutoUnmount(afterEach)
@@ -65,6 +65,11 @@ describe('OutboundDispatchesView', () => {
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
expect(wrapper.text()).toContain('ORD-001')
expect(wrapper.text()).toContain('已放舱')
expect(wrapper.text()).toContain('已完成')
expect(wrapper.text()).toContain('待复核')
expect(wrapper.text()).toContain('待确认出仓')
expect(wrapper.text()).not.toContain('READY_FOR_DISPATCH')
})
it('does not issue a search when reset returns to the initial state', async () => {
@@ -132,6 +137,11 @@ describe('OutboundDispatchesView', () => {
expect(source).toContain('detail.timeline')
expect(source).toContain("'confirm-dispatch'")
expect(source).toContain("'reverse-dispatch'")
expect(source).toContain("row.dispatchStatus === 'READY_FOR_DISPATCH'")
expect(source).toContain("row.dispatchStatus === 'WAITING_FOR_LOADING'")
expect(source).toContain('timelineModuleLabel(entry.module)')
expect(source).toContain('timelineActionLabel(entry.action)')
expect(source).toContain('timelineStatusLabel(entry.module, entry.fromStatus)')
expect(source).toContain("error.value?.code === 'A0409'")
expect(getOutboundDispatchDetail).not.toHaveBeenCalled()
})
@@ -17,6 +17,7 @@ import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { changeOutboundDispatch, getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource } from '../types'
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from '../statusLabels'
type DispatchFilters = QueryModel & { customerId: string; orderId: string; containerBookingId: string }
@@ -188,15 +189,15 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
<section v-if="!error" class="surface-panel table-panel">
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus !== 'DISPATCHED'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'DISPATCHED'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
<template #booking="{ row }"><StatusBadge :status="row.oceanBookingStatus || '未关联'" /></template><template #truck="{ row }"><StatusBadge :status="row.truckAppointmentStatus || '未关联'" /></template><template #pick="{ row }"><StatusBadge :status="row.pickTaskStatus || '未关联'" /></template><template #status="{ row }"><StatusBadge :status="row.dispatchStatus || '待处理'" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(row.dispatchStatus)" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :show-confirm="false">
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="detail.aggregate.dispatchStatus || '待处理'" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
</AppDialog>
</AdminPage>
</template>
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from './statusLabels'
describe('outbound dispatch status labels', () => {
it('maps list statuses to business Chinese', () => {
expect(outboundDispatchStatusLabel('READY_FOR_DISPATCH')).toBe('待确认出仓')
expect(outboundDispatchStatusLabel('WAITING_FOR_LOADING')).toBe('等待装柜')
expect(oceanBookingStatusLabel('RELEASED')).toBe('已放舱')
expect(truckStatusLabel('PICKUP_IN_PROGRESS')).toBe('提柜中')
expect(pickTaskStatusLabel('PENDING')).toBe('待拣货')
expect(pickTaskStatusLabel('IN_PROGRESS')).toBe('拣货中')
expect(pickTaskStatusLabel('AWAITING_REVIEW')).toBe('待复核')
expect(pickTaskStatusLabel('COMPLETED')).toBe('已完成')
})
it('localizes timeline modules, actions and module-specific states', () => {
expect(timelineModuleLabel('OUTBOUND_DISPATCH')).toBe('国内出仓')
expect(timelineActionLabel('CONFIRM_DISPATCH')).toBe('确认出仓')
expect(timelineStatusLabel('OUTBOUND_DISPATCH', 'READY_FOR_DISPATCH')).toBe('待确认出仓')
expect(timelineStatusLabel('OCEAN_BOOKING', 'RELEASED')).toBe('已放舱')
expect(timelineStatusLabel('TRUCK_APPOINTMENT', 'COMPLETED')).toBe('已完成')
expect(timelineStatusLabel('INBOUND', 'INBOUNDED')).toBe('已入库')
})
it('does not expose unknown backend enum values', () => {
expect(outboundDispatchStatusLabel('NEW_BACKEND_STATE')).toBe('未知状态')
expect(timelineModuleLabel('NEW_MODULE')).toBe('其他业务流程')
expect(timelineActionLabel('NEW_ACTION')).toBe('状态更新')
expect(timelineStatusLabel('NEW_MODULE', 'NEW_STATE')).toBe('未知状态')
})
})
@@ -0,0 +1,111 @@
import { truckAppointmentStatusLabels } from '@/modules/trucking'
import { oceanBookingStatusLabel as sharedOceanBookingStatusLabel } from '@/modules/ocean-bookings'
const dispatchStatusLabels: Record<string, string> = {
READY_FOR_DISPATCH: '待确认出仓',
WAITING_FOR_LOADING: '等待装柜',
}
const pickTaskStatusLabels: Record<string, string> = {
PENDING: '待拣货',
IN_PROGRESS: '拣货中',
AWAITING_REVIEW: '待复核',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
const inboundStatusLabels: Record<string, string> = {
WAIT_MEASURE: '待测量',
MEASURING: '测量中',
MEASURE_PENDING: '待测量审核',
MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费',
FEE_CONFIRMING: '费用处理中',
INBOUNDING: '入库处理中',
INBOUNDED: '已入库',
VOIDED: '已作废',
}
const loadingPlanStatusLabels: Record<string, string> = {
WAIT_LOADING: '等待装柜',
EDITING: '编辑中',
PRELOADED: '已预装',
CONFIRMED: '已确认',
LOADING: '装柜中',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
const moduleLabels: Record<string, string> = {
INBOUND: '入库作业',
LOADING_PLAN: '装柜计划',
PDA_PICK: 'PDA 拣货',
LOADING_EVIDENCE: '装柜证据',
OCEAN_BOOKING: '海运订舱',
TRUCK_APPOINTMENT: '拖车预约',
OUTBOUND_DISPATCH: '国内出仓',
}
const actionLabels: Record<string, string> = {
CREATE: '创建',
UPDATE: '更新',
SUBMIT: '提交',
APPROVE: '审核通过',
REJECT: '审核驳回',
CONFIRM: '确认',
RELEASE: '放舱',
FAIL: '标记失败',
CANCEL: '取消',
CHANGE_REQUEST: '申请改船',
REQUEST_CHANGE: '申请改船',
CONFIRM_CHANGE: '确认改船',
FAIL_CHANGE: '驳回改船',
CONFIRM_DISPATCH: '确认出仓',
REVERSE_DISPATCH: '撤销出仓',
UPSERT_EVIDENCE: '登记装柜证据',
VOID_EVIDENCE: '作废装柜证据',
START: '开始作业',
COMPLETE: '完成作业',
}
function mapped(value: string | null | undefined, labels: Record<string, string>, empty: string) {
if (!value) return empty
return labels[value] || '未知状态'
}
export function outboundDispatchStatusLabel(value: string | null | undefined) {
return mapped(value, dispatchStatusLabels, '待确认出仓')
}
export function oceanBookingStatusLabel(value: string | null | undefined) {
return sharedOceanBookingStatusLabel(value, '未关联')
}
export function truckStatusLabel(value: string | null | undefined) {
return mapped(value, truckAppointmentStatusLabels as Record<string, string>, '未关联')
}
export function pickTaskStatusLabel(value: string | null | undefined) {
return mapped(value, pickTaskStatusLabels, '未关联')
}
export function timelineModuleLabel(value: string | null | undefined) {
if (!value) return '业务流程'
return moduleLabels[value] || '其他业务流程'
}
export function timelineActionLabel(value: string | null | undefined) {
if (!value) return '状态更新'
return actionLabels[value] || '状态更新'
}
export function timelineStatusLabel(module: string | null | undefined, value: string | null | undefined) {
if (!value) return '—'
if (module === 'OUTBOUND_DISPATCH') return outboundDispatchStatusLabel(value)
if (module === 'OCEAN_BOOKING') return oceanBookingStatusLabel(value)
if (module === 'TRUCK_APPOINTMENT') return truckStatusLabel(value)
if (module === 'PDA_PICK') return pickTaskStatusLabel(value)
if (module === 'INBOUND') return mapped(value, inboundStatusLabels, '—')
if (module === 'LOADING_PLAN') return mapped(value, loadingPlanStatusLabels, '—')
return '未知状态'
}
@@ -13,7 +13,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0 })
const history = ref<TruckAppointmentStatusHistory[]>([])
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && !['COMPLETED','CANCELLED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
@@ -43,7 +43,7 @@ onMounted(load)
<template v-else-if="resource">
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
</template>
</DetailPageLayout>
@@ -12,7 +12,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listTruckAppointments } from '../api/platformTrucking'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
@@ -46,9 +46,7 @@ function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointme
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
}
function statusLabel(value: unknown) {
return typeof value === 'string' && value in truckAppointmentStatusLabels
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
: String(value || '—')
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
}
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { truckAppointmentActionLabel, truckAppointmentStatusLabel } from './types'
describe('truck appointment status labels', () => {
it('translates status history without exposing backend enum values', () => {
expect(truckAppointmentStatusLabel('DISPATCHED')).toBe('已派车')
expect(truckAppointmentStatusLabel('NEW_STATUS')).toBe('未知状态')
expect(truckAppointmentActionLabel('DISPATCH')).toBe('确认派车')
expect(truckAppointmentActionLabel('NEW_ACTION')).toBe('状态更新')
})
})
+24
View File
@@ -28,6 +28,30 @@ export const truckAppointmentStatusLabels: Record<TruckAppointmentStatus, string
CANCELLED: '已取消',
}
export function truckAppointmentStatusLabel(status: string | null | undefined, emptyLabel = '—') {
if (!status) return emptyLabel
return truckAppointmentStatusLabels[status as TruckAppointmentStatus] || '未知状态'
}
export const truckAppointmentActionLabels: Record<string, string> = {
CREATE: '创建预约',
SUBMIT: '提交派车',
DISPATCH: '确认派车',
PICKUP: '开始提柜',
ARRIVE: '到达仓库',
LOAD: '开始装柜',
DEPART: '离开仓库',
COMPLETE: '完成预约',
RESCHEDULE: '预约改期',
EXCEPTION: '标记异常',
CANCEL: '取消预约',
}
export function truckAppointmentActionLabel(action: string | null | undefined) {
if (!action) return '状态更新'
return truckAppointmentActionLabels[action] || '状态更新'
}
export interface TruckAttachmentReference {
id?: string
fileId: string
@@ -106,6 +106,11 @@ function formatNumber(value: number, precision = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: precision }) : '—'
}
function planStatusLabel(status?: string | null) {
return ({ EDITING: '预装柜中', LOADING: '拣货装柜中', COMPLETED: '装柜完成' } as Record<string, string>)[status || '']
|| (status ? '未知状态' : '尚未创建')
}
function routeMatches(candidate: SmartLoadingCandidate) {
const target = container.value?.destinationWarehouseCode
return !target || candidate.destinationWarehouseCode === target
@@ -291,7 +296,7 @@ onMounted(() => { void load() })
</el-alert>
<div v-if="container" class="smart-loading__summary">
<div><span>计划状态</span><strong>{{ plan?.status || '尚未创建' }}</strong></div>
<div><span>计划状态</span><strong>{{ planStatusLabel(plan?.status) }}</strong></div>
<div><span>已装箱数</span><strong>{{ totalPackages }}</strong></div>
<div><span>容积</span><strong>{{ formatNumber(usedVolume) }} / {{ formatNumber(container.capacityCbm || 0) }} m³</strong><small>{{ volumeRate == null ? '容量待配置' : `${volumeRate}%` }}</small></div>
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
@@ -7,6 +7,7 @@ import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
getEvidence,
getOutboundDispatch,
getPicking,
getPreload,
smartSplit,
voidEvidence,
@@ -18,6 +19,7 @@ vi.mock('../api', async (importOriginal) => {
...original,
getEvidence: vi.fn(),
getOutboundDispatch: vi.fn(),
getPicking: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
voidEvidence: vi.fn(),
@@ -50,6 +52,7 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
vi.mocked(getPicking).mockReset().mockResolvedValue({ id: '51', inboundId: '2086085512165634051', status: 'PICKING', version: 2, expectedCount: 10, pickedCount: 4, remark: null })
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
vi.mocked(voidEvidence).mockReset()
@@ -156,4 +159,37 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
)
prompt.mockRestore()
})
it('renders booking, trucking and picking backend statuses as business Chinese', async () => {
vi.mocked(getOutboundDispatch).mockResolvedValue({
orderId: '1', orderNo: 'ORD-1', inboundId: '2', inboundCode: 'IN-2', containerBookingId: '3', containerNo: 'CONT-3',
loadingPlanId: '4', loadingPlanStatus: 'LOADING', oceanBookingId: '5', oceanBookingStatus: 'RELEASED',
truckAppointmentId: '6', truckAppointmentStatus: 'PICKUP_IN_PROGRESS', gate: { passed: false, items: [] },
})
const wrapper = mountPanel(['platform:firstleg:inbound:picking:view'], 'LOADING')
await flushPromises()
expect(wrapper.text()).toContain('已放舱')
expect(wrapper.text()).toContain('提柜中')
expect(wrapper.text()).toContain('拣货中')
expect(wrapper.text()).not.toContain('RELEASED')
expect(wrapper.text()).not.toContain('PICKUP_IN_PROGRESS')
expect(wrapper.text()).not.toContain('PICKING')
})
it('does not expose unknown backend enum values', async () => {
vi.mocked(getOutboundDispatch).mockResolvedValue({
orderId: '1', orderNo: 'ORD-1', inboundId: '2', inboundCode: 'IN-2', containerBookingId: null, containerNo: null,
loadingPlanId: null, loadingPlanStatus: null, oceanBookingId: '5', oceanBookingStatus: 'NEW_BOOKING_STATE',
truckAppointmentId: '6', truckAppointmentStatus: 'NEW_TRUCK_STATE', gate: { passed: false, items: [] },
})
vi.mocked(getPicking).mockResolvedValue({ id: '51', inboundId: '2', status: 'NEW_PICK_STATE', version: 2, expectedCount: null, pickedCount: null, remark: null })
const wrapper = mountPanel(['platform:firstleg:inbound:picking:view'], 'LOADING')
await flushPromises()
expect(wrapper.text()).toContain('未知状态')
expect(wrapper.text()).not.toContain('NEW_BOOKING_STATE')
expect(wrapper.text()).not.toContain('NEW_TRUCK_STATE')
expect(wrapper.text()).not.toContain('NEW_PICK_STATE')
})
})
@@ -9,6 +9,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import SmartLoadingWorkbench from '@/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'
import { oceanBookingStatusLabel } from '@/modules/ocean-bookings'
import { truckAppointmentStatusLabel } from '@/modules/trucking'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
import {
completeLoading,
@@ -146,7 +148,19 @@ function removeItem(index: number) {
}
function preloadStatusLabel(status?: string | null) {
return ({ PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[status || ''] || '尚未生成'
return workflowStatusLabel(status, { PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' }, '尚未生成')
}
function workflowStatusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel: string) {
if (!value) return emptyLabel
return labels[value] || '未知状态'
}
function pickingStatusLabel(status?: string | null) {
return workflowStatusLabel(status, {
PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', AWAITING_REVIEW: '待复核',
COMPLETED: '已完成', CANCELLED: '已取消',
}, '暂无数据')
}
function formatVolume(value: number | null) {
@@ -423,8 +437,8 @@ onMounted(() => { void refresh() })
<el-card v-if="dispatch" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>子流程与门禁摘要</span><el-tag :type="dispatch.gate?.passed ? 'success' : 'warning'">{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}</el-tag></div></template>
<div class="gate-grid">
<div><span>订舱</span><strong>{{ dispatch.oceanBookingStatus || '暂无数据' }}</strong></div>
<div><span>拖车</span><strong>{{ dispatch.truckAppointmentStatus || '暂无数据' }}</strong></div>
<div><span>订舱</span><strong>{{ oceanBookingStatusLabel(dispatch.oceanBookingStatus, '未关联') }}</strong></div>
<div><span>拖车</span><strong>{{ truckAppointmentStatusLabel(dispatch.truckAppointmentStatus, '未关联') }}</strong></div>
<div><span>柜号</span><strong>{{ dispatch.containerNo || '暂无数据' }}</strong></div>
<div v-for="item in dispatch.gate?.items || []" :key="item.code"><span>{{ gateItemName(item.code, item.name) }}</span><el-tag size="small" :type="item.passed ? 'success' : 'danger'">{{ item.passed ? '已满足' : '待补齐' }}</el-tag></div>
</div>
@@ -473,7 +487,7 @@ onMounted(() => { void refresh() })
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
<el-descriptions v-if="picking" :column="2" border>
<el-descriptions-item label="拣货状态">{{ picking.status || '—' }}</el-descriptions-item>
<el-descriptions-item label="拣货状态">{{ pickingStatusLabel(picking.status) }}</el-descriptions-item>
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
+4 -4
View File
@@ -4,10 +4,10 @@ import { computed } from 'vue'
const props = defineProps<{ status: string }>()
const tone = computed(() => {
if (['正常', '启用', '已完成', '已审核', '审核通过', '已入库', '装柜完成', '派送完成'].includes(props.status)) return 'success'
if (['待审核', '草稿', '待处理', '未提交', '未录入', '待测量数据', '待分柜'].includes(props.status)) return 'warning'
if (['已受理', '已订舱', '运输中', '处理中', '装柜中'].includes(props.status)) return 'info'
if (['停用', '已取消', '已驳回', '审核驳回', '失败'].includes(props.status)) return 'danger'
if (['正常', '启用', '已完成', '已审核', '审核通过', '已入库', '装柜完成', '派送完成', '已确认', '已放舱'].includes(props.status)) return 'success'
if (['待审核', '草稿', '待处理', '未提交', '未录入', '待测量数据', '待分柜', '变更中'].includes(props.status)) return 'warning'
if (['已受理', '已订舱', '已提交', '运输中', '处理中', '装柜中'].includes(props.status)) return 'info'
if (['停用', '已取消', '已驳回', '审核驳回', '失败', '订舱失败'].includes(props.status)) return 'danger'
return 'neutral'
})
</script>