This commit is contained in:
2026-08-20 18:02:52 +08:00
parent 4ae5e7c672
commit 80947cd9ce
65 changed files with 1386 additions and 1083 deletions
@@ -1,62 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '—'))
const route = useRoute(); const router = useRouter()
const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待分配', COMPLETED: '已完成' }; return labels[String(value || '')] || text(value) }
const firstLine = computed(() => detail.value?.lines[0])
const fieldValues = computed<Record<string, string | number>>(() => ({
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: text(detail.value?.sourceOrderId),
海外仓: text(firstLine.value?.destinationWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) || 0,
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: '—', 更新时间: '—', 备注: '—',
}))
const groups = [
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '计费体积(m³)', '总托盘数'] },
{ title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] },
]
const feeColumns: DataTableColumn[] = [
{ key: 'index', label: '序号', width: 70, align: 'center' },
{ key: 'name', label: '费用项目', minWidth: 180 },
{ key: 'currency', label: '币种', width: 100 },
{ key: 'amount', label: '金额', width: 140, align: 'right' },
{ key: 'remark', label: '备注', minWidth: 200 },
]
function back() {
void router.push({ name: 'overseas-inbound-orders' })
}
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push({ name: 'overseas-inbound-orders' }) }
onMounted(load)
</script>
<template>
<OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="orderId" back-label="返回入库单" domain="入库单详情" @back="back">
<section class="detail-section" aria-label="入库单基本信息">
<div class="detail-groups">
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
<h2>{{ group.title }}</h2>
<dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
</div>
</section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading">
<h2 id="inbound-fees-heading">应收费用明细</h2>
<DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="海外区域应收费用契约待接入" />
<footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer>
</section>
</OverseasDetailShell>
</template>
<template><OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="detail?.planNo || planNo" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell></template>
<style scoped>
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
.detail-group:last-child { border-right: 0; }
.detail-group h2, .fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
.fee-section { overflow: hidden; padding-top: 16px; }
.fee-section h2 { margin-right: 18px; margin-left: 18px; }
.fee-total { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 40px; padding: 10px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-text); }
.fee-total strong { min-width: 120px; font-size: 16px; }
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }.detail-group:last-child { border-right: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }.detail-fields>div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0,1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-fields dt,.detail-fields dd { display:flex; min-width:0; align-items:center; padding:8px 10px; }.detail-fields dt { background:#f8fafc; color:var(--cc-muted); font-size:12px; }.detail-fields dd { margin:0; color:var(--cc-text); overflow-wrap:anywhere; }.fee-section { overflow:hidden; padding-top:16px; }.fee-section h2 { margin-right:18px; margin-left:18px; }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:10px 18px; border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:16px; }
</style>
@@ -1,58 +1,85 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { listInboundPlans } from '../api/inboundPlans'
import type { InboundOrderListItem } from '../types'
const rows = ref<InboundOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
const fields: QueryField[] = [
const total = ref(0)
const loading = ref(false)
const customersLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
const warehouseOptions = computed(() => [...new Map(rows.value.filter((row) => row.warehouseId).map((row) => [row.warehouseId, row.warehouse])).entries()].map(([value, label]) => ({ label, value })))
const fbaWarehouseOptions = computed(() => [...new Set(rows.value.map((row) => row.fbaWarehouse).filter((value) => value !== '—'))].map((value) => ({ label: value, value })))
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'orderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: ['待审核', '已审核', '已入库'].map((value) => ({ label: value, value })) },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', placeholder: '全部预约状态', options: ['未预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '全部排车状态', options: ['未排车', '已排车'].map((value) => ({ label: value, value })) },
{ key: 'inboundDate', type: 'daterange', label: '入库时间' },
]
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已完成', value: 'COMPLETED' }] },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouseOptions.value },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: fbaWarehouseOptions.value },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', placeholder: '全部预约状态', options: [{ label: '待预约', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '接口未提供', options: [], props: { disabled: true } },
{ key: 'inboundDate', type: 'daterange', label: '创建时间' },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'type', prop: 'type', label: '类型', width: 100 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'cartons', prop: 'cartons', label: '总箱数', width: 90, align: 'right' },
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'type', prop: 'type', label: '类型', width: 100 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 }, { key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'cartons', prop: 'cartons', label: '总箱数', width: 90, align: 'right' }, { key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' }, { key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 }, { key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
]
function openDetail(source: object) {
const row = source as InboundOrderListItem
void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo } })
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function statusLabel(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待分配', COMPLETED: '已完成' }; return labels[String(value || '')] || text(value) }
function appointmentLabel(value: unknown) { const labels: Record<string, string> = { PENDING: '待预约', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }; return labels[String(value || '')] || text(value) }
function orderType(value: unknown) { const labels: Record<string, string> = { ONE_CLICK_DROPSHIP: '一件代发', FIRST_LEG: '头程', RETURN_INBOUND: '退货入库' }; return labels[String(value || '')] || text(value) }
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '').trim()
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
const range = Array.isArray(filters.value.inboundDate) ? filters.value.inboundDate.map(String) : []
loading.value = true; loadError.value = null
try {
const result = await listInboundPlans({ customerId, page: page.value, size: pageSize.value, planNo: String(filters.value.orderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, overseasWarehouseId: String(filters.value.warehouse || '').trim() || undefined, fbaWarehouseCode: String(filters.value.fbaWarehouse || '').trim() || undefined, appointmentStatus: String(filters.value.appointmentStatus || '').trim() || undefined, createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined, createdTo: range[1] ? `${range[1]}T23:59:59` : undefined })
rows.value = result.records.map((item) => ({ id: String(item.id), customerId, sourceOrderId: String(item.sourceOrderId), warehouseId: String(item.overseasWarehouseId || ''), destinationWarehouseCode: String(item.destinationWarehouseCode || ''), orderNo: text(item.planNo), inboundCode: text(item.inboundCode), type: orderType(item.orderType), warehouse: text(item.overseasWarehouseName || item.destinationWarehouseName), fbaWarehouse: text(item.fbaWarehouseCode), owner: text(item.customerName), cartons: Number(item.packageCount || 0), volume: Number(item.totalVolumeCbm || 0), pallets: Number(item.palletCount || 0), status: statusLabel(item.status), appointmentStatus: appointmentLabel(item.appointmentStatus), dispatchStatus: '—' }))
total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '入库单加载失败,请重试') }
finally { loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
onMounted(loadCustomers)
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓入库单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="入库单" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inbound-orders" row-key="orderNo" empty-text="海外区域入库单查询契约待接入">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 0 条入库单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<template><AdminPage><ListPageLayout aria-label="海外仓入库单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="search" @reset="reset" /></template>
<template v-if="customersError || loadError" #state><LoadErrorState title="入库单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="id" :empty-text="filters.customerId ? '暂无入库单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template><template #pagination-left>共 {{ total }} 条入库单</template>
</DataTable></section>
</ListPageLayout></AdminPage></template>
@@ -1,88 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue'
const route = useRoute()
const router = useRouter()
const inventoryId = computed(() => String(route.params.inventoryId || '—'))
const productFields = ['客户', '海外仓', 'SKU编码', '中文品名', '英文品名', '更新时间']
const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
const locationColumns: DataTableColumn[] = [
{ key: 'location', label: '库位', minWidth: 130 },
{ key: 'status', label: '库存状态', width: 120 },
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
{ key: 'available', label: '可用数量', width: 110, align: 'right' },
{ key: 'inboundDate', label: '入库日期', width: 130 },
{ key: 'ageDays', label: '库龄(天)', width: 100, align: 'right' },
]
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'cartons', label: '关联箱数', width: 110, align: 'right' },
{ key: 'cartonRange', label: '箱号范围', minWidth: 200 },
{ key: 'location', label: '库位', minWidth: 130 },
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
{ key: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'location', label: '库位', minWidth: 120 },
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
{ key: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', label: '操作人', minWidth: 120 },
{ key: 'remark', label: '备注', minWidth: 180 },
]
function back() {
void router.push({ name: 'overseas-inventory' })
}
const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || '')); const customerId = computed(() => String(route.query.customerId || ''))
const balance = ref<InventoryBalanceDto | null>(null); const positions = ref<InventoryPositionDto[]>([]); const ledger = ref<InventoryLedgerDto[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
function text(value: unknown) { return String(value ?? '').trim() || '—' }
const productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: text(balance.value?.skuCode), 中文品名: text(balance.value?.productNameZh), 英文品名: text(balance.value?.productNameEn), 更新时间: formatOverseasDateTime(balance.value?.updatedAt) }))
const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散版: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) }))
const productFields = ['客户', '海外仓', 'SKU编码', '中文品名', '英文品名', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
const locationRows = computed(() => positions.value.map((item) => ({ id: String(item.id), location: text(item.warehouseLocationCode), status: text(item.status), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), quantity: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), inboundDate: formatOverseasDateTime(item.updatedAt), ageDays: '—' })))
const palletRows = computed(() => positions.value.map((item) => ({ id: String(item.id), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), cartonRange: '—', location: text(item.warehouseLocationCode), quantity: Number(item.totalPackages || 0) })))
const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })))
const locationColumns: DataTableColumn[] = [{ key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'status', prop: 'status', label: '库存状态', width: 120 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }, { key: 'available', prop: 'available', label: '可用数量', width: 110, align: 'right' }, { key: 'inboundDate', prop: 'inboundDate', label: '最近更新', width: 160 }, { key: 'ageDays', prop: 'ageDays', label: '库龄(天)', width: 100, align: 'right' }]
const palletColumns: DataTableColumn[] = [{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '关联箱数', width: 110, align: 'right' }, { key: 'cartonRange', prop: 'cartonRange', label: '箱号范围', minWidth: 200 }, { key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }]
const ledgerColumns: DataTableColumn[] = [{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 }, { key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 }, { key: 'location', prop: 'location', label: '库位', minWidth: 120 }, { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
async function load() { if (!inventoryId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从库存列表重新进入' }; return }; loading.value = true; loadError.value = null; try { const inboundOrderNo = String(route.query.inboundOrderNo || ''); const inboundCode = String(route.query.inboundCode || ''); const skuCode = String(route.query.skuCode || ''); const overseasWarehouseId = String(route.query.overseasWarehouseId || ''); const result = await listInventoryBalances({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: inboundOrderNo || undefined, inboundCode: inboundCode || undefined, skuCode: skuCode || undefined, overseasWarehouseId: overseasWarehouseId || undefined }); balance.value = result.records.find((item) => String(item.id) === inventoryId.value) || null; if (!balance.value) throw new Error('Inventory balance not found'); const [positionRows, ledgerPage] = await Promise.all([listInventoryPositions(inventoryId.value, customerId.value), listInventoryLedger({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: balance.value.inboundOrderNo || undefined, inboundCode: balance.value.inboundCode || undefined })]); positions.value = positionRows; ledger.value = ledgerPage.records } catch (error) { loadError.value = getApiErrorDetails(error, '库存详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
</script>
<template>
<OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" @back="back">
<section class="overview-panel" aria-label="库存概览">
<section aria-labelledby="inventory-product-heading">
<h2 id="inventory-product-heading">商品信息</h2>
<dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section aria-labelledby="inventory-summary-heading">
<h2 id="inventory-summary-heading">库存状态汇总</h2>
<dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
</section>
<section class="ledger-section" aria-labelledby="inventory-location-heading">
<h2 id="inventory-location-heading">库位库存</h2>
<DataTable :data="[]" :columns="locationColumns" column-settings-mode="hide" empty-text="海外区域库位库存契约待接入" />
</section>
<section class="ledger-section" aria-labelledby="inventory-pallet-heading">
<h2 id="inventory-pallet-heading">托盘箱号信息</h2>
<DataTable :data="[]" :columns="palletColumns" column-settings-mode="hide" empty-text="海外区域托盘箱号契约待接入" />
</section>
<section class="ledger-section" aria-labelledby="inventory-ledger-heading">
<h2 id="inventory-ledger-heading">进销存单据明细</h2>
<DataTable :data="[]" :columns="ledgerColumns" column-settings-mode="hide" empty-text="海外区域进销存契约待接入" />
</section>
</OverseasDetailShell>
</template>
<style scoped>
.overview-panel { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
.overview-panel > section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
.overview-panel > section:last-child { border-right: 0; }
.overview-panel h2, .ledger-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); }
.ledger-section { min-width: 0; overflow: hidden; padding-top: 16px; border: 1px solid var(--cc-border); background: #fff; }
.ledger-section h2 { margin-right: 18px; margin-left: 18px; }
</style>
<template><OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">当前流水接口未返回 SKU,按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
</OverseasDetailShell></template>
<style scoped>.overview-panel{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--cc-border);background:#fff}.overview-panel>section{min-width:0;padding:16px 18px 18px;border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 12px;color:var(--cc-text);font-size:15px}.detail-fields{margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-fields>div{display:grid;min-width:0;min-height:46px;grid-template-columns:120px minmax(0,1fr);border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}.detail-fields dt,.detail-fields dd{display:flex;min-width:0;align-items:center;padding:8px 10px}.detail-fields dt{background:#f8fafc;color:var(--cc-muted);font-size:12px}.detail-fields dd{margin:0;color:var(--cc-text)}.ledger-section{min-width:0;overflow:hidden;padding-top:16px;border:1px solid var(--cc-border);background:#fff}.ledger-section h2,.ledger-note{margin-right:18px;margin-left:18px}.ledger-note{color:var(--cc-muted);font-size:12px}</style>
@@ -1,60 +1,37 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { InventoryTransactionListItem } from '../types'
import { useRoute, useRouter } from 'vue-router'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'
const router = useRouter()
const rows = ref<InventoryTransactionListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ occurredDate: [], owner: '', warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields: QueryField[] = [
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主', width: 190 },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', width: 190, options: [] },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码', width: 170 },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '全部业务类型', width: 170, options: ['采购库存', '头程入库', '尾程入库', '退货入库', '暂扣库存', '一件代发', '头程出库', '派送出库', '调拨转仓', '库存调整'].map((value) => ({ label: value, value })) },
{ key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 },
{ key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 180 },
]
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const customersLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const customersError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ occurredDate: [], customerId: String(route.query.customerId || ''), warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 }, { key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '流水DTO暂未提供', width: 170, props: { disabled: true } },
{ key: 'businessType', type: 'input', label: '业务类型', placeholder: '请输入业务类型代码', width: 170 }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
])
const columns: DataTableColumn[] = [
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
{ key: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', label: '海外仓', minWidth: 180 },
{ key: 'sku', label: 'SKU编码', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
{ key: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', label: '操作人', minWidth: 120 },
{ key: 'remark', label: '备注', minWidth: 180 },
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 180 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
function back() { void router.push({ name: 'overseas-inventory' }) }
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function customerName(id: string) { return customers.value.find((item) => String(item.id) === id)?.tenantName || '—' }
async function loadCustomers() { customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records; if (filters.value.customerId) void load() } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') } finally { customersLoading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const customerId = String(filters.value.customerId || '').trim(); if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }; const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ customerId, page: page.value, size: pageSize.value, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: customerName(customerId), warehouse: text(item.destinationWarehouseCode), sku: '—', productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(loadCustomers)
</script>
<template>
<AdminPage><ListPageLayout aria-label="进销存记录列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="进销存记录" /><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。</p></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory-transactions" row-key="id" empty-text="海外区域进销存查询契约待接入">
<template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template>
<template #pagination-left>共 0 条进销存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.read-only-boundary { margin: 8px 2px 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
</style>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
<template v-if="customersError || loadError" #state><LoadErrorState title="进销存记录加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供 SKU、产品名称与海外仓名称,对应列显示空值。</p></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" :empty-text="filters.customerId ? '暂无进销存记录' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
@@ -1,75 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { BookOpen, Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InventoryListItem } from '../types'
const rows = ref<InventoryListItem[]>([])
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ warehouse: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields: QueryField[] = [
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' },
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '请输入批次号' },
{ key: 'location', type: 'input', label: '库位', placeholder: '请输入库位' },
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: ['正常', '库存不足', '无可用库存', '存在暂扣', '存在美本土散版'].map((value) => ({ label: value, value })) },
]
const metrics = [
['在库总量', 0, 'primary'], ['可用库存', 0, 'success'], ['暂扣库存', 0, 'danger'],
['美本土散版', 0, 'neutral'], ['在途库存', 0, 'warning'], ['采购库存', 0, 'neutral'],
] as const
const rows = ref<InventoryListItem[]>([]); const customers = ref<PlatformTenantResource[]>([]); const router = useRouter(); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const loading = ref(false); const customersLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const customersError = ref<ApiErrorDetails | null>(null); const summary = ref<InventoryDashboardDto | null>(null)
const filters = ref<QueryModel>({ customerId: '', warehouse: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const warehouseOptions = computed(() => { const values = new Map<string, string>(); for (const row of rows.value) if (row.warehouseId && row.warehouse !== '—') values.set(row.warehouseId, row.warehouse); return [...values].map(([value, label]) => ({ label, value })) })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouseOptions.value }, { key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' }, { key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '详情接口支持', props: { disabled: true } }, { key: 'location', type: 'input', label: '库位', placeholder: '详情接口支持', props: { disabled: true } },
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: [{ label: '可用', value: 'AVAILABLE' }, { label: '暂扣', value: 'HOLD' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已拣货', value: 'PICKED' }, { label: '在途', value: 'IN_TRANSIT' }, { label: '收货中', value: 'RECEIVING' }, { label: '采购库存', value: 'PROCUREMENT' }] },
])
const metrics = computed(() => [['在库总量', summary.value?.totalPackages ?? 0, 'primary'], ['可用库存', summary.value?.availablePackages ?? 0, 'success'], ['暂扣库存', summary.value?.holdPackages ?? 0, 'danger'], ['美本土散版', '—', 'neutral'], ['在途库存', summary.value?.inTransitPackages ?? 0, 'warning'], ['采购库存', summary.value?.procurementPackages ?? 0, 'neutral']] as const)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 },
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
{ key: 'available', prop: 'available', label: '可用库存', width: 105, align: 'right' },
{ key: 'held', prop: 'held', label: '暂扣库存', width: 105, align: 'right' },
{ key: 'looseboard', prop: 'looseboard', label: '美本土散版', width: 115, align: 'right' },
{ key: 'inTransit', prop: 'inTransit', label: '在途库存', width: 105, align: 'right' },
{ key: 'purchase', prop: 'purchase', label: '采购库存', width: 105, align: 'right' },
{ key: 'status', prop: 'status', label: '库存状态', width: 110 },
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
{ key: 'available', prop: 'available', label: '可用库存', width: 105, align: 'right' }, { key: 'held', prop: 'held', label: '暂扣库存', width: 105, align: 'right' }, { key: 'looseboard', prop: 'looseboard', label: '美本土散版', width: 115, align: 'right' },
{ key: 'inTransit', prop: 'inTransit', label: '在途库存', width: 105, align: 'right' }, { key: 'purchase', prop: 'purchase', label: '采购库存', width: 105, align: 'right' }, { key: 'status', prop: 'status', label: '库存状态', width: 110 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
]
function openDetail(source: object) {
const row = source as InventoryListItem
void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id } })
}
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function inventoryStatus(row: { holdPackages: number; availablePackages: number; totalPackages: number }) { if (row.holdPackages > 0) return '存在暂扣'; if (row.availablePackages <= 0) return '无可用库存'; return row.totalPackages > 0 ? '正常' : '库存不足' }
async function loadCustomers() { customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') } finally { customersLoading.value = false } }
function query() { return { customerId: String(filters.value.customerId || '').trim(), customerName: String(filters.value.owner || '').trim() || undefined, overseasWarehouseId: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, productName: String(filters.value.productName || '').trim() || undefined, inventoryStatus: String(filters.value.status || '').trim() || undefined } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const params = query(); if (!params.customerId) { rows.value = []; total.value = 0; summary.value = null; loadError.value = null; return }; loading.value = true; loadError.value = null; try { const [result, dashboard] = await Promise.all([listInventoryBalances({ ...params, page: page.value, size: pageSize.value }), getInventorySummary(params)]); rows.value = result.records.map((item) => ({ id: String(item.id), customerId: params.customerId, warehouseId: text(item.overseasWarehouseId) === '—' ? '' : String(item.overseasWarehouseId), inboundOrderNo: text(item.inboundOrderNo), inboundCode: text(item.inboundCode), owner: text(item.customerName), warehouse: text(item.overseasWarehouseName), sku: text(item.skuCode), productName: text(item.productNameZh), onHand: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), held: Number(item.holdPackages || 0), looseboard: null, inTransit: Number(item.inTransitPackages || 0), purchase: Number(item.procurementPackages || 0), status: inventoryStatus(item), updatedAt: formatOverseasDateTime(item.updatedAt) })); total.value = result.total; summary.value = dashboard } catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { customerId: row.customerId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku, overseasWarehouseId: row.warehouseId } }) }
function openTransactions() { const customerId = String(filters.value.customerId || ''); void router.push({ name: 'overseas-inventory-transactions', query: customerId ? { customerId } : {} }) }
onMounted(loadCustomers)
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓库存管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="page = 1" /></template>
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #state><ContractPendingNotice domain="库存管理" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory" row-key="id" empty-text="海外区域库存查询契约待接入">
<template #toolbar-actions><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 0 条库存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.inventory-metric { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: 12px; }
.inventory-metric strong { color: #334155; font-size: 22px; line-height: 1; }
.inventory-metric.is-primary strong { color: var(--cc-primary); }
.inventory-metric.is-success strong { color: #16a34a; }
.inventory-metric.is-danger strong { color: #dc2626; }
.inventory-metric.is-warning strong { color: #d97706; }
</style>
<template><AdminPage><ListPageLayout aria-label="海外仓库存管理"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="reset" /></template>
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template v-if="customersError || loadError" #state><LoadErrorState title="库存加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" :empty-text="filters.customerId ? '暂无库存记录' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="openTransactions"><BookOpen :size="16" />进销存记录</el-button></template><template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template><template #pagination-left>共 {{ total }} 条库存记录</template></DataTable></section>
</ListPageLayout></AdminPage></template>
<style scoped>.inventory-metrics{display:grid;grid-template-columns:repeat(6,minmax(130px,1fr));overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.inventory-metric{display:flex;min-width:0;flex-direction:column;gap:5px;padding:13px 16px;border-right:1px solid var(--cc-border)}.inventory-metric:last-child{border-right:0}.inventory-metric span{color:#64748b;font-size:12px}.inventory-metric strong{color:#334155;font-size:22px;line-height:1}.inventory-metric.is-primary strong{color:var(--cc-primary)}.inventory-metric.is-success strong{color:#16a34a}.inventory-metric.is-danger strong{color:#dc2626}.inventory-metric.is-warning strong{color:#d97706}</style>
@@ -1,16 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const palletId = computed(() => String(route.params.palletId || '—'))
const groups = [
{ title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] },
{ title: '使用信息', fields: ['关联入库单', '关联箱数', '创建时间', '更新时间', '备注'] },
]
function back() { void router.push({ name: 'overseas-pallets' }) }
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getPallet, type PalletDto } from '../api/pallets'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute(); const router = useRouter(); const palletId = computed(() => String(route.params.palletId || '')); const warehouseId = computed(() => String(route.query.warehouseId || '')); const pallet = ref<PalletDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const groups = [{ title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] }, { title: '使用信息', fields: ['关联入库单', '关联箱数', '创建时间', '更新时间', '备注'] }]
function text(value: unknown) { return String(value ?? '').trim() || '—' }; function status(value: unknown) { const labels: Record<string, string> = { IDLE: '闲置', OCCUPIED: '占用', SCRAPPED: '已报废' }; return labels[String(value || '')] || text(value) }
const values = computed<Record<string, string | number>>(() => ({ 托盘编码: text(pallet.value?.palletCode), 托盘类型: text(pallet.value?.palletType), 仓库: text(route.query.warehouseName || warehouseId.value), '容积(m³)': Number(pallet.value?.capacityCbm || 0), 采购价格: Number(pallet.value?.unitPrice || 0), 所属FBA仓: '—', 状态: status(pallet.value?.status), 关联入库单: '—', 关联箱数: '—', 创建时间: formatOverseasDateTime(pallet.value?.createdAt), 更新时间: formatOverseasDateTime(pallet.value?.updatedAt), 备注: text(pallet.value?.remark) }))
async function load() { if (!warehouseId.value || !palletId.value) { loadError.value = { message: '详情缺少仓库范围,请从托盘列表重新进入' }; return }; loading.value = true; loadError.value = null; try { pallet.value = await getPallet(warehouseId.value, palletId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push({ name: 'overseas-pallets' }) }; onMounted(load)
</script>
<template><OverseasReadOnlyDetail title="托盘详情" reference-label="托盘编码" :reference-value="palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" @back="back" /></template>
<template><OverseasReadOnlyDetail title="托盘详情" reference-label="托盘编码" :reference-value="pallet?.palletCode || palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" :field-values="values" :pending="false" :status-text="pallet ? status(pallet.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back"><template #state><LoadErrorState v-if="loadError" title="托盘详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></OverseasReadOnlyDetail></template>
@@ -1,66 +1,31 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { createPallet, listOverseasWarehouses, listPallets, updatePallet, type OverseasWarehouseDto } from '../api/pallets'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { PalletListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<PalletListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const access = useAccessPreviewStore(); const router = useRouter(); const rows = ref<PalletListItem[]>([]); const warehouses = ref<OverseasWarehouseDto[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const warehousesLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const warehousesError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ code: '', type: '', warehouse: '', status: '' })
const fields: QueryField[] = [
{ key: 'code', type: 'input', label: '托盘编码', placeholder: '请输入托盘编码', width: 180 },
{ key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸'].map((value) => ({ label: value, value })) },
{ key: 'warehouse', type: 'select', label: '仓库', placeholder: '全部仓库', width: 190, options: [] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: ['闲置', '占用'].map((value) => ({ label: value, value })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', label: '托盘类型', minWidth: 130 },
{ key: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', label: '容积(m³)', width: 110, align: 'right' },
{ key: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 },
{ key: 'status', label: '状态', width: 100 }, { key: 'remark', label: '备注', minWidth: 180 },
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ code: '', type: '木托盘', volume: undefined as number | undefined, purchasePrice: undefined as number | undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' })
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as PalletListItem | undefined; dialogMode.value = mode; Object.assign(form, { code: '', type: '木托盘', volume: undefined, purchasePrice: undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' }); if (mode === 'edit' && row) Object.assign(form, { code: String(row.code || ''), type: String(row.type || '木托盘'), volume: optionalNumber(row.volume), purchasePrice: optionalNumber(row.purchasePrice), warehouse: String(row.warehouse || ''), fbaWarehouse: String(row.fbaWarehouse || ''), status: String(row.status || '闲置'), remark: String(row.remark || '') }); dialogOpen.value = true }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: String(row.code) } }) }
const warehouseOptions = computed(() => warehouses.value.map((item) => ({ label: item.warehouseName, value: String(item.id) })))
const fields = computed<QueryField[]>(() => [{ key: 'code', type: 'input', label: '托盘编码', placeholder: '请输入托盘编码', width: 180 }, { key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸'].map((value) => ({ label: value, value })) }, { key: 'warehouse', type: 'select', label: '仓库', placeholder: '请选择仓库', width: 220, props: { filterable: true, loading: warehousesLoading.value }, options: warehouseOptions.value }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [{ label: '闲置', value: 'IDLE' }, { label: '占用', value: 'OCCUPIED' }, { label: '已报废', value: 'SCRAPPED' }] }])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'code', prop: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', prop: 'type', label: '托盘类型', minWidth: 130 }, { key: 'warehouse', prop: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', prop: 'volume', label: '容积(m³)', width: 110, align: 'right' }, { key: 'purchasePrice', prop: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 }, { key: 'status', prop: 'status', label: '状态', width: 100 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
const dialogOpen = ref(false); const dialogMode = ref<'create' | 'edit'>('create'); const saving = ref(false); const selected = ref<PalletListItem | null>(null)
const form = reactive({ code: '', type: '木托盘', volume: undefined as number | undefined, purchasePrice: undefined as number | undefined, warehouse: '', fbaWarehouse: '', status: 'IDLE', remark: '' })
function text(value: unknown) { return String(value ?? '').trim() || '—' }; function statusLabel(value: unknown) { const labels: Record<string, string> = { IDLE: '闲置', OCCUPIED: '占用', SCRAPPED: '已报废' }; return labels[String(value || '')] || text(value) }
async function loadWarehouses() { if (!access.can(permission.warehouseList)) { warehousesError.value = { message: '缺少海外仓列表权限,无法选择托盘所属仓库' }; return }; warehousesLoading.value = true; warehousesError.value = null; try { const tenants: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); tenants.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); const resolved: OverseasWarehouseDto[] = []; for (const tenant of tenants) { let warehousePage = 1; let warehousePages = 1; do { const result = await listOverseasWarehouses(String(tenant.id), warehousePage); resolved.push(...result.records); warehousePages = Math.max(1, result.pages); warehousePage += 1 } while (warehousePage <= warehousePages) }; warehouses.value = [...new Map(resolved.map((item) => [String(item.id), item])).values()] } catch (error) { warehousesError.value = getApiErrorDetails(error, '海外仓候选加载失败,请重试') } finally { warehousesLoading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const warehouseId = String(filters.value.warehouse || '').trim(); if (!warehouseId) { rows.value = []; total.value = 0; loadError.value = null; return }; loading.value = true; loadError.value = null; try { const result = await listPallets(warehouseId, { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); const warehouseName = warehouses.value.find((item) => String(item.id) === warehouseId)?.warehouseName || warehouseId; rows.value = result.records.map((item) => ({ id: String(item.id), warehouseId, locationId: item.locationId ? String(item.locationId) : '', code: text(item.palletCode), type: text(item.palletType), warehouse: warehouseName, volume: Number(item.capacityCbm || 0), purchasePrice: Number(item.unitPrice || 0), fbaWarehouse: '—', status: statusLabel(item.status), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as PalletListItem | undefined; dialogMode.value = mode; selected.value = row || null; Object.assign(form, { code: row?.code === '—' ? '' : row?.code || '', type: row?.type || '木托盘', volume: row?.volume, purchasePrice: row?.purchasePrice, warehouse: row?.warehouseId || String(filters.value.warehouse || ''), fbaWarehouse: '', status: row?.status === '占用' ? 'OCCUPIED' : row?.status === '已报废' ? 'SCRAPPED' : 'IDLE', remark: row?.remark === '—' ? '' : row?.remark || '' }); dialogOpen.value = true }
async function save() { if (!form.warehouse || !form.type || form.volume == null || form.purchasePrice == null || !form.status) { ElMessage.warning('请完整填写托盘必填字段'); return }; saving.value = true; try { const payload = { palletCode: form.code.trim() || undefined, palletType: form.type, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: selected.value?.locationId || undefined, remark: form.remark.trim() || undefined }; if (dialogMode.value === 'create') await createPallet(form.warehouse, payload); else if (selected.value) await updatePallet(selected.value.warehouseId, selected.value.id, payload); ElMessage.success(dialogMode.value === 'create' ? '托盘已创建' : '托盘已更新'); dialogOpen.value = false; filters.value.warehouse = form.warehouse; await load() } catch (error) { ElMessage.error(getApiErrorMessage(error, '托盘保存失败,请重试')) } finally { saving.value = false } }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId, warehouseName: row.warehouse } }) }
onMounted(loadWarehouses)
</script>
<template>
<AdminPage><ListPageLayout aria-label="托盘管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="page = 1" /></template>
<template #state><ContractPendingNotice domain="托盘管理" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-pallets" row-key="code" empty-text="海外区域托盘查询契约待接入">
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 0 条托盘</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" domain="托盘">
<el-form label-position="top"><el-row :gutter="16">
<el-col :span="12"><el-form-item label="托盘编码" required><el-input v-model="form.code" :readonly="dialogMode === 'edit'" placeholder="请输入托盘编码" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type"><el-option v-for="value in ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.01" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouse" placeholder="海外仓候选接口待接入" disabled /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouse" placeholder="FBA仓候选接口待接入" disabled /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status"><el-option label="闲置" value="闲置" /><el-option label="占用" value="占用" /></el-select></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row></el-form>
</ContractPendingFormDialog>
</AdminPage>
</template>
<template><AdminPage><ListPageLayout aria-label="托盘管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="reset" /></template><template v-if="warehousesError || loadError" #state><LoadErrorState title="托盘加载失败" :message="(warehousesError || loadError)?.message" :loading="warehousesLoading || loading" @retry="warehousesError ? loadWarehouses() : load()"><template #details><ApiErrorMeta :details="(warehousesError || loadError)!" /></template></LoadErrorState></template>
<section v-if="!warehousesError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouse ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" :disabled="!warehouses.length" @click="openForm('create')"><Plus :size="16" />新增托盘</el-button></template><template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template><template #pagination-left>共 {{ total }} 条托盘</template></DataTable></section>
</ListPageLayout><AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><el-row :gutter="16"><el-col :span="12"><el-form-item label="托盘编码"><el-input v-model="form.code" :readonly="dialogMode === 'edit'" placeholder="留空由系统生成" /></el-form-item></el-col><el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type"><el-option v-for="value in ['木托盘','塑料托盘','金属托盘','纸托盘','免熏蒸']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.01" :precision="2" controls-position="right" /></el-form-item></el-col><el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col><el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouse" filterable :disabled="dialogMode === 'edit'"><el-option v-for="item in warehouses" :key="String(item.id)" :label="item.warehouseName" :value="String(item.id)" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="所属 FBA 仓"><el-input model-value="接口未提供" readonly /></el-form-item></el-col><el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></el-col><el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
@@ -101,7 +101,7 @@ onMounted(loadCustomers)
<template v-if="customersError || loadError" #state><LoadErrorState title="换标单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" :empty-text="filters.customerId ? '暂无换标单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section>
</ListPageLayout>
@@ -50,7 +50,6 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('preserves the prototype dialog carriers and desktop widths', () => {
const expected = [
['RelabelOrdersView.vue', '760px', '新增换标单', '审核换标单'],
['PalletsView.vue', '760px', '新增托盘', '编辑托盘'],
['CartonsView.vue', '860px', '新增箱号', '编辑箱号'],
['ProductsView.vue', '560px', '新增产品', '编辑产品'],
]
@@ -62,6 +61,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain(editTitle)
expect(value).not.toContain('AppDrawer')
}
const pallets = source('PalletsView.vue')
expect(pallets).toContain('<AppDialog')
expect(pallets).toContain('760px')
expect(pallets).toContain('新增托盘')
expect(pallets).toContain('编辑托盘')
expect(pallets).not.toContain('AppDrawer')
})
it('implements all second-batch details as formal parameterized pages', () => {
@@ -79,11 +84,15 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).toContain('confirm-disabled')
expect(dialog).toContain('不可提交')
expect(dialog).not.toContain('@confirm=')
for (const fileName of ['RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
for (const fileName of ['RelabelOrdersView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
const value = source(fileName)
expect(value).not.toContain('ElMessage.success')
expect(value).not.toMatch(/https?:\/\//)
}
const pallets = source('PalletsView.vue')
expect(pallets).toContain('createPallet')
expect(pallets).toContain('updatePallet')
expect(pallets).toContain('ElMessage.success')
})
it('implements three prototype-defined details as formal pages', () => {
@@ -110,12 +119,15 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain(':data="cargo"')
expect(tailLeg).toContain('<LoadErrorState')
expect(tailLeg).not.toContain('ElMessage.success')
for (const fileName of ['InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
const value = source(fileName)
expect(value).toContain(':data="[]"')
expect(value).toContain('契约待接入')
expect(value).not.toContain('ElMessage.success')
}
const inbound = source('InboundOrderDetailView.vue')
expect(inbound).toContain('getInboundPlan')
expect(inbound).toContain('sourceOrderId')
expect(inbound).toContain('<LoadErrorState')
const inventory = source('InventoryDetailView.vue')
expect(inventory).toContain('listInventoryPositions')
expect(inventory).toContain(':data="locationRows"')
expect(inventory).toContain('当前流水接口未返回 SKU')
expect(inventory).not.toContain('ElMessage.success')
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
@@ -0,0 +1,28 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import { getInboundPlan } from '../api/inboundPlans'
import { listInventoryLedger } from '../api/inventory'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
import InventoryTransactionsView from './InventoryTransactionsView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
describe('overseas warehouse mounted real API pages', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
})
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
it('mounts inventory ledger as read-only facts', async () => { routing.route.query = { customerId: '10001' }; const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ customerId: '10001', page: 1, size: 20 })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
})