feat: complete customer overseas business flows

This commit is contained in:
2026-09-04 12:41:58 +08:00
parent 2a7e042420
commit 0957324fb0
18 changed files with 840 additions and 38 deletions
@@ -0,0 +1,73 @@
import { overseasApiRequest } from '@/api/overseasHttp'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
export interface InboundPlanQuery {
page: number
size: number
planNo?: string
inboundCode?: string
status?: string
destinationWarehouseCode?: string
appointmentStatus?: string
dispatchStatus?: string
createdFrom?: string
createdTo?: string
}
export interface InboundPlanSummary {
planNo: string
inboundCode: string
orderType: string | null
warehouseCode: string | null
warehouseName: string | null
fbaWarehouseCode: string | null
packageCount: number
pickedPackageCount: number
volumeCbm: number | null
palletCount: number
status: string
appointmentNo: string | null
appointmentStatus: string | null
appointmentAt: string | null
expectedLoadingAt: string | null
dispatchStatus: string | null
createdAt: string
version: number
}
export interface InboundPlanLine {
inboundCode: string
fbaWarehouseCode: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
volumeCbm: number
requiredPackageCount: number
plannedPalletCount: number
}
export interface InboundPlanPallet {
palletNo: string
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
capacityCbm: number
allocatedVolumeCbm: number
}
export interface InboundPlanDetail {
planNo: string
containerNo: string | null
status: string
lineCount: number
palletCount: number
totalVolumeCbm: number
expectedPackageCount: number
pickedPackageCount: number
lines: InboundPlanLine[]
pallets: InboundPlanPallet[]
version: number
}
export async function listInboundPlans(params: InboundPlanQuery) {
const result = await overseasApiRequest<PageResult<InboundPlanSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/inbound-plans', params })
return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
}
export function getInboundPlan(planNo: string) {
return overseasApiRequest<InboundPlanDetail>({ method: 'GET', url: `/merchant/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` })
}
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
test('activates the customer-scoped overseas inbound list and detail', () => {
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/)
assert.match(api, /encodeURIComponent\(planNo\)/)
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
})
test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => {
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
for (const token of ['planNo', 'inboundCode', 'destinationWarehouseCode', 'appointmentStatus', 'dispatchStatus', 'createdDates']) assert.match(view, new RegExp(token))
assert.match(view, /v-loading="loading"/)
assert.match(view, /type="error"/)
assert.match(view, /empty-text="暂无入库订单"/)
assert.match(view, /<el-pagination/)
assert.match(view, /detail\.planNo/)
assert.doesNotMatch(view, /planId|orderId|warehouseId|customerId/)
})
+1 -1
View File
@@ -17,7 +17,7 @@ export const orderManagementModule = {
path: 'orders/inbound',
name: 'orders-inbound',
component: () => import('./views/InboundOrdersView.vue'),
release: 'prototype',
release: 'active',
meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' },
navigation: { key: 'orders-inbound', icon: Package },
},
@@ -1,5 +1,137 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getInboundPlan, listInboundPlans, type InboundPlanDetail, type InboundPlanSummary } from '../api/inboundPlans'
const loading = ref(false)
const error = ref('')
const rows = ref<InboundPlanSummary[]>([])
const detail = ref<InboundPlanDetail | null>(null)
const total = ref(0)
const query = reactive({
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
appointmentStatus: '', dispatchStatus: '', createdDates: [] as string[],
})
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function statusLabel(value: unknown) {
return ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功', FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车' } as Record<string, string>)[String(value)] || text(value)
}
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
async function load() {
loading.value = true
error.value = ''
try {
const result = await listInboundPlans({
page: query.page, size: query.size, planNo: query.planNo || undefined, inboundCode: query.inboundCode || undefined,
destinationWarehouseCode: query.destinationWarehouseCode || undefined, status: query.status || undefined,
appointmentStatus: query.appointmentStatus || undefined, dispatchStatus: query.dispatchStatus || undefined,
createdFrom: query.createdDates[0] ? `${query.createdDates[0]}T00:00:00` : undefined,
createdTo: query.createdDates[1] ? `${query.createdDates[1]}T23:59:59` : undefined,
})
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = errorMessage(cause)
rows.value = []
total.value = 0
} finally { loading.value = false }
}
function resetQuery() {
Object.assign(query, { page: 1, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '', appointmentStatus: '', dispatchStatus: '', createdDates: [] })
void load()
}
async function openDetail(candidate: unknown) {
const row = candidate as InboundPlanSummary
loading.value = true
try { detail.value = await getInboundPlan(row.planNo) }
catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { loading.value = false }
}
onMounted(() => void load())
</script>
<template><PrototypeFeatureView title="入库订单列表" description="查询海外仓入库订单" /></template>
<template>
<section v-loading="loading" class="inbound-orders" :aria-busy="loading">
<header class="page-head">
<div><h2>{{ detail ? '入库订单详情' : '入库订单列表' }}</h2><p>{{ detail ? `计划号:${detail.planNo}` : '查询当前企业在海外仓的入库计划与作业进度' }}</p></div>
<el-button v-if="detail" @click="detail = null">返回列表</el-button>
</header>
<template v-if="!detail">
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button size="small" @click="load">重新加载</el-button></template></el-alert>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="query.page = 1; load()">
<el-form-item label="计划号"><el-input v-model.trim="query.planNo" clearable placeholder="请输入计划号" /></el-form-item>
<el-form-item label="入仓码"><el-input v-model.trim="query.inboundCode" clearable placeholder="请输入入仓码" /></el-form-item>
<el-form-item label="目的仓"><el-input v-model.trim="query.destinationWarehouseCode" clearable placeholder="请输入仓库代码" /></el-form-item>
<el-form-item label="作业状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
<el-form-item label="预约状态"><el-select v-model="query.appointmentStatus" clearable placeholder="全部"><el-option label="未预约" value="UNAPPOINTED" /><el-option label="待处理" value="PENDING" /><el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
<el-form-item label="排车状态"><el-select v-model="query.dispatchStatus" clearable placeholder="全部"><el-option label="未排车" value="UNSCHEDULED" /><el-option label="已排车" value="SCHEDULED" /></el-select></el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="query.createdDates" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="rows" border empty-text="暂无入库订单">
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column prop="planNo" label="计划号" min-width="180" show-overflow-tooltip />
<el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="海外仓" min-width="160" show-overflow-tooltip><template #default="scope">{{ text(scope.row.warehouseName || scope.row.warehouseCode) }}</template></el-table-column>
<el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="scope">{{ text(scope.row.fbaWarehouseCode) }}</template></el-table-column>
<el-table-column prop="packageCount" label="计划箱数" width="100" />
<el-table-column prop="pickedPackageCount" label="已拣箱数" width="100" />
<el-table-column prop="palletCount" label="托盘数" width="90" />
<el-table-column label="状态" width="120"><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column>
<el-table-column label="预约状态" width="120"><template #default="scope">{{ statusLabel(scope.row.appointmentStatus) }}</template></el-table-column>
<el-table-column label="排车状态" width="110"><template #default="scope">{{ statusLabel(scope.row.dispatchStatus) }}</template></el-table-column>
<el-table-column label="创建时间" width="180"><template #default="scope">{{ dateTime(scope.row.createdAt) }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
</template>
<template v-else>
<el-card shadow="never">
<dl class="detail-grid">
<div><dt>计划号</dt><dd>{{ detail.planNo }}</dd></div><div><dt>集装箱号</dt><dd>{{ text(detail.containerNo) }}</dd></div>
<div><dt>状态</dt><dd>{{ statusLabel(detail.status) }}</dd></div><div><dt>入仓码数</dt><dd>{{ detail.lineCount }}</dd></div>
<div><dt>托盘数</dt><dd>{{ detail.palletCount }}</dd></div><div><dt>总体积(m³)</dt><dd>{{ detail.totalVolumeCbm }}</dd></div>
<div><dt>计划箱数</dt><dd>{{ detail.expectedPackageCount }}</dd></div><div><dt>已拣箱数</dt><dd>{{ detail.pickedPackageCount }}</dd></div>
</dl>
</el-card>
<el-card shadow="never" header="入仓明细">
<el-table :data="detail.lines" border empty-text="暂无入仓明细">
<el-table-column prop="inboundCode" label="入仓码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓代码" min-width="140" />
<el-table-column prop="destinationWarehouseName" label="目的仓名称" min-width="160" /><el-table-column prop="requiredPackageCount" label="计划箱数" width="100" />
<el-table-column prop="plannedPalletCount" label="计划托数" width="100" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" />
</el-table>
</el-card>
<el-card shadow="never" header="托盘明细">
<el-table :data="detail.pallets" border empty-text="暂无托盘">
<el-table-column prop="palletNo" label="托盘码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓" min-width="150" />
<el-table-column prop="capacityCbm" label="容量(m³)" width="120" /><el-table-column prop="allocatedVolumeCbm" label="已分配体积(m³)" width="140" />
</el-table>
</el-card>
</template>
</section>
</template>
<style scoped>
.inbound-orders { display: grid; gap: 18px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
.filter-card :deep(.el-form-item) { margin-bottom: 12px; }
.filter-card :deep(.el-input), .filter-card :deep(.el-select) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.detail-grid div { padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; }
</style>