Compare commits

...
3 Commits
Author SHA1 Message Date
liuyihe 595c998214 Merge remote-tracking branch 'origin/dev' into dev 2026-08-20 18:27:39 +08:00
liuyihe 9ac4495632 合并海外仓入库预约与真实接口改动 2026-08-20 18:27:30 +08:00
liuyihe c4cd4b42b3 优化提交 2026-08-20 18:04:38 +08:00
19 changed files with 975 additions and 195 deletions
+5 -5
View File
@@ -81,11 +81,11 @@ describe('admin router access metadata', () => {
it('registers the confirmed phase-four routes with stable list permissions', () => {
const expected = [
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:overseas-warehouse:inbound-order:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:overseas-warehouse:pallet:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:overseas-warehouse:inventory:list'],
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:lastleg:order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:lastleg:inbound-plan:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:lastleg:relabel:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:pallet:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:lastleg:inventory:list'],
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase:list'],
['overseas-business-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
['overseas-business-looseboards', '/overseas-business/looseboards', 'platform:overseas-business:looseboard:list'],
+5 -6
View File
@@ -32,7 +32,6 @@ describe('adminNavigation', () => {
'订单测量',
'测量数据审核',
'库位管理',
'托盘管理',
'整柜业务',
])
expect(adminNavigation.find((item) => item.key === 'service-center')).toBeUndefined()
@@ -138,11 +137,11 @@ describe('adminNavigation', () => {
it('shows only the confirmed phase-four pages with their list permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:relabel-order:list',
'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:inventory:list',
'platform:lastleg:order:list',
'platform:lastleg:inbound-plan:list',
'platform:lastleg:relabel:list',
'platform:pallet:list',
'platform:lastleg:inventory:list',
'platform:overseas-business:purchase:list',
'platform:overseas-business:truck:list',
'platform:overseas-business:looseboard:list',
+5 -6
View File
@@ -68,7 +68,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
],
},
@@ -121,11 +120,11 @@ export const adminNavigation: AdminNavigationNode[] = [
title: '海外仓',
icon: PrototypeOverseasWarehouseIcon,
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:lastleg:order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:lastleg:inbound-plan:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:lastleg:relabel:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:lastleg:inventory:list' },
],
},
{
@@ -6,15 +6,15 @@
|---|---|---|---|---|
| 尾程订单列表 | `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`、`GET /{planNo}` | 已接入只读 | 显式 `customerId`、真实分页/筛选/状态;详情展示 `sourceOrderId`、入仓行和汇总。详情 DTO 未提供预约、排车、创建更新时间和费用,对应原型字段保持空值。 |
| 入库单列表/详情/FBA 预约 | `fba-inbound-order*` | `GET /platform/v1/last-leg/inbound-plans`、`GET /{planNo}`、`POST /fba-appointments/batch`、`GET/PUT /fba-appointments/{appointmentNo}` | 已接入 | 显式 `customerId`、真实分页和筛选;支持按入库明细勾选、同客户/同 FBA 仓校验、预计装车时间以及预约编辑。 |
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|balances/{id}/positions|ledger` | 已接入只读 | 余额已提供 SKU/中英文品名;列表、汇总、库位/托盘位置与流水已接。流水 DTO 仍缺 SKU、产品名和仓库名,库存详情流水只能按入库单范围展示并明确提示。 |
| 托盘列表/详情 | `fba-pallet*` | `GET/POST /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET/PUT /{id}` | 已接入 | 资源请求不传 `customerId`,按公共 `customer_id=0` 查询/写入;新增/编辑使用真实 DTO。DTO 未提供 FBA 仓和业务绑定汇总,字段保持空值。仓库候选因仓库列表仍按客户分页,当前跨全部 ACTIVE 客户汇总,不受页面客户筛选控制。 |
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|balances/{id}/positions|ledger` | 已接入只读 | 列表、汇总、库位/托盘位置与流水已接入;余额返回 SKU/产品名数组,流水返回 SKU 和库位代码。 |
| 托盘列表/详情 | `fba-pallet*` | `GET/POST /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET/PUT/DELETE /{id}` | 已接入 | 所有请求显式携带 `customerId` 和仓库范围;新增、编辑、删除使用真实 DTO 和真实 Controller 端点。 |
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部接收事件产生快照,浏览器不得调用内部端点。 |
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部一件代发快照写入,未提供平台查询/维护端点。 |
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
当前入仓、库存、托盘 Controller 已使用 `platform:overseas-warehouse:*` canonical 权限。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令;库存页面没有原型承载的写操作,不自行新增按钮。
菜单、路由与操作按钮使用真实 Controller 权限:`platform:lastleg:*`、`platform:pallet:*`、`platform:warehouse:*` 和 `platform:location:*`。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令。
当前正式路由范围只保留尾程订单、入库单、库存/进销存、换标单和托盘;箱号、产品库不删除源码,但不属于本期可达页面。
当前正式路由只保留尾程订单、入库单、换标单、托盘管理和库存管理;箱号和产品库本期暂缓,不恢复正式路由。
@@ -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)
}
@@ -1,10 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('inventory overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('queries SKU balances with explicit customer scope', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }); await listInventoryBalances({ customerId: '9', page: 1, size: 20, skuCode: 'SKU-1', productName: '产品' }); expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/balances', { params: { customerId: '9', page: 1, size: 20, skuCode: 'SKU-1', productName: '产品' } }) })
it('uses read-only summary, positions and ledger endpoints', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: {} }); await getInventorySummary({ customerId: '9', skuCode: 'SKU-1' }); await listInventoryPositions('190000000000000099', '9'); await listInventoryLedger({ customerId: '9', page: 1, size: 20 }); expect(vi.mocked(overseasHttp.get).mock.calls.map((call) => call[0])).toEqual(['/platform/v1/last-leg/inventory/summary', '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', '/platform/v1/last-leg/inventory/ledger']) })
it('does not expose stock-leaving commands', () => { const source = `${listInventoryBalances}${getInventorySummary}${listInventoryPositions}${listInventoryLedger}`; expect(source).not.toMatch(/allocate|unallocate|pick|ship/) })
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' } })
})
it('uses the read-only position and ledger endpoints with explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [] })
await listInventoryPositions('190000000000000099', '100')
await listInventoryLedger({ customerId: '100', page: 1, size: 20, skuCode: 'SKU-1' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: { customerId: '100' } })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { customerId: '100', page: 1, size: 20, skuCode: 'SKU-1' } })
})
})
+147 -13
View File
@@ -2,6 +2,96 @@ 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 InventoryPositionDto {
id: string
balanceId: string
customerId: string
overseasWarehouseId?: string | null
destinationWarehouseCode?: string | null
inboundOrderId?: string | null
inboundOrderNo?: string | null
inboundCode?: string | null
palletId?: string | null
palletNo?: string | null
batchNo?: string | null
warehouseLocationId?: string | null
warehouseLocationCode?: string | null
totalPackages: number
availablePackages: number
holdPackages: number
allocatedPackages: number
pickedPackages: number
receivingPackages: number
status?: string | null
updatedAt?: string | null
}
export interface InventoryLedgerDto {
id: string
customerId: string
overseasWarehouseId?: string | null
destinationWarehouseCode?: string | null
inboundOrderId?: string | null
inboundOrderNo?: string | null
inboundCode?: string | null
palletNo?: string | null
batchNo?: string | null
warehouseLocationCode?: string | null
skuCode?: string | null
businessType?: string | null
businessOrderNo?: string | null
outboundOrderNo?: string | null
statusFrom?: string | null
statusTo?: string | null
beforePackages: number
deltaPackages: number
afterPackages: number
operatorId?: string | null
remark?: string | null
occurredAt?: string | null
}
export interface InventoryBalanceQuery {
customerId: string
page: number
@@ -15,19 +105,63 @@ export interface InventoryBalanceQuery {
businessOrderNo?: string
skuCode?: string
productName?: string
batchNo?: string
warehouseLocationCode?: string
inventoryStatus?: string
}
export interface InventoryBalanceDto {
id: string; customerId: string; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseCode?: string | null; overseasWarehouseName?: string | null
destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; inboundOrderId?: string | null; inboundOrderNo?: string | null; inboundCode?: string | null
skuCode?: string | null; productNameZh?: string | null; productNameEn?: string | null; 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 InventoryPositionDto { id: string; balanceId: string; customerId: string; overseasWarehouseId?: string | null; destinationWarehouseCode?: string | null; inboundOrderId?: string | null; inboundOrderNo?: string | null; inboundCode?: string | null; palletId?: string | null; palletNo?: string | null; batchNo?: string | null; warehouseLocationId?: string | null; warehouseLocationCode?: string | null; totalPackages: number; availablePackages: number; holdPackages: number; allocatedPackages: number; pickedPackages: number; receivingPackages: number; status?: string | null; updatedAt?: string | null }
export interface InventoryLedgerDto { id: string; customerId: string; overseasWarehouseId?: string | null; destinationWarehouseCode?: string | null; inboundOrderId?: string | null; inboundOrderNo?: string | null; inboundCode?: string | null; palletNo?: string | null; batchNo?: string | null; warehouseLocationCode?: string | null; businessType?: string | null; businessOrderNo?: string | null; outboundOrderNo?: string | null; statusFrom?: string | null; statusTo?: string | null; beforePackages: number; deltaPackages: number; afterPackages: number; operatorId?: string | null; remark?: string | null; occurredAt?: string | null }
export async function listInventoryBalances(query: InventoryBalanceQuery) { const { customerId, ...params } = query; return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>, ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>>('/platform/v1/last-leg/inventory/balances', { params: { customerId, ...params } })) }
export async function getInventorySummary(query: Omit<InventoryBalanceQuery, 'page' | 'size'>) { const { customerId, ...params } = query; return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>('/platform/v1/last-leg/inventory/summary', { params: { customerId, ...params } })) }
export async function listInventoryPositions(balanceId: string, customerId: string, query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {}) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InventoryPositionDto[]>, ApiEnvelope<InventoryPositionDto[]>>(`/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { params: { customerId, ...query } })) }
export async function listInventoryLedger(query: { customerId: string; page: number; size: number; overseasWarehouseId?: string; destinationWarehouseCode?: string; businessType?: string; businessOrderNo?: string; inboundOrderNo?: string; inboundCode?: string; outboundOrderNo?: string; palletNo?: string; batchNo?: string; occurredFrom?: string; occurredTo?: string }) { const { customerId, ...params } = query; return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>, ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>>('/platform/v1/last-leg/inventory/ledger', { params: { customerId, ...params } })) }
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)
}
export async function listInventoryPositions(
balanceId: string,
customerId: string,
query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {},
) {
const response = await overseasHttp.get<ApiEnvelope<InventoryPositionDto[]>, ApiEnvelope<InventoryPositionDto[]>>(
`/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`,
{ params: { customerId, ...query } },
)
return unwrapApiResponse(response)
}
export interface InventoryLedgerQuery {
customerId: string
page: number
size: number
overseasWarehouseId?: string
destinationWarehouseCode?: string
businessType?: string
businessOrderNo?: string
inboundOrderNo?: string
inboundCode?: string
outboundOrderNo?: string
palletNo?: string
batchNo?: string
skuCode?: string
warehouseLocationCode?: string
occurredFrom?: string
occurredTo?: string
}
export async function listInventoryLedger(query: InventoryLedgerQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>, ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>>(
'/platform/v1/last-leg/inventory/ledger',
{ params: { ...query } },
)
return unwrapApiResponse(response)
}
@@ -1,9 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createPallet, getPallet, listPallets, updatePallet } from './pallets'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('public overseas pallet API', () => {
beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset(); vi.mocked(overseasHttp.put).mockReset() })
it('does not send a customer isolation parameter for public pallet reads', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }); await listPallets('190000000000000001', { page: 1, size: 20, status: 'IDLE' }); expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/190000000000000001/pallets', { params: { page: 1, size: 20, status: 'IDLE' } }); expect(JSON.stringify(vi.mocked(overseasHttp.get).mock.calls)).not.toContain('customerId') })
it('uses the public resource path for detail, create and update', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: {} }); vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'ok', data: '8' }); vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'ok', data: null }); const payload = { palletType: '木托盘', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }; await getPallet('1', '2'); await createPallet('1', payload); await updatePallet('1', '2', payload); expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/1/pallets/2'); expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/1/pallets', payload); expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/1/pallets/2', payload) })
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' } })
})
})
+80 -8
View File
@@ -2,11 +2,83 @@ import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
export interface OverseasWarehouseDto { id: string; customerId: string; warehouseCode: string; warehouseName: string; warehouseType?: string | null; countryCode?: string | null; status?: string | null }
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 PalletSave { palletCode?: string; palletType: string; capacityCbm?: number; unitPrice: number; status: string; locationId?: string; remark?: string }
export async function listOverseasWarehouses(customerId: string, page: number, size = 200) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<OverseasWarehouseDto>>, ApiEnvelope<OverseasPageResult<OverseasWarehouseDto>>>('/platform/v1/warehouse-admin', { params: { customerId, page, size, status: 'ACTIVE' } })) }
export async function listPallets(warehouseId: string, query: { page: number; size: number; palletType?: string; status?: string; locationId?: string; keyword?: string }) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { params: query })) }
export async function getPallet(warehouseId: string, palletId: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PalletDto>, ApiEnvelope<PalletDto>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)) }
export async function createPallet(warehouseId: string, payload: PalletSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload)) }
export async function updatePallet(warehouseId: string, palletId: string, payload: PalletSave) { unwrapApiResponse(await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, payload)) }
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,85 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, 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 { listInboundPlans } from '../api/inboundPlans'
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 customers = ref<PlatformTenantResource[]>([])
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 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: '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: [{ 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: '创建时间' },
{ 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: '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: '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 },
{ 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 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) }
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),
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode),
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 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, '客户列表加载失败,请重试') }
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 }
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
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" :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>
<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>
<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>
@@ -8,9 +8,9 @@ import { listInventoryBalances, listInventoryLedger, listInventoryPositions, typ
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 productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: balance.value?.skuCodes?.length ? balance.value.skuCodes.join('、') : '—', 产品名称: balance.value?.productNames?.length ? balance.value.productNames.join('、') : '—', 更新时间: 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 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) })))
@@ -23,6 +23,6 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(
<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>
<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">按该库存记录的入库单范围展示,出库字段仅为只读事实。</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>
@@ -11,7 +11,7 @@ const route = useRoute(); const router = useRouter(); const rows = ref<Inventory
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: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入 SKU 编码', width: 170 },
{ 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 },
])
@@ -25,13 +25,13 @@ const columns: DataTableColumn[] = [
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 } }
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, skuCode: String(filters.value.sku || '').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: text(item.skuCode), 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" :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>
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供产品名称与海外仓名称,对应列显示空值。</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,47 +1,123 @@
<script setup lang="ts">
import { computed, onMounted, 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 { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
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 InventoryBalanceDto, type InventoryDashboardDto } from '../api/inventory'
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
import type { InventoryListItem } from '../types'
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 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 total = ref(0)
const filters = ref<QueryModel>({ customerId: '', warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
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' }] },
{ 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: [{ 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 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: '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: '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 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 } : {} }) }
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), warehouseId: String(dto.overseasWarehouseId || ''), inboundOrderNo: text(dto.inboundOrderNo) === '—' ? '' : String(dto.inboundOrderNo), inboundCode: text(dto.inboundCode) === '—' ? '' : String(dto.inboundCode), 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, overseasWarehouseId: row.warehouseId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku === '—' ? undefined : row.sku.split('、')[0] } }) }
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" :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>
<template>
<AdminPage><ListPageLayout aria-label="海外仓库存管理">
<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 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>共 {{ 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>
@@ -2,13 +2,46 @@
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, 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)
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: ['库位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="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>
<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,31 +1,155 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
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, 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'
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 { 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 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 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)
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 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: ['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: 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({ id: '', customerId: '', warehouseId: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' })
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" :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>
<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>
<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'" 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="仓库"><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>
</AppDialog>
</AdminPage>
</template>
@@ -126,10 +126,33 @@ describe('overseas warehouse first-batch prototype contract', () => {
const inventory = source('InventoryDetailView.vue')
expect(inventory).toContain('listInventoryPositions')
expect(inventory).toContain(':data="locationRows"')
expect(inventory).toContain('当前流水接口未返回 SKU')
expect(inventory).toContain('balance.value?.skuCodes')
expect(inventory).not.toContain('当前流水接口未返回 SKU')
expect(inventory).not.toContain('ElMessage.success')
})
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')
@@ -199,11 +222,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([
+23 -22
View File
@@ -1,26 +1,27 @@
export const overseasWarehousePermissions = {
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
lastMileOrderEdit: 'platform:overseas-warehouse:last-mile-order:edit',
lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export',
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
inventoryList: 'platform:overseas-warehouse:inventory:list',
inventoryExport: 'platform:overseas-warehouse:inventory:export',
inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust',
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
relabelOrderStatus: 'platform:overseas-warehouse:relabel-order:status',
locationList: 'platform:overseas-warehouse:location:list',
locationCreate: 'platform:overseas-warehouse:location:create',
locationEdit: 'platform:overseas-warehouse:location:edit',
locationDelete: 'platform:overseas-warehouse:location:delete',
warehouseList: 'platform:overseas-warehouse:warehouse:list',
palletList: 'platform:overseas-warehouse:pallet:list',
palletCreate: 'platform:overseas-warehouse:pallet:create',
palletEdit: 'platform:overseas-warehouse:pallet:edit',
palletDelete: 'platform:overseas-warehouse:pallet:delete',
lastMileOrderList: 'platform:lastleg:order:list',
lastMileOrderExport: 'platform:lastleg:order:export',
lastMileOrderTransfer: 'platform:lastleg:order:transfer',
inboundOrderList: 'platform:lastleg:inbound-plan:list',
inboundOrderCreate: 'platform:lastleg:inbound-plan:create',
inboundOrderEdit: 'platform:lastleg:inbound-plan:edit',
inboundOrderAppointment: 'platform:lastleg:inbound-plan:appointment',
inventoryList: 'platform:lastleg:inventory:list',
inventoryExport: 'platform:lastleg:inventory:export',
inventoryEdit: 'platform:lastleg:inventory:manage',
inventoryAdjust: 'platform:lastleg:inventory:adjust',
relabelOrderList: 'platform:lastleg:relabel:list',
relabelOrderCreate: 'platform:lastleg:relabel:create',
relabelOrderStatus: 'platform:lastleg:relabel:status',
locationList: 'platform:location:list',
locationCreate: 'platform:location:create',
locationEdit: 'platform:location:edit',
locationDelete: 'platform:location:delete',
warehouseList: 'platform:warehouse:list',
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',
+9 -3
View File
@@ -42,9 +42,10 @@ export interface LastMileOrderListItem {
}
export interface InboundOrderListItem {
id: string
lineId: string
planId: string
customerId: string
sourceOrderId: string
orderId: string
warehouseId: string
destinationWarehouseCode: string
orderNo: string
@@ -57,7 +58,10 @@ export interface InboundOrderListItem {
volume: number
pallets: number
status: string
appointmentNo: string
appointmentStatus: string
appointmentAt: string
expectedLoadingAt: string
dispatchStatus: string
}
@@ -71,10 +75,11 @@ export interface InventoryListItem {
warehouse: string
sku: string
productName: string
location: string
onHand: number
available: number
held: number
looseboard: number | null
looseboard: number | string | null
inTransit: number
purchase: number
status: string
@@ -98,6 +103,7 @@ export interface RelabelOrderListItem {
export interface PalletListItem {
id: string
customerId: string
warehouseId: string
locationId: string
code: string