优化提交

This commit is contained in:
2026-08-20 18:04:38 +08:00
parent 4ae5e7c672
commit c4cd4b42b3
17 changed files with 814 additions and 105 deletions
@@ -6,7 +6,8 @@
|---|---|---|---|---|
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
| 入库单列表/详情 | `fba-inbound-order*` | 仅有 `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 阻断 | 没有分页列表,无法由详情接口反推列表;当前页面继续明确显示契约待接入。 |
| 入库单列表/FBA预约 | `fba-inbound-order` | `GET /platform/v1/last-leg/inbound-plans`、`POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | 前端已接入,待后端合入 | 已实现显式客户范围、分页筛选、按入库明细勾选、同客户/同 FBA 仓校验和预约弹窗;后端仓库写权限获批后需合入分页端点及预计装车时间字段。 |
| 入库单详情 | `fba-inbound-order-detail` | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 待接入 | 详情端点已存在,当前任务只处理列表分页与列表勾选预约。 |
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|ledger` | 阻断 | API 是入库单/包裹余额口径,原型是客户/仓库/SKU/产品口径,缺 SKU、产品名及原型指标。 |
| 托盘列表/详情 | `fba-pallet*` | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET /{id}` | 阻断 | API 强制 `customerId + warehouseId` 且资源客户绑定;产品契约要求平台公共资源、独立查询全部客户/仓库。字段还缺 FBA 仓和采购价。 |
@@ -15,4 +16,4 @@
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
当前所有上述海外业务 Controller 均未声明与前端 `platform:overseas-warehouse:*` 对应的服务端 `@PreAuthorize`。因此本轮只接入尾程只读查询;已有写端点在服务端权限、幂等和原型字段未一致前不向 Admin 暴露。
入库单菜单与预约按钮已统一使用服务端正式权限 `platform:lastleg:inbound-plan:list`、`platform:lastleg:inbound-plan:appointment`。其余海外页面仍按各自契约缺口处理,不用前端按钮权限替代服务端鉴权。
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment } from './inboundOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('inbound order overseas API', () => {
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
vi.mocked(overseasHttp.put).mockReset()
})
it('loads appointment details and updates the existing appointment without replacing its lines', async () => {
const detail = { id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: detail })
await getFbaAppointment('190000000000000001', 'APT/1')
await updateFbaAppointment('APT/1', { customerId: '190000000000000001', appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', { params: { customerId: '190000000000000001' } })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ customerId: '190000000000000001' }))
})
it('loads the paginated list with an explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listInboundOrders({ customerId: '190000000000000001', page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', {
params: { customerId: '190000000000000001', page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' },
})
})
it('preserves selected line ids as strings when creating an appointment', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', appointmentNo: 'APT-1', appointmentStatus: 'SUCCESS', selectedCount: 1, fbaWarehouseCode: 'ONT8' } })
const payload = {
customerId: '190000000000000001',
lineIds: ['190000000000000099'],
appointmentNo: 'APT-1',
appointmentAt: '2026-08-20T10:30:00',
expectedLoadingAt: '2026-08-20T09:00:00',
appointmentStatus: 'SUCCESS' as const,
}
await createFbaAppointment(payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/batch', payload)
})
})
@@ -0,0 +1,99 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
export interface InboundOrderQuery {
customerId: string
page: number
size: number
planNo?: string
inboundCode?: string
status?: string
warehouse?: string
fbaWarehouseCode?: string
appointmentStatus?: string
dispatchStatus?: string
createdFrom?: string
createdTo?: string
}
export interface InboundOrderSummaryDto {
lineId: string
planId: string
planNo: string
customerId: string
orderId: string
inboundCode?: string | null
orderType?: string | null
warehouseId?: string | null
warehouseCode?: string | null
warehouseName?: string | null
fbaWarehouseCode?: string | null
customerName?: string | null
packageCount?: number | null
volumeCbm?: number | null
palletCount?: number | null
status?: string | null
appointmentNo?: string | null
appointmentStatus?: string | null
appointmentAt?: string | null
expectedLoadingAt?: string | null
dispatchStatus?: string | null
createdAt?: string | null
}
export interface BatchFbaAppointmentPayload {
customerId: string
lineIds: string[]
appointmentNo: string
appointmentAt: string
expectedLoadingAt: string
appointmentStatus: 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'
appointmentRemark?: string
}
export interface FbaAppointmentDto {
id: string
customerId: string
appointmentNo: string
appointmentAt: string
expectedLoadingAt: string
appointmentStatus: string
appointmentRemark?: string | null
selectedCount: number
fbaWarehouseCode: string
items: Array<{ id: string; planId: string; lineId: string; orderId: string; inboundCode: string; fbaWarehouseCode: string }>
}
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
export async function listInboundOrders(query: InboundOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>>(
'/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } },
)
return unwrapApiResponse(response)
}
export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) {
const response = await overseasHttp.post<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>(
'/platform/v1/last-leg/inbound-plans/fba-appointments/batch', payload,
)
return unwrapApiResponse(response)
}
export async function getFbaAppointment(customerId: string, appointmentNo: string) {
const response = await overseasHttp.get<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function updateFbaAppointment(appointmentNo: string, payload: UpdateFbaAppointmentPayload) {
const response = await overseasHttp.put<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
payload,
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getInventorySummary, listInventoryBalances } from './inventory'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('overseas inventory API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('keeps skuCode and nullable location filters on real balance queries', async () => {
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } })
const query = { customerId: '100', page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' }
await listInventoryBalances(query)
await getInventorySummary(query)
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances', { params: query })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/summary', { params: { customerId: '100', skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } })
})
})
@@ -0,0 +1,74 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
export interface InventoryBalanceDto {
id: string
customerId: string
customerName?: string | null
overseasWarehouseId: string
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
inboundOrderId: string
inboundOrderNo?: string | null
inboundCode: string
skuCodes: string[]
productNames: string[]
warehouseLocationIds: string[]
warehouseLocationCodes: string[]
totalPackages: number
availablePackages: number
holdPackages: number
allocatedPackages: number
pickedPackages: number
inTransitPackages: number
receivingPackages: number
procurementPackages: number
updatedAt?: string | null
}
export interface InventoryDashboardDto {
inboundOrderCount: number
palletCount: number
totalPackages: number
availablePackages: number
holdPackages: number
allocatedPackages: number
pickedPackages: number
inTransitPackages: number
receivingPackages: number
procurementPackages: number
}
export interface InventoryBalanceQuery {
customerId: string
page: number
size: number
customerName?: string
overseasWarehouseId?: string
skuCode?: string
productName?: string
batchNo?: string
warehouseLocationCode?: string
inventoryStatus?: string
}
function params(query: InventoryBalanceQuery) { return { ...query } }
export async function listInventoryBalances(query: InventoryBalanceQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>, ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>>(
'/platform/v1/last-leg/inventory/balances', { params: params(query) },
)
return unwrapApiResponse(response)
}
export async function getInventorySummary(query: InventoryBalanceQuery) {
const { page: _page, size: _size, ...summaryParams } = params(query)
const response = await overseasHttp.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>(
'/platform/v1/last-leg/inventory/summary', { params: summaryParams },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createPallet, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from './pallets'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('overseas pallet API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('uses the customer and warehouse scoped pallet CRUD contract', async () => {
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', data: null })
const payload = { palletCode: 'PLT-1', palletType: 'WOOD', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
await listCustomerWarehouses('100')
await listPallets('100', '200', { page: 1, size: 20, keyword: 'PLT' })
await getPallet('100', '200', '9')
await createPallet('100', '200', payload)
await updatePallet('100', '200', '9', payload)
await deletePallet('100', '200', '9')
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { customerId: '100', page: 1, size: 20, keyword: 'PLT' } })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload, { params: { customerId: '100' } })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', payload, { params: { customerId: '100' } })
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', { params: { customerId: '100' } })
})
})
@@ -0,0 +1,84 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
export interface CustomerWarehouseDto {
id: string
customerId: string
warehouseCode: string
warehouseName: string
warehouseType: string
status: string
}
export interface PalletDto {
id: string
customerId: string
warehouseId: string
locationId?: string | null
palletCode: string
palletType: string
capacityCbm: number
unitPrice: number
status: string
remark?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface SavePalletPayload {
palletCode: string
palletType: string
capacityCbm: number
unitPrice: number
status: string
locationId?: string | null
remark?: string | null
}
export async function listCustomerWarehouses(customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>, ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>>(
'/platform/v1/warehouse-admin',
{ params: { customerId, page: 1, size: 200, status: 'ACTIVE' } },
)
return unwrapApiResponse(response)
}
export async function listPallets(customerId: string, warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`,
{ params: { customerId, ...query } },
)
return unwrapApiResponse(response)
}
export async function getPallet(customerId: string, warehouseId: string, palletId: string) {
const response = await overseasHttp.get<ApiEnvelope<PalletDto>, ApiEnvelope<PalletDto>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function createPallet(customerId: string, warehouseId: string, payload: SavePalletPayload) {
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload, { params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function updatePallet(customerId: string, warehouseId: string, palletId: string, payload: SavePalletPayload) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
payload, { params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function deletePallet(customerId: string, warehouseId: string, palletId: string) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
@@ -1,31 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Eye } from '@lucide/vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { CalendarCheck2, Eye, Pencil } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import 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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { InboundOrderListItem } from '../types'
const rows = ref<InboundOrderListItem[]>([])
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<InboundOrderListItem[]>([])
const selectedRows = ref<InboundOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const loading = ref(false)
const customersLoading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
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 filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', 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: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '入库中', value: 'IN_PROGRESS' }, { label: '已完成', value: 'COMPLETED' }] },
{ key: 'warehouse', type: 'input', label: '海外仓', placeholder: '仓库编码或名称' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', placeholder: '请输入FBA仓库编码' },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '全部排车状态', options: [{ label: '未排车', value: 'UNSCHEDULED' }, { label: '已排车', value: 'SCHEDULED' }] },
{ key: 'inboundDate', type: 'daterange', label: '入库时间' },
]
])
const appointmentOpen = ref(false)
const appointmentLoading = ref(false)
const appointmentMode = ref<'create' | 'edit'>('create')
const editingAppointmentNo = ref('')
const editingCustomerId = ref('')
const appointmentSelectedCount = ref(0)
const appointmentFbaWarehouse = ref('—')
const appointmentForm = reactive({ appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约')
function selectableRow(source: object) {
const row = source as InboundOrderListItem
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
const anchor = selectedRows.value[0]
return !anchor || (anchor.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse)
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: selectableRow },
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'type', prop: 'type', label: '类型', width: 100 },
@@ -40,19 +79,132 @@ const columns: DataTableColumn[] = [
{ 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) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function typeLabel(value: unknown) { return ({ ONE_CLICK_DROPSHIP: '一件代发', FIRST_LEG: '头程', REJECTED: '拒收订单', EXPRESS: '快递订单' } as Record<string, string>)[String(value || '')] || text(value) }
function planStatusLabel(value: unknown) { return ({ PENDING_ASSIGNMENT: '待分配', IN_PROGRESS: '入库中', COMPLETED: '已完成' } as Record<string, string>)[String(value || '')] || text(value) }
function appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) }
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
return {
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), type: typeLabel(dto.orderType), warehouse: text(dto.warehouseName || dto.warehouseCode),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: text(dto.appointmentAt), expectedLoadingAt: text(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
}
}
function onSelectionChange(selection: object[]) { selectedRows.value = selection as InboundOrderListItem[] }
function selectionError() {
if (!selectedRows.value.length) return '请至少选择 1 条入库单'
if (selectedRows.value.some((row) => !row.fbaWarehouse || row.fbaWarehouse === '—')) return '所选入库单缺少 FBA 仓库信息'
if (new Set(selectedRows.value.map((row) => row.customerId)).size !== 1) return '所选入库单必须属于同一客户'
if (new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1) return '所选入库单必须属于同一 FBA 仓库'
return ''
}
function openAppointment() {
const error = selectionError()
if (error) { ElMessage.warning(error); return }
appointmentMode.value = 'create'; editingAppointmentNo.value = ''; editingCustomerId.value = ''
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
Object.assign(appointmentForm, { appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS', appointmentRemark: '' })
mutationError.value = null
appointmentOpen.value = true
}
async function openAppointmentEdit(source: object) {
const row = source as InboundOrderListItem
if (!row.appointmentNo || row.appointmentNo === '—') return
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo; editingCustomerId.value = row.customerId
appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true
try {
const detail = await getFbaAppointment(row.customerId, row.appointmentNo)
appointmentSelectedCount.value = detail.selectedCount
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
Object.assign(appointmentForm, { appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, appointmentRemark: detail.appointmentRemark || '' })
} catch (caught) {
appointmentOpen.value = false
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
} finally { appointmentLoading.value = false }
}
async function submitAppointment() {
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
if (!appointmentForm.appointmentNo.trim() || !appointmentForm.appointmentAt || !appointmentForm.expectedLoadingAt) { ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return }
saving.value = true; mutationError.value = null
try {
const common = { appointmentAt: appointmentForm.appointmentAt, expectedLoadingAt: appointmentForm.expectedLoadingAt, appointmentStatus: appointmentForm.appointmentStatus, appointmentRemark: appointmentForm.appointmentRemark.trim() || undefined }
if (appointmentMode.value === 'edit') await updateFbaAppointment(editingAppointmentNo.value, { customerId: editingCustomerId.value, ...common })
else await createFbaAppointment({ customerId: selectedRows.value[0].customerId, lineIds: selectedRows.value.map((row) => row.lineId), appointmentNo: appointmentForm.appointmentNo.trim(), ...common })
appointmentOpen.value = false; selectedRows.value = []; ElMessage.success('FBA 预约保存成功'); await load()
} catch (caught) { mutationError.value = getApiErrorDetails(caught, 'FBA 预约保存失败,请重试') }
finally { saving.value = false }
}
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { customersLoading.value = false }
}
function dateRange() {
const range = Array.isArray(filters.value.inboundDate) ? filters.value.inboundDate : []
return { createdFrom: range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '').trim()
selectedRows.value = []
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
loading.value = true; loadError.value = null
try {
const result = await listInboundOrders({ 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, warehouse: String(filters.value.warehouse || '').trim() || undefined, fbaWarehouseCode: String(filters.value.fbaWarehouse || '').trim() || undefined, appointmentStatus: String(filters.value.appointmentStatus || '').trim() || undefined, dispatchStatus: String(filters.value.dispatchStatus || '').trim() || undefined, ...dateRange() })
rows.value = result.records.map(toListItem); 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>
<AdminPage><OperationErrorToast :details="mutationError" /><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="lineId" :empty-text="filters.customerId ? '暂无入库单' : '请先选择客户后查询'" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" type="primary" :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />FBA预约</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="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</ListPageLayout>
<AppDialog v-model="appointmentOpen" :title="appointmentTitle" width="min(720px, calc(100vw - 28px))" confirm-text="保存预约" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<el-form label-position="top" class="appointment-form">
<el-form-item label="预约号" required><el-input v-model="appointmentForm.appointmentNo" maxlength="64" :readonly="appointmentMode === 'edit'" placeholder="请输入预约号" /></el-form-item>
<el-form-item label="预约送仓时间" required><el-date-picker v-model="appointmentForm.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约送仓时间" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="appointmentForm.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装车时间" /></el-form-item>
<el-form-item label="预约状态" required><el-select v-model="appointmentForm.appointmentStatus"><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-input v-model="appointmentForm.appointmentRemark" maxlength="1000" placeholder="请输入预约备注" /></el-form-item>
</el-form>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.appointment-summary { margin-bottom: 16px; padding: 10px 12px; border: 1px solid #cdddfd; border-radius: 6px; background: #f5f9ff; color: #52627a; font-size: 13px; }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
</style>
@@ -1,63 +1,111 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { BookOpen, 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 { getInventorySummary, listInventoryBalances, type InventoryBalanceDto, type InventoryDashboardDto } from '../api/inventory'
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
import type { InventoryListItem } from '../types'
const rows = ref<InventoryListItem[]>([])
const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<CustomerWarehouseDto[]>([])
const rows = ref<InventoryListItem[]>([])
const summary = ref<InventoryDashboardDto | null>(null)
const loading = ref(false)
const contextLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
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: [] },
const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 230, placeholder: '请选择客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, clearable: true } },
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 230, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: !filters.value.customerId || contextLoading.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: '请输入批次号' },
{ 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
{ key: 'location', type: 'input', label: '库位', placeholder: '请输入库位(可空)' },
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: [{ label: '有可用库存', value: 'AVAILABLE' }, { label: '存在暂扣', value: 'HOLD' }, { label: '已分配', value: 'ALLOCATED' }, { 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: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false },
{ 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: '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) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function status(dto: InventoryBalanceDto) { if (number(dto.holdPackages) > 0) return '存在暂扣'; if (number(dto.availablePackages) <= 0) return '无可用库存'; return '正常' }
function toRow(dto: InventoryBalanceDto): InventoryListItem { return { id: String(dto.id), customerId: String(dto.customerId), owner: text(dto.customerName), warehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseCode), sku: dto.skuCodes?.length ? dto.skuCodes.join('、') : '—', productName: dto.productNames?.length ? dto.productNames.join('、') : '—', location: dto.warehouseLocationCodes?.length ? dto.warehouseLocationCodes.join('、') : '—', onHand: number(dto.totalPackages), available: number(dto.availablePackages), held: number(dto.holdPackages), looseboard: '—', inTransit: number(dto.inTransitPackages), purchase: number(dto.procurementPackages), status: status(dto), updatedAt: text(dto.updatedAt) } }
async function loadCustomers() {
contextLoading.value = true; loadError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records }
catch (error) { loadError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { contextLoading.value = false }
}
async function loadWarehouses(customerId: string) {
warehouses.value = []; filters.value.warehouseId = ''; rows.value = []; total.value = 0; summary.value = null
if (!customerId) return
contextLoading.value = true; loadError.value = null
try { warehouses.value = (await listCustomerWarehouses(customerId)).records }
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false }
}
function query() {
return { customerId: String(filters.value.customerId || ''), page: page.value, size: pageSize.value, customerName: String(filters.value.owner || '').trim() || undefined, overseasWarehouseId: String(filters.value.warehouseId || '') || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, productName: String(filters.value.productName || '').trim() || undefined, batchNo: String(filters.value.batchNo || '').trim() || undefined, warehouseLocationCode: String(filters.value.location || '').trim() || undefined, inventoryStatus: String(filters.value.status || '') || undefined }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
if (!filters.value.customerId) { rows.value = []; total.value = 0; summary.value = null; return }
loading.value = true; loadError.value = null
try { const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(query()), getInventorySummary(query())]); rows.value = pageResult.records.map(toRow); total.value = pageResult.total; summary.value = summaryResult }
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 openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { customerId: row.customerId } }) }
watch(() => String(filters.value.customerId || ''), (value, oldValue) => { if (value !== oldValue) void loadWarehouses(value) })
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 #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></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 v-if="loadError" #state><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" :empty-text="filters.customerId ? '暂无库存记录' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<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>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
@@ -1,16 +1,47 @@
<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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getPallet, listCustomerWarehouses, type PalletDto } from '../api/pallets'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const palletId = computed(() => String(route.params.palletId || '—'))
const customerId = computed(() => String(route.query.customerId || ''))
const warehouseId = computed(() => String(route.query.warehouseId || ''))
const detail = ref<PalletDto | null>(null)
const warehouseName = ref('—')
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const groups = [
{ title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] },
{ title: '使用信息', fields: ['关联入库单', '关联箱数', '创建时间', '更新时间', '备注'] },
{ title: '使用信息', fields: ['库位ID', '创建时间', '更新时间', '备注'] },
]
const values = computed(() => ({
托盘编码: detail.value?.palletCode || '—', 托盘类型: detail.value?.palletType || '—', 仓库: warehouseName.value,
'容积(m³)': detail.value?.capacityCbm ?? '—', 采购价格: detail.value?.unitPrice ?? '—', 所属FBA仓: '—', 状态: detail.value?.status || '—',
库位ID: detail.value?.locationId || '—', 创建时间: detail.value?.createdAt || '—', 更新时间: detail.value?.updatedAt || '—', 备注: detail.value?.remark || '—',
}))
async function load() {
if (!customerId.value || !warehouseId.value || palletId.value === '—') { loadError.value = getApiErrorDetails(new Error('托盘详情缺少客户或仓库参数')); return }
loading.value = true; loadError.value = null
try {
const [pallet, warehouses] = await Promise.all([getPallet(customerId.value, warehouseId.value, palletId.value), listCustomerWarehouses(customerId.value)])
detail.value = pallet
const warehouse = warehouses.records.find((item) => String(item.id) === warehouseId.value)
warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : warehouseId.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="detail?.palletCode || palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" :field-values="values" :pending="false" status-text="真实数据" :loading="loading" :show-content="!loadError" @back="back">
<template v-if="loadError" #state><LoadErrorState title="托盘详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
</OverseasReadOnlyDetail></template>
@@ -1,66 +1,155 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Eye, Pencil, Plus, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import 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 OperationErrorToast from '@/shared/components/OperationErrorToast.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 { createPallet, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<CustomerWarehouseDto[]>([])
const rows = ref<PalletListItem[]>([])
const loading = ref(false)
const contextLoading = ref(false)
const saving = ref(false)
const deletingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ code: '', type: '', warehouse: '', status: '' })
const fields: QueryField[] = [
const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', warehouseId: '', code: '', type: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, clearable: true } },
{ key: 'warehouseId', type: 'select', label: '仓库', width: 240, placeholder: contextLoading.value ? '仓库加载中' : '请选择仓库', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: !filters.value.customerId || contextLoading.value } },
{ 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 })) },
]
{ key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE'].map((value) => ({ label: palletTypeLabel(value), 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', 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 },
{ key: 'actions', label: '操作', width: 250, 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 form = reactive({ code: '', type: '木托盘', volume: undefined as number | undefined, purchasePrice: undefined as number | undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' })
const form = reactive({ id: '', customerId: '', warehouseId: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', 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) } }) }
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function palletTypeLabel(value: string) { return ({ WOOD: '木托盘', PLASTIC: '塑料托盘', METAL: '金属托盘', PAPER: '纸托盘', FUMIGATION_FREE: '免熏蒸托盘' } as Record<string, string>)[value] || value }
function statusLabel(value: string) { return ({ IDLE: '闲置', OCCUPIED: '占用', SCRAPPED: '已报废' } as Record<string, string>)[value] || value }
function warehouseName(id: string) { const warehouse = warehouses.value.find((item) => String(item.id) === id); return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '—' }
function toRow(dto: PalletDto): PalletListItem { return { id: String(dto.id), customerId: String(dto.customerId), warehouseId: String(dto.warehouseId), locationId: text(dto.locationId), code: text(dto.palletCode), type: palletTypeLabel(dto.palletType), warehouse: warehouseName(String(dto.warehouseId)), volume: number(dto.capacityCbm), purchasePrice: number(dto.unitPrice), fbaWarehouse: '—', status: statusLabel(dto.status), remark: text(dto.remark) } }
async function loadCustomers() {
contextLoading.value = true; loadError.value = null
try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records }
catch (error) { loadError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { contextLoading.value = false }
}
async function loadWarehouses(customerId: string) {
warehouses.value = []; filters.value.warehouseId = ''; rows.value = []; total.value = 0
if (!customerId) return
contextLoading.value = true; loadError.value = null
try { warehouses.value = (await listCustomerWarehouses(customerId)).records }
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '')
const warehouseId = String(filters.value.warehouseId || '')
if (!customerId || !warehouseId) { rows.value = []; total.value = 0; return }
loading.value = true; loadError.value = null
try {
const result = await listPallets(customerId, warehouseId, { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '') || undefined, status: String(filters.value.status || '') || undefined })
rows.value = result.records.map(toRow); total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请重试') }
finally { loading.value = false }
}
function openCreate() {
if (!filters.value.customerId || !filters.value.warehouseId) { ElMessage.warning('请先选择客户和仓库'); return }
dialogMode.value = 'create'; Object.assign(form, { id: '', customerId: String(filters.value.customerId), warehouseId: String(filters.value.warehouseId), code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
}
async function openEdit(source: object) {
const row = source as PalletListItem; saving.value = true; mutationError.value = null
try {
const detail = await getPallet(row.customerId, row.warehouseId, row.id)
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), customerId: String(detail.customerId), warehouseId: String(detail.warehouseId), code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '' }); dialogOpen.value = true
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { saving.value = false }
}
async function save() {
if (!form.code.trim() || !form.type || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写托盘编码、类型、容积、采购价格和状态'); return }
saving.value = true; mutationError.value = null
const payload = { palletCode: form.code.trim(), palletType: form.type, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
try {
if (dialogMode.value === 'edit') await updatePallet(form.customerId, form.warehouseId, form.id, payload)
else await createPallet(form.customerId, form.warehouseId, payload)
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
finally { saving.value = false }
}
async function remove(source: object) {
const row = source as PalletListItem
try { await ElMessageBox.confirm(`确认删除托盘 ${row.code}?`, '删除托盘', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) }
catch { return }
deletingId.value = row.id; mutationError.value = null
try { await deletePallet(row.customerId, row.warehouseId, row.id); ElMessage.success('托盘已删除'); await load() }
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘删除失败,请重试') }
finally { deletingId.value = '' }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { customerId: row.customerId, warehouseId: row.warehouseId } }) }
watch(() => String(filters.value.customerId || ''), (value, oldValue) => { if (value !== oldValue) void loadWarehouses(value) })
onMounted(loadCustomers)
</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>
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="托盘管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="filters.customerId ? loadWarehouses(String(filters.customerId)) : loadCustomers()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.customerId && filters.warehouseId ? '暂无托盘' : '请先选择客户和仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><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="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.palletDelete)" size="small" plain type="danger" :loading="deletingId === (row as PalletListItem).id" @click="remove(row)"><Trash2 :size="15" />删除</el-button></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" domain="托盘">
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<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="托盘编码" required><el-input v-model="form.code" :readonly="dialogMode === 'edit'" maxlength="128" 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 ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(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.0001" :precision="4" 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-col :span="12"><el-form-item label="仓库"><el-input :model-value="warehouseName(form.warehouseId)" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓"><el-input model-value="—" readonly placeholder="当前托盘不绑定 FBA 仓" /></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" maxlength="500" /></el-form-item></el-col>
</el-row></el-form>
</ContractPendingFormDialog>
</AppDialog>
</AdminPage>
</template>
@@ -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', '新增产品', '编辑产品'],
]
@@ -79,7 +78,7 @@ 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?:\/\//)
@@ -118,6 +117,28 @@ describe('overseas warehouse first-batch prototype contract', () => {
}
})
it('connects pallet CRUD after the overseas contract is available', () => {
const value = source('PalletsView.vue')
expect(value).toContain('<AppDialog')
expect(value).toContain('760px')
expect(value).toContain("dialogMode === 'create' ? '新增托盘' : '编辑托盘'")
expect(value).toContain('createPallet')
expect(value).toContain('updatePallet')
expect(value).toContain('deletePallet')
expect(value).not.toContain('ContractPendingFormDialog')
})
it('connects inbound order pagination and FBA appointment selection to the real overseas API', () => {
const value = source('InboundOrdersView.vue')
expect(value).toContain('listInboundOrders')
expect(value).toContain("key: 'selection', type: 'selection'")
expect(value).toContain('@selection-change="onSelectionChange"')
expect(value).toContain('createFbaAppointment')
expect(value).toContain('预计装车时间')
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
expect(value).not.toContain('ContractPendingNotice')
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
const inventory = source('InventoryView.vue')
const transactions = source('InventoryTransactionsView.vue')
@@ -187,11 +208,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
'actions', 'orderNo', 'inboundCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'volume',
'actions', 'selection', 'orderNo', 'inboundCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'volume',
'pallets', 'status', 'appointmentStatus', 'dispatchStatus',
])
expect(columnKeys('InventoryView.vue')).toEqual([
'actions', 'owner', 'warehouse', 'sku', 'productName', 'onHand', 'available', 'held', 'looseboard',
'actions', 'owner', 'warehouse', 'sku', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
'inTransit', 'purchase', 'status', 'updatedAt',
])
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
@@ -1,13 +1,15 @@
export const overseasWarehousePermissions = {
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
inventoryList: 'platform:overseas-warehouse:inventory:list',
inboundOrderList: 'platform:lastleg:inbound-plan:list',
inboundOrderAppointment: 'platform:lastleg:inbound-plan:appointment',
inventoryList: 'platform:lastleg:inventory:list',
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
relabelOrderEdit: 'platform:overseas-warehouse:relabel-order:edit',
palletList: 'platform:overseas-warehouse:pallet:list',
palletCreate: 'platform:overseas-warehouse:pallet:create',
palletEdit: 'platform:overseas-warehouse:pallet:edit',
palletList: 'platform:pallet:list',
palletCreate: 'platform:pallet:create',
palletEdit: 'platform:pallet:edit',
palletDelete: 'platform:pallet:delete',
cartonList: 'platform:overseas-warehouse:carton:list',
cartonCreate: 'platform:overseas-warehouse:carton:create',
cartonEdit: 'platform:overseas-warehouse:carton:edit',
+14 -1
View File
@@ -42,6 +42,10 @@ export interface LastMileOrderListItem {
}
export interface InboundOrderListItem {
lineId: string
planId: string
customerId: string
orderId: string
orderNo: string
inboundCode: string
type: string
@@ -52,20 +56,25 @@ export interface InboundOrderListItem {
volume: number
pallets: number
status: string
appointmentNo: string
appointmentStatus: string
appointmentAt: string
expectedLoadingAt: string
dispatchStatus: string
}
export interface InventoryListItem {
id: string
customerId: string
owner: string
warehouse: string
sku: string
productName: string
location: string
onHand: number
available: number
held: number
looseboard: number
looseboard: number | string
inTransit: number
purchase: number
status: string
@@ -88,6 +97,10 @@ export interface RelabelOrderListItem {
}
export interface PalletListItem {
id: string
customerId: string
warehouseId: string
locationId: string
code: string
type: string
warehouse: string