优化
This commit is contained in:
@@ -19,6 +19,8 @@ import { customerRoutes } from '@/modules/customers/routes'
|
||||
import { dashboardRoutes } from '@/modules/dashboard/routes'
|
||||
import { warehouseRoutes } from '@/modules/warehouses/routes'
|
||||
import { prototypeModuleRoutes } from '@/prototype/routes'
|
||||
import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes'
|
||||
import { truckingRoutes } from '@/modules/trucking/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -42,6 +44,8 @@ const router = createRouter({
|
||||
},
|
||||
...dashboardRoutes,
|
||||
...prototypeModuleRoutes,
|
||||
...oceanBookingRoutes,
|
||||
...truckingRoutes,
|
||||
...customerRoutes,
|
||||
...warehouseRoutes,
|
||||
...unifiedOrderRoutes,
|
||||
|
||||
@@ -44,7 +44,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
iconColor: '#52c41a',
|
||||
children: [
|
||||
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
|
||||
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/prototype/customer-management/warehouse-tier-fee', hidden: true },
|
||||
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/customers/overseas-storage-fees', permission: 'platform:overseas-storage-fee:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -76,10 +76,9 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
key: 'service-center',
|
||||
title: '服务中心',
|
||||
icon: ClipboardList,
|
||||
hidden: true,
|
||||
children: [
|
||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/prototype/service-center/booking-list' },
|
||||
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/prototype/service-center/truck-list' },
|
||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
||||
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||
import { Pencil, RefreshCw } 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'
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:customer:view'))
|
||||
const canEdit = computed(() => access.can('platform:customer:edit'))
|
||||
const config = ref<OverseasStorageFeeConfig | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const visible = ref(false)
|
||||
const dirty = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<{ currency: string; tiers: OverseasStorageFeeTier[]; version: number }>({ currency: 'USD', tiers: [], version: 0 })
|
||||
const currencies = ['USD', 'CNY', 'HKD', 'EUR', 'GBP']
|
||||
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
|
||||
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `customer-overseas-storage-fee-${props.customerId}`, label: computed(() => `${props.customerName}的海外仓费用`), isOpen: visible, model: form, requiredPermission: computed(() => 'platform:customer:edit') })
|
||||
watch(form, () => { if (visible.value) dirty.value = true }, { deep: true })
|
||||
watch(() => props.customerId, () => { visible.value = false; void load() })
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; error.value = null
|
||||
try { config.value = await getCustomerOverseasStorageFees(props.customerId) }
|
||||
catch (cause) { config.value = null; error.value = getApiErrorDetails(cause, '海外仓费用加载失败,请稍后重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function cloneTiers(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
|
||||
async function openEditor() {
|
||||
if (!config.value) return
|
||||
form.currency = config.value.currency || 'USD'; form.tiers = cloneTiers(config.value.tiers); form.version = config.value.version
|
||||
await nextTick(); dirty.value = false; visible.value = true; await nextTick(); captureBaseline(); formRef.value?.clearValidate()
|
||||
}
|
||||
async function closeEditor() {
|
||||
if (saving.value) return
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
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
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
async function save() {
|
||||
if (!config.value || !validate()) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateCustomerOverseasStorageFees(props.customerId, { customerId: props.customerId, customerName: props.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) })
|
||||
await load(); dirty.value = false; visible.value = false; ElMessage.success('海外仓费用已保存')
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '海外仓费用保存失败,请刷新后重试').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function dayLabel(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `第${tier.startDay}天以上` : `第${tier.startDay}-${tier.endDay}天` }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overseas-fee-panel">
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor"><Pencil :size="16" />编辑费用</el-button></header>
|
||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load"><RefreshCw :size="16" />重新加载</el-button></div>
|
||||
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
||||
<template v-else-if="config">
|
||||
<div class="fee-meta"><span>币种:<strong>{{ config.currency }}</strong></span><span>按整组保存,版本 {{ config.version }}</span></div>
|
||||
<div class="fee-groups"><div><h3>仓租({{ config.currency }}/CBM/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div><div><h3>退货仓储费({{ config.currency }}/托/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'RETURN_STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div></div>
|
||||
</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>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups :deep(.el-input-number) { width:130px; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups { overflow:auto; }.edit-groups > div { min-width:620px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees } from './overseasStorageFee'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), put: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
|
||||
|
||||
describe('customer overseas storage fee api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps customer ids as strings and uses the customer detail path', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: config })
|
||||
const result = await getCustomerOverseasStorageFees(config.customerId)
|
||||
expect(http.get).toHaveBeenCalledWith(`/platform/v1/iam/tenants/${config.customerId}/overseas-storage-fees`)
|
||||
expect(result.id).toBe('12')
|
||||
expect(result.customerId).toBe(config.customerId)
|
||||
})
|
||||
|
||||
it('submits the complete group and reloads the new version', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({ data: null })
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { ...config, version: 1 } })
|
||||
const request = { customerId: config.customerId, customerName: '客户', currency: 'CNY', version: 0, tiers: [{ feeType: 'STORAGE' as const, billingUnit: 'CBM' as const, startDay: 1, endDay: null, unitPrice: 0 }] }
|
||||
const result = await updateCustomerOverseasStorageFees(config.customerId, request)
|
||||
expect(http.put).toHaveBeenCalledWith(`/platform/v1/iam/tenants/${config.customerId}/overseas-storage-fees`, request)
|
||||
expect(result.version).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type OverseasFeeType = 'STORAGE' | 'RETURN_STORAGE'
|
||||
export type OverseasBillingUnit = 'CBM' | 'PALLET'
|
||||
|
||||
export interface OverseasStorageFeeTier {
|
||||
id: string
|
||||
startDay: number
|
||||
endDay: number | null
|
||||
unitPrice: number
|
||||
sortNo: number
|
||||
feeType: OverseasFeeType
|
||||
billingUnit: OverseasBillingUnit
|
||||
}
|
||||
|
||||
export interface OverseasStorageFeeConfig {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
currency: string
|
||||
tiers: OverseasStorageFeeTier[]
|
||||
version: number
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface OverseasStorageFeeUpdateRequest {
|
||||
customerId: string
|
||||
customerName: string
|
||||
currency: string
|
||||
tiers: Array<Pick<OverseasStorageFeeTier, 'feeType' | 'billingUnit' | 'startDay' | 'endDay' | 'unitPrice'>>
|
||||
version: number
|
||||
}
|
||||
|
||||
const path = (tenantId: string) => `/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`
|
||||
|
||||
type RawConfig = Omit<OverseasStorageFeeConfig, 'id' | 'customerId' | 'version' | 'tiers'> & {
|
||||
id: string | number
|
||||
customerId: string | number
|
||||
version: string | number
|
||||
tiers: Array<Omit<OverseasStorageFeeTier, 'id'> & { id: string | number; unitPrice: number | string }>
|
||||
}
|
||||
|
||||
function normalize(data: RawConfig): OverseasStorageFeeConfig {
|
||||
return {
|
||||
...data,
|
||||
id: String(data.id), customerId: String(data.customerId), version: Number(data.version),
|
||||
tiers: data.tiers.map((tier) => ({ ...tier, id: String(tier.id), unitPrice: Number(tier.unitPrice), feeType: tier.feeType as OverseasFeeType, billingUnit: tier.billingUnit as OverseasBillingUnit })),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomerOverseasStorageFees(tenantId: string) {
|
||||
const response = await http.get<never, ApiEnvelope<RawConfig>>(path(tenantId))
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
|
||||
await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
|
||||
return getCustomerOverseasStorageFees(tenantId)
|
||||
}
|
||||
|
||||
export async function listOverseasStorageFees(page = 1, size = 20, customerName?: string) {
|
||||
const response = await http.get<never, ApiEnvelope<{ records: RawConfig[]; total: number; page: number; size: number }>>('/platform/v1/overseas-storage-fees', { params: { page, size, customerName } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, records: result.records.map(normalize) }
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
||||
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
|
||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||
@@ -423,7 +424,7 @@ watch(orderPage, (page, previousPage) => {
|
||||
|
||||
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
|
||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
|
||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /></section>
|
||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Edit, RefreshCw, Search } from '@lucide/vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
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'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const rows = ref<OverseasStorageFeeConfig[]>([])
|
||||
const page = ref(1)
|
||||
const size = ref(20)
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const dialogOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editing = ref<OverseasStorageFeeConfig | null>(null)
|
||||
const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorageFeeTier[] })
|
||||
|
||||
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
|
||||
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
|
||||
|
||||
function clone(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
|
||||
function label(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `${tier.startDay}天以上` : `${tier.startDay}-${tier.endDay}天` }
|
||||
function summary(row: OverseasStorageFeeConfig, feeType: OverseasStorageFeeTier['feeType']) { return row.tiers.filter((tier) => tier.feeType === feeType).map((tier) => `${label(tier)} ${tier.unitPrice}`).join(' / ') }
|
||||
async function load() {
|
||||
loading.value = true; error.value = null
|
||||
try { const result = await listOverseasStorageFees(page.value, size.value, keyword.value.trim() || undefined); rows.value = result.records; total.value = Number(result.total) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search() { 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 }
|
||||
async function save() {
|
||||
if (!editing.value || storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('费用配置必须包含仓租5档和退货仓储费4档'); 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) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page overseas-fees-page">
|
||||
<section class="page-header"><div><h1>海外仓阶梯费用</h1><p>维护客户仓租和退货仓储费,初始化后可调整天数、价格和币种。</p></div><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></section>
|
||||
<section v-if="error" class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section>
|
||||
<section v-else class="surface-panel">
|
||||
<div class="toolbar"><el-input v-model="keyword" clearable placeholder="搜索客户名称" @keyup.enter="search"><template #prefix><Search :size="16" /></template></el-input><el-button type="primary" @click="search">查询</el-button></div>
|
||||
<el-table :data="rows" v-loading="loading" row-key="id" empty-text="暂无海外仓阶梯费用配置">
|
||||
<el-table-column label="操作" width="100" fixed="left"><template #default="{ row }"><el-button v-if="access.can('platform:customer:edit')" link type="primary" @click="openEdit(row)"><Edit :size="15" />编辑</el-button></template></el-table-column>
|
||||
<el-table-column prop="customerName" label="客户" min-width="180" /><el-table-column prop="customerId" label="客户ID" min-width="180" />
|
||||
<el-table-column prop="currency" label="币种" width="90" /><el-table-column label="仓租" min-width="260"><template #default="{ row }">{{ summary(row as OverseasStorageFeeConfig, 'STORAGE') }}</template></el-table-column>
|
||||
<el-table-column label="退货仓储费" min-width="260"><template #default="{ row }">{{ summary(row as OverseasStorageFeeConfig, 'RETURN_STORAGE') }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" /></div>
|
||||
</section>
|
||||
<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>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header,.toolbar,.pagination { display:flex; align-items:center; justify-content:space-between; gap:12px; }.page-header { margin-bottom:16px; }.page-header h1 { margin:0; font-size:22px; }.page-header p { margin:6px 0 0; color:var(--cc-muted); font-size:13px; }.toolbar { justify-content:flex-start; padding:16px; border-bottom:1px solid var(--cc-border); }.toolbar :deep(.el-input) { width:min(320px,100%); }.pagination { justify-content:flex-end; padding:16px; }.error-state { display:flex; justify-content:space-between; padding:20px; color:#b42318; }.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }.groups h3 { margin:0 0 10px; font-size:15px; } @media (max-width:720px) { .groups { grid-template-columns:1fr; }.page-header { align-items:flex-start; flex-direction:column; } }
|
||||
</style>
|
||||
@@ -7,6 +7,12 @@ export const customerRoutes = [
|
||||
component: () => import('@/modules/customers/pages/CustomersView.vue'),
|
||||
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
|
||||
},
|
||||
{
|
||||
path: 'customers/overseas-storage-fees',
|
||||
name: 'overseas-storage-fees',
|
||||
component: () => import('@/modules/customers/pages/OverseasStorageFeesView.vue'),
|
||||
meta: { title: '海外仓阶梯费用', description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' },
|
||||
},
|
||||
{
|
||||
path: 'customers/:tenantId',
|
||||
name: 'customer-detail',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { listOceanBookings } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
describe('ocean bookings api', () => { it('normalizes pagination and long ids', async () => { vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } }); const result = await listOceanBookings({ page: 1, size: 10 }); expect(result.page).toBe(2); expect(result.total).toBe(21); expect(result.records[0]?.id).toBe('123'); expect(result.records[0]?.customerId).toBe('456') }) })
|
||||
@@ -0,0 +1,18 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/ocean-bookings'
|
||||
function pageNumber(value: number | string, fallback: number) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback }
|
||||
function booking(raw: OceanBookingResource): OceanBookingResource { return { ...raw, id: String(raw.id), customerId: raw.customerId == null ? null : String(raw.customerId), containers: (raw.containers || []).map((item) => ({ ...item, id: String(item.id) })) } }
|
||||
export async function listOceanBookings(query: OceanBookingQuery) {
|
||||
const response = await http.get<ApiEnvelope<OceanBookingPage>, ApiEnvelope<OceanBookingPage>>(basePath, { params: query })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
|
||||
}
|
||||
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); const raw = unwrapApiResponse(response); return { ...raw, ...booking(raw), statusHistory: (raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) })) } }
|
||||
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<string>, ApiEnvelope<string>>(basePath, payload); return unwrapApiResponse(response) }
|
||||
export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}`, payload); return unwrapApiResponse(response) }
|
||||
export type OceanBookingAction = 'submit' | 'confirm' | 'release' | 'fail' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
|
||||
export async function performOceanBookingAction(id: string, action: OceanBookingAction, reason?: string) { const response = await http.post<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}/${action}`, reason ? { reason } : undefined); return unwrapApiResponse(response) }
|
||||
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
|
||||
export type { OceanBookingResource, OceanBookingStatus }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { oceanBookingRoutes } from './routes'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { performOceanBookingAction, createOceanBooking, getOceanBooking, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const id = computed(() => String(route.params.bookingId || '')); const detail = ref<OceanBookingDetail | null>(null); const loading = ref(false); const saving = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const form = reactive<OceanBookingSaveRequest>({ containers: [{ containerType: '40HC', quantity: 1 }], currency: 'USD' })
|
||||
const actionStatuses: OceanBookingStatus[] = ['SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
|
||||
const currentStatus = computed(() => detail.value?.status)
|
||||
async function load(){ if(!id.value || id.value==='new') return; loading.value=true; try { detail.value=await getOceanBooking(id.value); Object.assign(form, detail.value) } catch(e){error.value=getApiErrorDetails(e)} finally{loading.value=false} }
|
||||
async function save(){ saving.value=true; actionError.value=null; try { if(id.value==='new') { const created=await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else await updateOceanBooking(id.value, form); await load() } catch(e){actionError.value=getApiErrorDetails(e)} finally{saving.value=false} }
|
||||
const actionByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction>> = { SUBMITTED: 'submit', CONFIRMED: 'confirm', RELEASED: 'release', FAILED: 'fail', CHANGE_PENDING: 'change-request', CANCELLED: 'cancel' }
|
||||
async function action(status: OceanBookingStatus){ const actionName = actionByStatus[status]; if (!actionName) return; actionError.value=null; try { await performOceanBookingAction(id.value, actionName); await load() } catch(e){actionError.value=getApiErrorDetails(e)} }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template><div class="business-page booking-detail"><div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="currentStatus || 'DRAFT'" /></div></div><LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" /><template v-else><el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}<span v-if="actionError.requestId">(requestId: {{ actionError.requestId }})</span></el-alert><section class="surface-panel form-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus" size="small" @click="action(nextStatus)">{{ nextStatus }}</el-button></template><el-button type="primary" :loading="saving" @click="save">保存</el-button></div></div><el-form :model="form" label-position="top" class="form-grid"><el-form-item label="客户"><el-input v-model="form.customerId" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.orderNo" /></el-form-item><el-form-item label="船公司"><el-input v-model="form.carrierName" /></el-form-item><el-form-item label="订舱代理"><el-input v-model="form.bookingAgent" /></el-form-item><el-form-item label="船名"><el-input v-model="form.vesselName" /></el-form-item><el-form-item label="航次"><el-input v-model="form.voyageNo" /></el-form-item><el-form-item label="起运港 POL"><el-input v-model="form.pol" /></el-form-item><el-form-item label="目的港 POD"><el-input v-model="form.pod" /></el-form-item><el-form-item label="MB/L 货人"><el-input v-model="form.mblShipper" /></el-form-item><el-form-item label="MB/L 收人"><el-input v-model="form.mblConsignee" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.soNo" /></el-form-item><el-form-item label="截关时间"><el-input v-model="form.cutoffAt" /></el-form-item><el-form-item label="预计开船"><el-input v-model="form.etd" /></el-form-item><el-form-item label="预计到港"><el-input v-model="form.eta" /></el-form-item><el-form-item label="件数"><el-input v-model.number="form.packageCount" /></el-form-item><el-form-item label="金额(Decimal 字符串)"><el-input v-model="form.amount" /></el-form-item><el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.goodsDescription" type="textarea" /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item></el-form></section><section v-if="detail" class="surface-panel"><h2>状态历史</h2><el-timeline><el-timeline-item v-for="event in detail.statusHistory" :key="event.id" :timestamp="event.occurredAt">{{ event.fromStatus || '—' }} → {{ event.toStatus }} · {{ event.operatorName || '—' }}</el-timeline-item></el-timeline></section></template></div></template>
|
||||
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{margin-left:auto}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2{margin:0;font-size:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}}</style>
|
||||
@@ -0,0 +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"') }) })
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingQuery, OceanBookingStatus } from '../types'
|
||||
|
||||
const router = useRouter(); const rows = ref<OceanBookingResource[]>([]); const loading = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const query = reactive<QueryModel>({ keyword: '', status: '', pol: '', pod: '', soNo: '' })
|
||||
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: 'pol', type: 'input', label: '起运港', width: 150 }, { key: 'pod', type: 'input', label: '目的港', width: 150 }, { key: 'soNo', type: 'input', label: 'S/O号', width: 150 }]
|
||||
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false }, { prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'orderNo', label: '关联订单', minWidth: 140 }, { prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'pol', label: '起运港', minWidth: 110 }, { prop: 'pod', label: '目的港', minWidth: 110 }, { prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'soNo', label: 'S/O号', minWidth: 130 }, { prop: 'cutoffAt', label: '截关时间', minWidth: 150 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' }]
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined, pol: String(query.pol || '') || undefined, pod: String(query.pod || '') || undefined, soNo: String(query.soNo || '') || undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
|
||||
function search() { page.value = 1; void load() }; function open(id?: string) { void router.push(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') }
|
||||
watch([page, pageSize], () => void load()); onMounted(load)
|
||||
</script>
|
||||
<template><div class="business-page ocean-bookings-page"><div class="page-heading"><div><h1>海运订舱</h1><p>维护订舱申请、船期及放舱信息</p></div><el-button type="primary" @click="open()">新增订舱</el-button></div><QueryToolbar v-model="query" aria-label="订舱查询" :fields="fields" @search="search" /><LoadErrorState v-if="error" title="订舱列表加载失败" :message="error.message" @retry="load" /><DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings"><template #actions="{ row }"><el-button link type="primary" @click="open(row.id)">查看</el-button><el-button link @click="open(row.id)">编辑</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></DataTable></div></template>
|
||||
<style scoped>.ocean-bookings-page{display:grid;gap:16px}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.page-heading h1{margin:0;font-size:20px}.page-heading p{margin:6px 0 0;color:var(--cc-muted);font-size:13px}@media(max-width:600px){.page-heading{align-items:flex-start;flex-direction:column}.page-heading :deep(.el-button){width:100%;min-height:40px}}</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:view' } }] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,16 @@
|
||||
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
|
||||
|
||||
export interface OceanBookingContainer { id: string; containerType: string; quantity: number; containerKind?: string | null }
|
||||
export interface OceanBookingResource {
|
||||
id: string; bookingNo: string | null; orderNo: string | null; customerId: string | null; customerName: string | null
|
||||
status: OceanBookingStatus; carrierName: string | null; bookingAgent: string | null; vesselName: string | null; voyageNo: string | null
|
||||
pol: string | null; pod: string | null; mblShipper: string | null; mblConsignee: string | null; soNo: string | null
|
||||
cutoffAt: string | null; etd: string | null; atd: string | null; eta: string | null; ata: string | null
|
||||
containerSummary: string | null; containers: OceanBookingContainer[]; amount: string | null; currency: string | null
|
||||
remark: string | null; createdAt: string | null; updatedAt: string | null
|
||||
}
|
||||
export interface OceanBookingDetail extends OceanBookingResource { goodsDescription: string | null; packageCount: number | null; grossWeight: string | null; volume: string | null; statusHistory: OceanBookingStatusEvent[] }
|
||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; operatorName: string | null; occurredAt: string }
|
||||
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
|
||||
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; pol?: string; pod?: string; soNo?: string; customerId?: string }
|
||||
export interface OceanBookingSaveRequest { customerId?: string | null; orderNo?: string | null; carrierName?: string | null; bookingAgent?: string | null; vesselName?: string | null; voyageNo?: string | null; pol?: string | null; pod?: string | null; mblShipper?: string | null; mblConsignee?: string | null; soNo?: string | null; cutoffAt?: string | null; etd?: string | null; eta?: string | null; goodsDescription?: string | null; packageCount?: number | null; grossWeight?: string | null; volume?: string | null; amount?: string | null; currency?: string | null; remark?: string | null; containers: Array<{ containerType: string; quantity: number; containerKind?: string | null }> }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { changeTruckAppointmentStatus, createTruckAppointment, getTruckAppointment, listTruckAppointments } from './platformTrucking'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
|
||||
|
||||
describe('truck appointment API', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('normalizes page values and maps filters', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '21', pages: '2' } })
|
||||
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerType: '40HC' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerType: '40HC' } })
|
||||
})
|
||||
|
||||
it('uses detail, create, and status contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
||||
vi.mocked(http.patch).mockResolvedValue({ data: null })
|
||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ expectedLoadingDate: '2026-08-10' })).resolves.toBe('9001')
|
||||
await expect(changeTruckAppointmentStatus('9001', 'DISPATCHED')).resolves.toBeNull()
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/status', { status: 'DISPATCHED' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
SaveTruckAppointmentRequest,
|
||||
TruckAppointmentPage,
|
||||
TruckAppointmentQuery,
|
||||
TruckAppointmentResource,
|
||||
} from '../types'
|
||||
|
||||
const basePath = '/platform/v1/truck-appointments'
|
||||
function dtoPayload(payload: SaveTruckAppointmentRequest) {
|
||||
const { attachmentRefs: _attachmentRefs, ...dto } = payload
|
||||
return dto
|
||||
}
|
||||
|
||||
type PagePayload = Omit<TruckAppointmentPage, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages?: number | string
|
||||
}
|
||||
|
||||
function normalizePage(payload: PagePayload): TruckAppointmentPage {
|
||||
const page = Number(payload.page || 1)
|
||||
const size = Number(payload.size || 20)
|
||||
const total = Number(payload.total || 0)
|
||||
return { records: (payload.records || []).map(normalizeResource), page, size, total, pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)) }
|
||||
}
|
||||
|
||||
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
|
||||
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'carrierId']
|
||||
const normalized = { ...resource } as Record<string, unknown>
|
||||
for (const key of idKeys) {
|
||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||
}
|
||||
return normalized as unknown as TruckAppointmentResource
|
||||
}
|
||||
|
||||
export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload>, ApiEnvelope<PagePayload>>(basePath, {
|
||||
params: {
|
||||
...query,
|
||||
keyword: query.keyword?.trim() || undefined,
|
||||
status: query.status || undefined,
|
||||
containerBookingId: query.containerBookingId || undefined,
|
||||
},
|
||||
})
|
||||
return normalizePage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getTruckAppointment(id: string | number) {
|
||||
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
|
||||
return normalizeResource(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createTruckAppointment(payload: SaveTruckAppointmentRequest) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(basePath, dtoPayload(payload))
|
||||
return normalizeResource(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateTruckAppointment(id: string | number, payload: SaveTruckAppointmentRequest) {
|
||||
const response = await http.put<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`, dtoPayload(payload))
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE' | 'LOAD' | 'DEPART' | 'COMPLETE' | 'EXCEPTION' | 'CANCEL'
|
||||
|
||||
export async function performTruckAppointmentAction(
|
||||
id: string | number,
|
||||
action: TruckAppointmentAction,
|
||||
expectedVersion: number,
|
||||
reason?: string,
|
||||
idempotencyKey?: string,
|
||||
requestHash?: string,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
|
||||
headers: { ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function rescheduleTruckAppointment(id: string | number, plannedLoadingAt: string, expectedVersion: number, reason?: string) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/reschedule`, { expectedVersion, reason: reason || null }, { params: { plannedLoadingAt } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
|
||||
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const draft = reactive<SaveTruckAppointmentRequest>({ ...props.modelValue })
|
||||
watch(() => props.modelValue, (value) => Object.assign(draft, value), { deep: true })
|
||||
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
|
||||
const rules: FormRules = {
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请输入承运商 ID', trigger: 'blur' }],
|
||||
}
|
||||
const relationText = computed(() => [props.resource?.customerName, props.resource?.outboundOrderId, props.resource?.containerBookingId].filter(Boolean).join(' / ') || '由服务端关联资源返回')
|
||||
async function validate() { return formRef.value?.validate() }
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">关联资源:{{ relationText }};柜型由关联柜体返回(只读)</el-alert>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商 ID" prop="carrierId"><el-input v-model="draft.carrierId" placeholder="请输入后端资源 ID" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队电话"><el-input v-model="draft.fleetPhone" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机电话"><el-input v-model="draft.driverPhone" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="件数"><el-input v-model="draft.packageCount" inputmode="numeric" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)"><el-input v-model="draft.grossWeightKg" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)"><el-input v-model="draft.volumeCbm" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量"><el-input v-model="draft.palletCount" inputmode="numeric" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费"><el-input v-model="draft.payableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费"><el-input v-model="draft.receivableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<div class="form-actions"><el-button :disabled="readonly || submitting" @click="emit('attachments')">管理附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="emit('submit')">保存</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: 12px; }
|
||||
.relation-alert { margin-bottom: 16px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { TruckAttachmentReference } from '../types'
|
||||
const props = defineProps<{ modelValue: boolean; attachments: TruckAttachmentReference[]; readonly?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean]; save: [TruckAttachmentReference[]] }>()
|
||||
const draft = reactive<TruckAttachmentReference[]>([])
|
||||
watch(() => props.attachments, (value) => { draft.splice(0, draft.length, ...(value || []).map((item) => ({ ...item }))) }, { immediate: true, deep: true })
|
||||
function add() { draft.push({ fileId: '', fileName: '' }) }
|
||||
function remove(index: number) { draft.splice(index, 1) }
|
||||
function save() { emit('save', draft.filter((item) => item.fileId.trim() && item.fileName.trim()).map((item) => ({ ...item }))); emit('update:modelValue', false) }
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog :model-value="props.modelValue" title="附件引用" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-alert type="info" :closable="false" title="此处仅维护文件服务返回的引用,不会模拟上传成功。" />
|
||||
<div v-for="(item, index) in draft" :key="index" class="attachment-row">
|
||||
<el-input v-model="item.fileId" placeholder="文件 ID" :disabled="readonly" />
|
||||
<el-input v-model="item.fileName" placeholder="文件名" :disabled="readonly" />
|
||||
<el-button text type="danger" :disabled="readonly" @click="remove(index)">删除</el-button>
|
||||
</div>
|
||||
<el-button v-if="!readonly" plain @click="add">新增引用</el-button>
|
||||
<template #footer><el-button @click="emit('update:modelValue', false)">关闭</el-button><el-button v-if="!readonly" type="primary" @click="save">保存引用</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.attachment-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin: 14px 0; }
|
||||
@media (max-width: 560px) { .attachment-row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './api/platformTrucking'
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { createTruckAppointment } from '../api/platformTrucking'
|
||||
import type { SaveTruckAppointmentRequest, TruckAttachmentReference } from '../types'
|
||||
const router = useRouter(); const open = ref(true); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ attachmentRefs: [], expectedLoadingDate: null, payableAmount: '0.00', receivableAmount: '0.00' })
|
||||
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form, attachmentRefs: form.attachmentRefs || [] }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
|
||||
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
|
||||
</script>
|
||||
<template><AppDrawer v-model="open" title="新增拖车预约" :confirm-loading="saving" :show-footer="false" @cancel="router.push({ name: 'trucking' })"><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" @save="saveAttachments" /></AppDrawer></template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getTruckAppointment, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAttachmentReference } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const open = ref(true); 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>({})
|
||||
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'))
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0, attachmentRefs: result.attachments || [] }) } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||||
async function changeStatus(status: TruckAppointmentStatus) { if (!resource.value || status === resource.value.status) return; try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false } }
|
||||
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<AppDrawer v-model="open" :title="resource?.appointmentNo ? `拖车预约 ${resource.appointmentNo}` : '拖车预约详情'" :loading="loading" :show-footer="false" @cancel="router.push({ name: 'trucking' })">
|
||||
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="resource"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><el-select :model-value="resource.status" :loading="statusSaving" :disabled="statusSaving || !canChangeStatus" style="width:170px" @change="changeStatus"><el-option v-for="status in TRUCK_APPOINTMENT_STATUSES" :key="status" :value="status" :label="truckAppointmentStatusLabels[status]" /></el-select></div><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" :readonly="!canEdit" @save="saveAttachments" /></template>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TruckAppointmentsView from './TruckAppointmentsView.vue'
|
||||
import { listTruckAppointments } from '../api/platformTrucking'
|
||||
import type { TruckAppointmentResource } from '../types'
|
||||
|
||||
vi.mock('../api/platformTrucking', () => ({
|
||||
listTruckAppointments: vi.fn(),
|
||||
exportTruckAppointments: vi.fn(),
|
||||
}))
|
||||
|
||||
const row: TruckAppointmentResource = {
|
||||
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', customerName: '示例企业', orderNo: 'ORD-1',
|
||||
containerNo: 'CONT-1', containerType: '40HC', transportCompanyName: '宁波运输', driverName: '王强', tractorNo: '浙B12345',
|
||||
expectedLoadingDate: '2026-08-10', payableAmount: '3200.00', receivableAmount: '3800.00', attachments: [],
|
||||
}
|
||||
|
||||
describe('TruckAppointmentsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listTruckAppointments).mockReset().mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('loads the real list and renders relationship snapshots', async () => {
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerType: '' })
|
||||
expect(wrapper.text()).toContain('TA-9001')
|
||||
expect(wrapper.text()).toContain('40HC')
|
||||
expect(wrapper.text()).toContain('宁波运输')
|
||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus, RefreshCw } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.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'
|
||||
|
||||
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const loaded = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '' }); const applied = ref({ ...filters.value })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '委托单位、预约单号、车牌' },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
|
||||
{ key: 'containerBookingId', type: 'input', label: '柜体 ID', placeholder: '按关联柜体筛选' },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointmentNo', prop: 'appointmentNo', label: '预约单号', minWidth: 170 },
|
||||
{ key: 'relation', label: '关联资源', minWidth: 220, slotName: 'relation' },
|
||||
{ key: 'company', label: '承运商', minWidth: 150, slotName: 'company' },
|
||||
{ key: 'driver', label: '司机/车头', minWidth: 150, slotName: 'driver' },
|
||||
{ key: 'store', label: '门店', minWidth: 130, slotName: 'store' },
|
||||
{ key: 'plannedLoadingAt', prop: 'plannedLoadingAt', label: '预计装箱时间', minWidth: 170 },
|
||||
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.payableAmount ? `¥${row.payableAmount}` : '—' },
|
||||
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.receivableAmount ? `¥${row.receivableAmount}` : '—' },
|
||||
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
|
||||
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) },
|
||||
]
|
||||
function statusLabel(value: unknown) {
|
||||
return typeof value === 'string' && value in truckAppointmentStatusLabels
|
||||
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
|
||||
: String(value || '—')
|
||||
}
|
||||
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; loaded.value = true } 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: String(model.containerBookingId || '') }; page.value = 1; void load() }
|
||||
function reset() { filters.value = { keyword: '', status: '', containerBookingId: '' }; search(filters.value) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-heading"><div><h1>拖车预约</h1><p>维护拖车预约、关联资源与派车状态</p></div><div class="heading-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增预约</el-button></div></div>
|
||||
<LoadErrorState v-if="loadError" title="拖车预约加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<QueryToolbar v-model="filters" ariaLabel="拖车预约查询" :fields="fields" @search="search" @reset="reset" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments">
|
||||
<template #relation="{ row }"><div>{{ row.customerName || '—' }}</div><small>出仓单 {{ row.outboundOrderId || '—' }} · 柜体 {{ row.containerBookingId || '—' }}</small></template>
|
||||
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
|
||||
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.vehicleNo || '—' }}</small></template>
|
||||
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||
<template #actions="{ row }"><el-button text :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) } })"><Eye :size="16" />查看</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" text :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: { edit: '1' } })"><Pencil :size="16" />编辑</el-button></template>
|
||||
</DataTable>
|
||||
<div v-if="!loading && loaded && rows.length === 0 && !loadError" class="empty-hint"><RefreshCw :size="16" />暂无拖车预约</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.page-shell { min-width: 0; padding: 24px; }
|
||||
.page-heading { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:18px; flex-wrap:wrap; }
|
||||
h1 { margin:0; font-size:24px; }.page-heading p { margin:6px 0 0; color:var(--el-text-color-secondary); }.heading-actions { display:flex; gap:8px; flex-wrap:wrap; }.heading-actions :deep(.el-button) { margin:0; }.heading-actions :deep(svg) { vertical-align:middle; margin-right:5px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
|
||||
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
|
||||
@media (max-width:600px) { .page-shell { padding:16px 12px; } h1 { font-size:20px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const truckingRoutes = [
|
||||
{
|
||||
path: 'trucking',
|
||||
name: 'trucking',
|
||||
component: () => import('./pages/TruckAppointmentsView.vue'),
|
||||
meta: { title: '拖车预约', description: '维护拖车预约及派车状态', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
},
|
||||
{
|
||||
path: 'trucking/new',
|
||||
name: 'trucking-new',
|
||||
component: () => import('./pages/TruckAppointmentCreateView.vue'),
|
||||
meta: { title: '新增拖车预约', description: '创建拖车预约', permission: 'platform:firstleg:truck-appointment:create' },
|
||||
},
|
||||
{
|
||||
path: 'trucking/:id',
|
||||
name: 'trucking-detail',
|
||||
component: () => import('./pages/TruckAppointmentDetailView.vue'),
|
||||
props: true,
|
||||
meta: { title: '拖车预约详情', description: '查看和编辑拖车预约', permission: 'platform:firstleg:truck-appointment:view' },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,134 @@
|
||||
export const TRUCK_APPOINTMENT_STATUSES = [
|
||||
'DRAFT',
|
||||
'PENDING_DISPATCH',
|
||||
'DISPATCHED',
|
||||
'PICKUP_IN_PROGRESS',
|
||||
'ARRIVED_WAREHOUSE',
|
||||
'LOADING',
|
||||
'DEPARTED',
|
||||
'COMPLETED',
|
||||
'RESCHEDULED',
|
||||
'EXCEPTION',
|
||||
'CANCELLED',
|
||||
] as const
|
||||
|
||||
export type TruckAppointmentStatus = typeof TRUCK_APPOINTMENT_STATUSES[number]
|
||||
|
||||
export const truckAppointmentStatusLabels: Record<TruckAppointmentStatus, string> = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_DISPATCH: '待派车',
|
||||
DISPATCHED: '已派车',
|
||||
PICKUP_IN_PROGRESS: '提柜中',
|
||||
ARRIVED_WAREHOUSE: '已到仓',
|
||||
LOADING: '装柜中',
|
||||
DEPARTED: '已出发',
|
||||
COMPLETED: '已完成',
|
||||
RESCHEDULED: '已改期',
|
||||
EXCEPTION: '异常',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
export interface TruckAttachmentReference {
|
||||
fileId: string
|
||||
fileName: string
|
||||
objectKey?: string | null
|
||||
}
|
||||
|
||||
export interface TruckAppointmentResource {
|
||||
id: string
|
||||
appointmentNo?: string | null
|
||||
status: TruckAppointmentStatus
|
||||
version?: number | null
|
||||
revision?: number | null
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId?: string | null
|
||||
containerBookingId?: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
carrierId?: string | null
|
||||
carrierName?: string | null
|
||||
fleetContact?: string | null
|
||||
fleetPhone?: string | null
|
||||
driverName?: string | null
|
||||
driverPhone?: string | null
|
||||
driverIdCard?: string | null
|
||||
vehicleNo?: string | null
|
||||
storeName?: string | null
|
||||
storeContact?: string | null
|
||||
storePhone?: string | null
|
||||
storeAddress?: string | null
|
||||
storeCity?: string | null
|
||||
storeZipcode?: string | null
|
||||
plannedLoadingAt?: string | null
|
||||
transitMinutes?: number | string | null
|
||||
packageCount?: number | string | null
|
||||
grossWeightKg?: string | null
|
||||
volumeCbm?: string | null
|
||||
palletCount?: number | string | null
|
||||
payableAmount?: string | null
|
||||
receivableAmount?: string | null
|
||||
currency?: string | null
|
||||
poNo?: string | null
|
||||
remark?: string | null
|
||||
orderId?: string | null
|
||||
orderNo?: string | null
|
||||
containerNo?: string | null
|
||||
attachments?: TruckAttachmentReference[] | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface SaveTruckAppointmentRequest {
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId?: string | null
|
||||
containerBookingId?: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
carrierId?: string | null
|
||||
carrierName?: string | null
|
||||
fleetContact?: string | null
|
||||
fleetPhone?: string | null
|
||||
driverName?: string | null
|
||||
driverPhone?: string | null
|
||||
driverIdCard?: string | null
|
||||
vehicleNo?: string | null
|
||||
storeName?: string | null
|
||||
storeContact?: string | null
|
||||
storePhone?: string | null
|
||||
storeAddress?: string | null
|
||||
storeCity?: string | null
|
||||
storeZipcode?: string | null
|
||||
plannedLoadingAt?: string | null
|
||||
transitMinutes?: number | string | null
|
||||
packageCount?: number | string | null
|
||||
grossWeightKg?: string | null
|
||||
volumeCbm?: string | null
|
||||
palletCount?: number | string | null
|
||||
payableAmount?: string | null
|
||||
receivableAmount?: string | null
|
||||
currency?: string | null
|
||||
poNo?: string | null
|
||||
remark?: string | null
|
||||
attachmentRefs?: TruckAttachmentReference[]
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface TruckAppointmentQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
status?: TruckAppointmentStatus | ''
|
||||
containerBookingId?: string | ''
|
||||
}
|
||||
|
||||
export interface TruckAppointmentPage {
|
||||
records: TruckAppointmentResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
Reference in New Issue
Block a user