This commit is contained in:
2026-08-21 11:20:09 +08:00
parent 595c998214
commit 6e9bb6e00c
45 changed files with 1051 additions and 329 deletions
+1
View File
@@ -10,3 +10,4 @@ services:
environment: environment:
CLIENT_WEB_URL: "http://8.163.49.32:9002" CLIENT_WEB_URL: "http://8.163.49.32:9002"
API_UPSTREAM: "http://host.docker.internal:9000" API_UPSTREAM: "http://host.docker.internal:9000"
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
+1
View File
@@ -16,3 +16,4 @@ services:
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或 # 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。 # 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
API_UPSTREAM: "http://host.docker.internal:9000" API_UPSTREAM: "http://host.docker.internal:9000"
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
+12
View File
@@ -30,6 +30,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
location /overseas-api/ {
client_max_body_size 12m;
rewrite ^/overseas-api/(.*)$ /api/$1 break;
proxy_pass ${OVERSEAS_API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# HTML must be revalidated on every visit so a new deployment cannot keep # HTML must be revalidated on every visit so a new deployment cannot keep
# referencing hashed chunks that were removed with the previous image. # referencing hashed chunks that were removed with the previous image.
location = /index.html { location = /index.html {
+6 -5
View File
@@ -81,11 +81,12 @@ describe('admin router access metadata', () => {
it('registers the confirmed phase-four routes with stable list permissions', () => { it('registers the confirmed phase-four routes with stable list permissions', () => {
const expected = [ const expected = [
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:lastleg:order:list'], ['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:lastleg:inbound-plan:list'], ['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:overseas-warehouse:inbound-order:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:lastleg:relabel:list'], ['overseas-dispatches', '/overseas-warehouse/dispatches', 'platform:overseas-warehouse:dispatch:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:pallet:list'], ['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:lastleg:inventory:list'], ['overseas-pallets', '/overseas-warehouse/pallets', 'platform:overseas-warehouse:pallet:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:overseas-warehouse:inventory:list'],
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase: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-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
['overseas-business-looseboards', '/overseas-business/looseboards', 'platform:overseas-business:looseboard:list'], ['overseas-business-looseboards', '/overseas-business/looseboards', 'platform:overseas-business:looseboard:list'],
+8 -5
View File
@@ -50,6 +50,7 @@ describe('adminNavigation', () => {
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单', '尾程订单',
'入库单', '入库单',
'排车列表',
'换标单', '换标单',
'托盘管理', '托盘管理',
'库存管理', '库存管理',
@@ -137,11 +138,12 @@ describe('adminNavigation', () => {
it('shows only the confirmed phase-four pages with their list permissions', () => { it('shows only the confirmed phase-four pages with their list permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([ const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:lastleg:order:list', 'platform:overseas-warehouse:last-mile-order:list',
'platform:lastleg:inbound-plan:list', 'platform:overseas-warehouse:inbound-order:list',
'platform:lastleg:relabel:list', 'platform:overseas-warehouse:dispatch:list',
'platform:pallet:list', 'platform:overseas-warehouse:relabel-order:list',
'platform:lastleg:inventory:list', 'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:inventory:list',
'platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:list',
'platform:overseas-business:truck:list', 'platform:overseas-business:truck:list',
'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:list',
@@ -154,6 +156,7 @@ describe('adminNavigation', () => {
expect(paths).toEqual(expect.arrayContaining([ expect(paths).toEqual(expect.arrayContaining([
'/overseas-warehouse/last-mile-orders', '/overseas-warehouse/last-mile-orders',
'/overseas-warehouse/inbound-orders', '/overseas-warehouse/inbound-orders',
'/overseas-warehouse/dispatches',
'/overseas-warehouse/relabel-orders', '/overseas-warehouse/relabel-orders',
'/overseas-warehouse/pallets', '/overseas-warehouse/pallets',
'/overseas-warehouse/inventory', '/overseas-warehouse/inventory',
+6 -5
View File
@@ -120,11 +120,12 @@ export const adminNavigation: AdminNavigationNode[] = [
title: '海外仓', title: '海外仓',
icon: PrototypeOverseasWarehouseIcon, icon: PrototypeOverseasWarehouseIcon,
children: [ children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:lastleg:order:list' }, { 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:lastleg:inbound-plan: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:lastleg:relabel:list' }, { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:pallet:list' }, { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:lastleg:inventory: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' },
], ],
}, },
{ {
+13
View File
@@ -3,6 +3,8 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8') const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
describe('admin nginx deployment config', () => { describe('admin nginx deployment config', () => {
it('allows qualification uploads with multipart overhead through the API proxy', () => { it('allows qualification uploads with multipart overhead through the API proxy', () => {
@@ -24,4 +26,15 @@ describe('admin nginx deployment config', () => {
expect(config).toContain('try_files $uri $uri/ /index.html;') expect(config).toContain('try_files $uri $uri/ /index.html;')
expect(config).toContain('Cache-Control "no-cache"') expect(config).toContain('Cache-Control "no-cache"')
}) })
it('proxies overseas API requests to the gateway API prefix', () => {
expect(config).toContain('location /overseas-api/')
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
})
}) })
@@ -15,6 +15,6 @@
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。 状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
菜单、路由与操作按钮使用真实 Controller 权限:`platform:lastleg:*`、`platform:pallet:*`、`platform:warehouse:*` 和 `platform:location:*`。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令。 菜单、路由与操作按钮使用真实 Controller 权限:`platform:overseas-warehouse:*`(尾程、入库、换标、库存、仓库、库位和托盘资源)。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令。
当前正式路由只保留尾程订单、入库单、换标单、托盘管理和库存管理;箱号和产品库本期暂缓,不恢复正式路由。 当前正式路由只保留尾程订单、入库单、换标单、托盘管理和库存管理;箱号和产品库本期暂缓,不恢复正式路由。
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { listDispatchCandidates, listDispatches, preArrangeDispatch } from './dispatches'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
describe('dispatch overseas API', () => {
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
})
it('uses the platform dispatch page without customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 },
})
await listDispatches({
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
})
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', {
params: {
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
},
})
})
it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
await listDispatchCandidates({ fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'], route: 'LAX -> ONT8' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'], route: 'LAX -> ONT8' })
})
})
@@ -0,0 +1,97 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
export interface DispatchQuery {
page: number
size: number
dispatchNo?: string
vehiclePlate?: string
status?: string
fbaAppointmentNo?: string
}
export interface DispatchPalletCandidateDto {
planId: string
planPalletId: string
warehousePalletId?: string | null
orderId: string
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
orderRefs?: string | null
inboundCode?: string | null
palletNo?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
volumeCbm?: number | null
sourceStage?: 'PLAN' | 'RECEIVED' | null
}
export interface DispatchCandidateQuery {
orderNo?: string
fbaWarehouseCode?: string
sourceStage?: 'PLAN' | 'RECEIVED'
}
export interface PreArrangeDispatchPayload {
planPalletIds: string[]
route?: string
fbaAppointmentNo?: string
fbaAppointmentStatus?: string
vehicleAppointmentStatus?: string
}
export interface DispatchSummaryDto {
id: string
dispatchNo: string
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
orderNos?: string | null
route?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
status?: string | null
itemCount?: number | null
palletCount?: number | null
packageCount?: number | null
pickedPackageCount?: number | null
shippedPackageCount?: number | null
remainingPackageCount?: number | null
partialCompletionReason?: string | null
createdAt?: string | null
}
export async function listDispatches(query: DispatchQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<DispatchSummaryDto>>, ApiEnvelope<OverseasPageResult<DispatchSummaryDto>>>(
'/platform/v1/last-leg/dispatches',
{ params: query },
)
return unwrapApiResponse(response)
}
export async function listDispatchCandidates(query: DispatchCandidateQuery) {
const response = await overseasHttp.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>(
'/platform/v1/last-leg/dispatches/candidates',
{ params: query },
)
return unwrapApiResponse(response)
}
export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
const response = await overseasHttp.post<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
'/platform/v1/last-leg/dispatches/pre-arrange',
payload,
)
return unwrapApiResponse(response)
}
@@ -23,11 +23,11 @@ describe('inbound order overseas API', () => {
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ 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 () => { it('loads the paginated list across all customers without a customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) 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' }) await listInboundOrders({ page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', {
params: { customerId: '190000000000000001', page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' }, params: { page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' },
}) })
}) })
@@ -3,7 +3,6 @@ import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders' import type { OverseasPageResult } from './lastMileOrders'
export interface InboundOrderQuery { export interface InboundOrderQuery {
customerId: string
page: number page: number
size: number size: number
planNo?: string planNo?: string
@@ -68,9 +67,8 @@ export interface FbaAppointmentDto {
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'> export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
export async function listInboundOrders(query: InboundOrderQuery) { export async function listInboundOrders(query: InboundOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>>( const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>>(
'/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }, '/platform/v1/last-leg/inbound-plans', { params: query },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -9,22 +9,22 @@ describe('overseas inventory API', () => {
it('keeps skuCode and nullable location filters on real balance queries', async () => { 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 } }) 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' } const query = { page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' }
await listInventoryBalances(query) await listInventoryBalances(query)
await getInventorySummary(query) await getInventorySummary(query)
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances', { params: 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' } }) expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/summary', { params: { skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } })
}) })
it('uses the read-only position and ledger endpoints with explicit customer scope', async () => { it('uses the read-only position and ledger endpoints without customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [] }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [] })
await listInventoryPositions('190000000000000099', '100') await listInventoryPositions('190000000000000099')
await listInventoryLedger({ customerId: '100', page: 1, size: 20, skuCode: 'SKU-1' }) await listInventoryLedger({ 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(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: {} })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { customerId: '100', page: 1, size: 20, skuCode: 'SKU-1' } }) expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, skuCode: 'SKU-1' } })
}) })
}) })
@@ -15,10 +15,9 @@ export interface InventoryBalanceDto {
inboundOrderId: string inboundOrderId: string
inboundOrderNo?: string | null inboundOrderNo?: string | null
inboundCode: string inboundCode: string
skuCodes: string[] skuCode?: string | null
productNames: string[] productNameZh?: string | null
warehouseLocationIds: string[] productNameEn?: string | null
warehouseLocationCodes: string[]
totalPackages: number totalPackages: number
availablePackages: number availablePackages: number
holdPackages: number holdPackages: number
@@ -46,7 +45,6 @@ export interface InventoryDashboardDto {
export interface InventoryPositionDto { export interface InventoryPositionDto {
id: string id: string
balanceId: string balanceId: string
customerId: string
overseasWarehouseId?: string | null overseasWarehouseId?: string | null
destinationWarehouseCode?: string | null destinationWarehouseCode?: string | null
inboundOrderId?: string | null inboundOrderId?: string | null
@@ -93,7 +91,6 @@ export interface InventoryLedgerDto {
} }
export interface InventoryBalanceQuery { export interface InventoryBalanceQuery {
customerId: string
page: number page: number
size: number size: number
customerName?: string customerName?: string
@@ -129,18 +126,16 @@ export async function getInventorySummary(query: InventoryBalanceQuery) {
export async function listInventoryPositions( export async function listInventoryPositions(
balanceId: string, balanceId: string,
customerId: string,
query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {}, query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {},
) { ) {
const response = await overseasHttp.get<ApiEnvelope<InventoryPositionDto[]>, ApiEnvelope<InventoryPositionDto[]>>( const response = await overseasHttp.get<ApiEnvelope<InventoryPositionDto[]>, ApiEnvelope<InventoryPositionDto[]>>(
`/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`, `/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`,
{ params: { customerId, ...query } }, { params: query },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export interface InventoryLedgerQuery { export interface InventoryLedgerQuery {
customerId: string
page: number page: number
size: number size: number
overseasWarehouseId?: string overseasWarehouseId?: string
@@ -1,30 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { getLastMileOrder, listLastMileOrders } from './lastMileOrders' import { getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn() } }))
describe('last-mile order overseas API', () => { describe('last-mile order overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('uses the gateway-relative platform list endpoint and explicit customer scope', async () => { it('loads all platform orders without a customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
await expect(listLastMileOrders({ customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' })).resolves.toMatchObject({ page: 2 }) await expect(listLastMileOrders({ page: 2, size: 20, status: 'PENDING_ASSIGNMENT' })).resolves.toMatchObject({ page: 2 })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: { customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' }, params: { page: 2, size: 20, status: 'PENDING_ASSIGNMENT' },
}) })
}) })
it('preserves a long order id as a string in the detail path', async () => { it('preserves a long order id as a string in the detail path', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } })
await getLastMileOrder('190000000000000099', '190000000000000001') await getLastMileOrder('190000000000000099')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099', { expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099')
params: { customerId: '190000000000000001' },
})
}) })
it('does not call outbound or delivery endpoints', () => { it('does not call outbound or delivery endpoints', () => {
const source = `${listLastMileOrders}${getLastMileOrder}` const source = `${listLastMileOrders}${getLastMileOrder}`
expect(source).not.toMatch(/outbound|delivery|dispatch/) expect(source).not.toMatch(/outbound|delivery|dispatch/)
}) })
it('uses the real destination transfer endpoint and edit contract', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: 1 })
await transferLastMileOrders({
orderIds: ['190000000000000099'], warehouseId: 'WH-1', warehouseCode: 'ONT8', warehouseName: 'Ontario',
})
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] }))
})
}) })
@@ -10,24 +10,33 @@ export interface OverseasPageResult<T> {
} }
export interface LastMileOrderQuery { export interface LastMileOrderQuery {
customerId: string
page: number page: number
size: number size: number
orderNo?: string orderNo?: string
inboundCode?: string inboundCode?: string
containerNo?: string containerNo?: string
status?: string status?: string
sourceOrderNo?: string
transferWarehouseCode?: string
abnormal?: boolean
detained?: boolean
createdFrom?: string
createdTo?: string
} }
export interface LastMileOrderSummaryDto { export interface LastMileOrderSummaryDto {
id: string id: string
customerId: string
orderNo: string orderNo: string
orderType?: string | null orderType?: string | null
inboundCode?: string | null inboundCode?: string | null
sourceOrderNo?: string | null sourceOrderNo?: string | null
sourceInboundId?: string | null
customerName?: string | null customerName?: string | null
overseasWarehouseName?: string | null overseasWarehouseName?: string | null
transferWarehouseName?: string | null transferWarehouseName?: string | null
transferWarehouseId?: string | null
transferWarehouseCode?: string | null
fbaWarehouseCode?: string | null fbaWarehouseCode?: string | null
containerNo?: string | null containerNo?: string | null
containerStatus?: string | null containerStatus?: string | null
@@ -42,8 +51,9 @@ export interface LastMileOrderSummaryDto {
actualArrivalAt?: string | null actualArrivalAt?: string | null
estimatedWarehouseAt?: string | null estimatedWarehouseAt?: string | null
inboundOrderNo?: string | null inboundOrderNo?: string | null
outboundOrderNo?: string | null /** Legacy read-only association retained for compatibility; absent from current service DTO. */
deliveryOrderNo?: string | null deliveryOrderNo?: string | null
outboundOrderNo?: string | null
relabelOrderNo?: string | null relabelOrderNo?: string | null
relabelStatus?: string | null relabelStatus?: string | null
palletMergeFlag?: string | null palletMergeFlag?: string | null
@@ -81,11 +91,18 @@ export interface LastMileStatusHistoryDto {
id: string id: string
fromStatus?: string | null fromStatus?: string | null
toStatus?: string | null toStatus?: string | null
eventId?: string | null
remark?: string | null remark?: string | null
createdAt?: string | null createdAt?: string | null
} }
export interface TransferDestinationPayload {
orderIds: string[]
warehouseId: string
warehouseCode: string
warehouseName: string
remark?: string
}
export interface LastMileOrderDetailDto { export interface LastMileOrderDetailDto {
order: LastMileOrderDto order: LastMileOrderDto
cargo: LastMileCargoDto[] cargo: LastMileCargoDto[]
@@ -93,18 +110,23 @@ export interface LastMileOrderDetailDto {
} }
export async function listLastMileOrders(query: LastMileOrderQuery) { export async function listLastMileOrders(query: LastMileOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>( const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>(
'/platform/v1/last-leg/orders', '/platform/v1/last-leg/orders',
{ params: { customerId, ...params } }, { params: query },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function getLastMileOrder(orderId: string, customerId: string) { export async function getLastMileOrder(orderId: string) {
const response = await overseasHttp.get<ApiEnvelope<LastMileOrderDetailDto>, ApiEnvelope<LastMileOrderDetailDto>>( const response = await overseasHttp.get<ApiEnvelope<LastMileOrderDetailDto>, ApiEnvelope<LastMileOrderDetailDto>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`, `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`,
{ params: { customerId } }, )
return unwrapApiResponse(response)
}
export async function transferLastMileOrders(payload: TransferDestinationPayload) {
const response = await overseasHttp.put<ApiEnvelope<number>, ApiEnvelope<number>>(
'/platform/v1/last-leg/orders/destination', payload,
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -7,24 +7,26 @@ vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post
describe('overseas pallet API', () => { describe('overseas pallet API', () => {
beforeEach(() => { vi.clearAllMocks() }) beforeEach(() => { vi.clearAllMocks() })
it('uses the customer and warehouse scoped pallet CRUD contract', async () => { it('uses the public warehouse scoped pallet CRUD contract', async () => {
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 } const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' }) vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null }) vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null })
vi.mocked(overseasHttp.delete).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' } const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
await listCustomerWarehouses('100') await listCustomerWarehouses()
await listPallets('100', '200', { page: 1, size: 20, keyword: 'PLT' }) await listPallets('200', { page: 1, size: 20, keyword: 'PLT' })
await getPallet('100', '200', '9') await getPallet('200', '9')
await createPallet('100', '200', payload) await createPallet('200', payload)
await updatePallet('100', '200', '9', payload) await updatePallet('200', '9', payload)
await deletePallet('100', '200', '9') await deletePallet('200', '9')
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { customerId: '100', page: 1, size: 20, keyword: 'PLT' } }) expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload, { params: { customerId: '100' } }) expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/warehouse-admin/200/pallets/9', {})
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', payload, { params: { customerId: '100' } }) expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload)
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', { params: { customerId: '100' } }) expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', payload)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ONT8' }))
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', {})
}) })
}) })
+14 -12
View File
@@ -18,6 +18,7 @@ export interface PalletDto {
locationId?: string | null locationId?: string | null
palletCode: string palletCode: string
palletType: string palletType: string
fbaWarehouseCode: string
capacityCbm: number capacityCbm: number
unitPrice: number unitPrice: number
status: string status: string
@@ -29,6 +30,7 @@ export interface PalletDto {
export interface SavePalletPayload { export interface SavePalletPayload {
palletCode: string palletCode: string
palletType: string palletType: string
fbaWarehouseCode: string
capacityCbm: number capacityCbm: number
unitPrice: number unitPrice: number
status: string status: string
@@ -36,49 +38,49 @@ export interface SavePalletPayload {
remark?: string | null remark?: string | null
} }
export async function listCustomerWarehouses(customerId: string) { export async function listCustomerWarehouses() {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>, ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>>( const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>, ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>>(
'/platform/v1/warehouse-admin', '/platform/v1/warehouse-admin',
{ params: { customerId, page: 1, size: 200, status: 'ACTIVE' } }, { params: { page: 1, size: 200, status: 'ACTIVE' } },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function listPallets(customerId: string, warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) { export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>( const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`,
{ params: { customerId, ...query } }, { params: { ...query } },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function getPallet(customerId: string, warehouseId: string, palletId: string) { export async function getPallet(warehouseId: string, palletId: string) {
const response = await overseasHttp.get<ApiEnvelope<PalletDto>, ApiEnvelope<PalletDto>>( const response = await overseasHttp.get<ApiEnvelope<PalletDto>, ApiEnvelope<PalletDto>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
{ params: { customerId } }, {},
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function createPallet(customerId: string, warehouseId: string, payload: SavePalletPayload) { export async function createPallet(warehouseId: string, payload: SavePalletPayload) {
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>( const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload, { params: { customerId } }, `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload,
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function updatePallet(customerId: string, warehouseId: string, palletId: string, payload: SavePalletPayload) { export async function updatePallet(warehouseId: string, palletId: string, payload: SavePalletPayload) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>( const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
payload, { params: { customerId } }, payload,
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function deletePallet(customerId: string, warehouseId: string, palletId: string) { export async function deletePallet(warehouseId: string, palletId: string) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>( const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
{ params: { customerId } }, {},
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -1,21 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { getRelabelOrder, listRelabelOrders } from './relabelOrders' import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders } from './relabelOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('relabel overseas API', () => { describe('relabel overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('uses explicit customer scope for the real page endpoint', async () => { it('uses the platform page endpoint without customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listRelabelOrders({ customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' }) await listRelabelOrders({ page: 1, size: 20, skuCode: 'SKU-1' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' } }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { page: 1, size: 20, skuCode: 'SKU-1' } })
}) })
it('encodes the relabel number and does not call a mutation endpoint', async () => { it('encodes the relabel number and does not call a mutation endpoint', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } })
await getRelabelOrder('RL/1', '190000000000000001') await getRelabelOrder('RL/1')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1', { params: { customerId: '190000000000000001' } }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1')
})
it('uses the real create and status endpoints', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: {} })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
await createRelabelOrder(payload)
await changeRelabelOrderStatus('RL-1', 'PROCESSING', 'ok')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL-1/status', { status: 'PROCESSING', remark: 'ok' })
}) })
}) })
@@ -3,13 +3,40 @@ import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders' import type { OverseasPageResult } from './lastMileOrders'
export interface RelabelOrderQuery { export interface RelabelOrderQuery {
customerId: string
page: number page: number
size: number size: number
skuCode?: string skuCode?: string
status?: string status?: string
} }
export interface CreateRelabelItemPayload {
skuCode: string
productNameZh: string
totalPackageCount: number
notifiedPackageCount: number
shipmentNo: string
newSkuCode: string
labelRequirement?: string
productImageFileId?: string
productImageFileName?: string
productImageUrl?: string
trackingCode?: string
relabelFileId?: string
relabelFileName?: string
relabelFileUrl?: string
}
export interface CreateRelabelPayload {
customerOrderNo: string
overseasWarehouseId: string
labelDeadline: string
contactName: string
contactPhone: string
remark?: string
idempotencyKey: string
items: CreateRelabelItemPayload[]
}
export interface RelabelSummaryDto { export interface RelabelSummaryDto {
id: string id: string
relabelNo: string relabelNo: string
@@ -56,16 +83,30 @@ export interface RelabelDetailDto {
} }
export async function listRelabelOrders(query: RelabelOrderQuery) { export async function listRelabelOrders(query: RelabelOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>( const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>(
'/platform/v1/last-leg/relabel-applications', { params: { customerId, ...params } }, '/platform/v1/last-leg/relabel-applications', { params: query },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function getRelabelOrder(relabelNo: string, customerId: string) { export async function getRelabelOrder(relabelNo: string) {
const response = await overseasHttp.get<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>( const response = await overseasHttp.get<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`, { params: { customerId } }, `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`,
)
return unwrapApiResponse(response)
}
export async function createRelabelOrder(payload: CreateRelabelPayload) {
const response = await overseasHttp.post<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
'/platform/v1/last-leg/relabel-applications', payload,
)
return unwrapApiResponse(response)
}
export async function changeRelabelOrderStatus(relabelNo: string, status: string, remark?: string) {
const response = await overseasHttp.put<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`,
{ status, remark },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { listDispatchCandidates, preArrangeDispatch, type DispatchPalletCandidateDto } from '../api/dispatches'
import type { InboundOrderListItem } from '../types'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const candidates = ref<DispatchPalletCandidateDto[]>([])
const selectedIds = ref<string[]>([])
const query = reactive({ orderNo: '', sourceStage: '' as '' | 'PLAN' | 'RECEIVED' })
const route = ref('')
const fbaWarehouseCode = computed(() => props.orders[0]?.fbaWarehouse || '')
const selected = computed(() => candidates.value.filter((item) => selectedIds.value.includes(String(item.planPalletId))))
const selectedPackages = computed(() => selected.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const selectedVolume = computed(() => selected.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function toggle(candidate: DispatchPalletCandidateDto, checked: boolean) {
const id = String(candidate.planPalletId)
selectedIds.value = checked ? [...new Set([...selectedIds.value, id])] : selectedIds.value.filter((value) => value !== id)
}
function remove(candidate: DispatchPalletCandidateDto) { toggle(candidate, false) }
async function load() {
loading.value = true; loadError.value = null
try {
candidates.value = await listDispatchCandidates({
orderNo: query.orderNo.trim() || undefined,
fbaWarehouseCode: fbaWarehouseCode.value || undefined,
sourceStage: query.sourceStage || undefined,
})
const validIds = new Set(candidates.value.map((item) => String(item.planPalletId)))
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id))
} catch (error) {
candidates.value = []
loadError.value = getApiErrorDetails(error, '可排车托盘加载失败,请重试')
} finally { loading.value = false }
}
function reset() { query.orderNo = ''; query.sourceStage = ''; void load() }
async function submit() {
if (!selectedIds.value.length) { ElMessage.warning('请至少选择一个托盘'); return }
saving.value = true; mutationError.value = null
try {
await preArrangeDispatch({
planPalletIds: selectedIds.value,
route: route.value.trim() || undefined,
fbaAppointmentNo: props.orders.find((item) => item.appointmentNo !== '—')?.appointmentNo,
})
emit('update:modelValue', false); emit('created'); ElMessage.success('预排车创建成功')
} catch (error) { mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试') }
finally { saving.value = false }
}
watch(() => props.modelValue, (open) => {
if (!open) return
selectedIds.value = []; query.orderNo = ''; query.sourceStage = ''; route.value = ''; mutationError.value = null
void load()
})
</script>
<template>
<OperationErrorToast :details="mutationError" />
<AppDialog :model-value="modelValue" title="智能排车" width="min(1600px, calc(100vw - 48px))" confirm-text="预排车" :confirm-loading="saving" :confirm-disabled="!selectedIds.length" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<section class="dispatch-query" aria-label="智能排车筛选">
<el-input v-model="query.orderNo" clearable placeholder="订单号" @keyup.enter="load" />
<el-select v-model="query.sourceStage" clearable placeholder="全部类型"><el-option label="计划托盘" value="PLAN" /><el-option label="已收货托盘" value="RECEIVED" /></el-select>
<el-input :model-value="fbaWarehouseCode" readonly placeholder="FBA仓库" />
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<LoadErrorState v-if="loadError" title="可排车托盘加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else class="dispatch-workbench">
<section class="candidate-pane" aria-label="可排车托盘列表">
<header><h3>可排车托盘 <span>({{ candidates.length }}个)</span></h3></header>
<el-table v-loading="loading" :data="candidates" border height="500" empty-text="暂无可排车托盘">
<el-table-column width="48"><template #default="{ row }"><el-checkbox :model-value="selectedIds.includes(String((row as DispatchPalletCandidateDto).planPalletId))" @change="toggle(row as DispatchPalletCandidateDto, Boolean($event))" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="150" />
<el-table-column prop="inboundCode" label="入仓码" min-width="130" />
<el-table-column prop="palletNo" label="托盘号" min-width="130" />
<el-table-column prop="fbaWarehouseCode" label="目的地FBA" width="115" />
<el-table-column prop="packageCount" label="箱数" width="80" align="right" />
<el-table-column prop="volumeCbm" label="体积(m³)" width="105" align="right" />
</el-table>
</section>
<aside class="plan-pane" aria-label="装车托盘">
<header><div><h3>装车托盘</h3><p>托盘 {{ selected.length }} · 箱数 {{ selectedPackages }} · 立方 {{ selectedVolume.toFixed(2) }}</p></div><el-button disabled><Plus :size="15" />添加装托盘</el-button></header>
<el-input v-model="route" maxlength="255" placeholder="排车路线(选填)" />
<div class="selected-list">
<div v-for="item in selected" :key="String(item.planPalletId)" class="selected-pallet">
<div><strong>{{ text(item.palletNo) }}</strong><span>{{ text(item.orderNo) }} · {{ text(item.fbaWarehouseCode) }}</span></div>
<div><span>{{ Number(item.packageCount || 0) }}箱 / {{ Number(item.volumeCbm || 0).toFixed(2) }}m³</span><el-button text type="danger" :aria-label="`移除托盘${text(item.palletNo)}`" @click="remove(item)"><Trash2 :size="15" /></el-button></div>
</div>
<el-empty v-if="!selected.length" :image-size="72" description="请选择左侧托盘" />
</div>
</aside>
</div>
<template #footer="{ close }"><div class="dialog-footer"><span>已选托盘会在预排车成功后锁定。</span><div><el-button :disabled="saving" @click="close">取消</el-button><el-button type="primary" :loading="saving" :disabled="!selectedIds.length" @click="submit">预排车</el-button></div></div></template>
</AppDialog>
</template>
<style scoped>
.dispatch-query { display: flex; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-bg); }
.dispatch-query :deep(.el-input), .dispatch-query :deep(.el-select) { width: 210px; }
.dispatch-workbench { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(420px, .95fr); gap: 20px; margin-top: 18px; }
.candidate-pane, .plan-pane { min-width: 0; }
.candidate-pane header, .plan-pane header { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 12px; }
h3, p { margin: 0; } h3 { font-size: 17px; } h3 span, p { color: var(--cc-muted); font-size: 13px; font-weight: 400; }
.plan-pane > .el-input { margin-bottom: 12px; }
.selected-list { min-height: 444px; max-height: 444px; overflow-y: auto; border: 1px dashed #cfd5df; border-radius: 6px; background: #fafbfc; }
.selected-pallet { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border); background: #fff; }
.selected-pallet > div { display: flex; min-width: 0; align-items: center; gap: 12px; } .selected-pallet > div:first-child { align-items: flex-start; flex-direction: column; gap: 4px; }
.selected-pallet span { color: var(--cc-muted); font-size: 13px; }
.dialog-footer { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 16px; } .dialog-footer > span { color: var(--cc-muted); font-size: 13px; }
@media (max-width: 1100px) { .dispatch-workbench { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { inventoryProductName, toInventoryListItem } from './inventoryPresentation'
describe('overseas inventory presentation adapter', () => {
it('maps the latest singular balance DTO fields without inventing position data', () => {
const row = toInventoryListItem({
id: '2089652067743272963',
customerId: '990018',
customerName: 'Acceptance Customer',
overseasWarehouseId: '990018000000000001',
overseasWarehouseName: 'Acceptance US Warehouse',
inboundOrderId: '2089652066950549505',
inboundOrderNo: 'LIP2089652067021852673',
inboundCode: 'ACCEPT-20260818-R4-IN-03B',
skuCode: 'SKU-ACCEPT-1',
productNameZh: '验收商品',
productNameEn: 'Acceptance Product',
totalPackages: 20,
availablePackages: 18,
holdPackages: 2,
allocatedPackages: 0,
pickedPackages: 0,
inTransitPackages: 0,
receivingPackages: 0,
procurementPackages: 0,
updatedAt: '2026-08-18T17:54:11',
})
expect(row).toMatchObject({
id: '2089652067743272963',
customerId: '990018',
sku: 'SKU-ACCEPT-1',
productName: '验收商品',
location: '—',
onHand: 20,
available: 18,
held: 2,
status: '存在暂扣',
})
})
it('falls back to the English product name only when the Chinese name is absent', () => {
expect(inventoryProductName({
id: '1', customerId: '2', overseasWarehouseId: '3', inboundOrderId: '4', inboundCode: 'IN-1',
productNameEn: 'English Product', totalPackages: 0, availablePackages: 0, holdPackages: 0,
allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0,
procurementPackages: 0,
})).toBe('English Product')
})
})
@@ -0,0 +1,45 @@
import type { InventoryBalanceDto } from './api/inventory'
import type { InventoryListItem } from './types'
function text(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || '—'
}
function count(value: unknown) {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : 0
}
export function inventoryProductName(dto: InventoryBalanceDto) {
return text(dto.productNameZh || dto.productNameEn)
}
export function inventoryStatusLabel(dto: InventoryBalanceDto) {
if (count(dto.holdPackages) > 0) return '存在暂扣'
if (count(dto.availablePackages) <= 0) return '无可用库存'
return '正常'
}
export function toInventoryListItem(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: text(dto.skuCode),
productName: inventoryProductName(dto),
location: '—',
onHand: count(dto.totalPackages),
available: count(dto.availablePackages),
held: count(dto.holdPackages),
looseboard: '—',
inTransit: count(dto.inTransitPackages),
purchase: count(dto.procurementPackages),
status: inventoryStatusLabel(dto),
updatedAt: text(dto.updatedAt),
}
}
@@ -4,9 +4,9 @@ import { toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => { describe('last-mile order presentation adapter', () => {
it('maps backend codes, associations and long ids without inventing data', () => { it('maps backend codes, associations and long ids without inventing data', () => {
const row = toLastMileOrderListItem({ const row = toLastMileOrderListItem({
id: '190000000000000099', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT', id: '190000000000000099', customerId: '190000000000000001', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT',
abnormal: true, detained: false, packageCount: 12, deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY', abnormal: true, detained: false, packageCount: 12, deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
}, '190000000000000001') })
expect(row).toMatchObject({ expect(row).toMatchObject({
id: '190000000000000099', customerId: '190000000000000001', orderType: '一件代发', status: '待分配', id: '190000000000000099', customerId: '190000000000000001', orderType: '一件代发', status: '待分配',
warning: '异常', abnormal: '是', held: '否', cartons: 12, dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY', warning: '异常', abnormal: '是', held: '否', cartons: 12, dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
@@ -48,10 +48,10 @@ export function formatOverseasDateTime(value: unknown) {
return Number.isNaN(date.getTime()) ? raw : date.toLocaleString('zh-CN', { hour12: false }) return Number.isNaN(date.getTime()) ? raw : date.toLocaleString('zh-CN', { hour12: false })
} }
export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto, customerId: string): LastMileOrderListItem { export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileOrderListItem {
return { return {
id: String(dto.id), id: String(dto.id),
customerId, customerId: String(dto.customerId),
orderNo: text(dto.orderNo), orderNo: text(dto.orderNo),
warning: dto.abnormal ? '异常' : '—', warning: dto.abnormal ? '异常' : '—',
orderType: lastMileOrderTypeLabel(dto.orderType), orderType: lastMileOrderTypeLabel(dto.orderType),
@@ -0,0 +1,84 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listDispatches } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue'
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function memoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('DispatchesView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listDispatches).mockReset().mockResolvedValue({
records: [{
id: '190000000000000099', dispatchNo: 'PC-1', vehiclePlate: 'CA-100', driverName: '张师傅',
driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'LAX -> ONT8',
overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00',
status: 'PENDING_DEPARTURE', palletCount: 12, packageCount: 80, createdAt: '2026-08-19T16:00:00',
}],
page: 1, size: 20, total: 1, pages: 1,
})
})
it('loads immediately and sends only supported page filters', async () => {
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listDispatches).toHaveBeenCalledWith({ page: 1, size: 20, dispatchNo: undefined, vehiclePlate: undefined, status: undefined, fbaAppointmentNo: undefined })
const model: QueryModel = {
dispatchNo: 'PC-1', vehiclePlate: 'CA-100',
status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
}
wrapper.findComponent(QueryToolbar).vm.$emit('search', model)
await flushPromises()
expect(listDispatches).toHaveBeenCalledWith({
page: 1, size: 20, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
})
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('待发车')
expect(wrapper.text()).toContain('FBA-1 · CONFIRMED')
})
it('keeps long dispatch identifiers as strings', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce({
records: [{ id: '190000000000000099', dispatchNo: '190000000000000099' }],
page: 1, size: 20, total: 1, pages: 1,
})
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.html()).toContain('190000000000000099')
})
it('documents the backend feature flag required by a missing endpoint', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('请确认海外尾程排车接口已发布')
})
})
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
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 StatusBadge from '@/shared/components/StatusBadge.vue'
import { listDispatches, type DispatchSummaryDto } from '../api/dispatches'
import type { DispatchListItem } from '../types'
const rows = ref<DispatchListItem[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' })
const statusOptions = [
{ label: '待发车', value: 'PENDING_DEPARTURE' },
{ label: '拣货中', value: 'PICKING' },
{ label: '待发车(拣货完成)', value: 'READY_TO_DEPART' },
{ label: '待发车(部分完成)', value: 'PARTIAL_READY_TO_DEPART' },
{ label: '已发车', value: 'DEPARTED' },
{ label: '已发车(部分完成)', value: 'PARTIAL_DEPARTED' },
{ label: '已取消', value: 'CANCELLED' },
]
const fields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号' },
{ key: 'status', type: 'select', label: '车辆状态', placeholder: '全部状态', options: statusOptions },
{ key: 'fbaAppointmentNo', type: 'input', label: 'FBA预约号', placeholder: '请输入FBA预约号' },
])
const columns: DataTableColumn[] = [
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
{ key: 'carrierName', prop: 'carrierName', label: '托车公司名称', minWidth: 160 },
{ key: 'orderNos', prop: 'orderNos', label: '关联订单', minWidth: 220 },
{ key: 'route', prop: 'route', label: '路线', minWidth: 180 },
{ key: 'overseasWarehouse', prop: 'overseasWarehouse', label: '海外仓', minWidth: 160 },
{ key: 'departAt', prop: 'departAt', label: '发车时间', minWidth: 170 },
{ key: 'fbaAppointment', prop: 'fbaAppointment', label: 'FBA预约', minWidth: 180 },
{ key: 'vehicleAppointment', prop: 'vehicleAppointment', label: '车辆预约', minWidth: 130 },
{ key: 'estimatedArrivalAt', prop: 'estimatedArrivalAt', label: '预计到达时间', minWidth: 170 },
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 90, align: 'right' },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 80, align: 'right' },
{ key: 'status', prop: 'status', label: '车辆状态', width: 150, slotName: 'status' },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
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 statusLabel(value: unknown) {
const status = String(value || '')
return statusOptions.find((option) => option.value === status)?.label || text(value)
}
function appointmentLabel(numberValue: unknown, statusValue: unknown) {
const appointmentNo = text(numberValue)
const appointmentStatus = text(statusValue)
if (appointmentNo === '—') return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : `${appointmentNo} · ${appointmentStatus}`
}
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
return {
id: String(dto.id),
dispatchNo: text(dto.dispatchNo),
vehiclePlate: text(dto.vehiclePlate),
driverName: text(dto.driverName),
driverPhone: text(dto.driverPhone),
carrierName: text(dto.carrierName),
orderNos: text(dto.orderNos),
route: text(dto.route),
overseasWarehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseId),
departAt: text(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: text(dto.vehicleAppointmentStatus),
estimatedArrivalAt: text(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount),
createdAt: text(dto.createdAt),
}
}
function dispatchError(error: unknown) {
const details = getApiErrorDetails(error, '排车列表加载失败,请重试')
return details.code === 'HTTP_404'
? { ...details, message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布' }
: details
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
try {
const result = await listDispatches({
page: page.value,
size: pageSize.value,
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
status: String(filters.value.status || '').trim() || undefined,
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
})
rows.value = result.records.map(toListItem)
total.value = result.total
} catch (error) {
rows.value = []
total.value = 0
loadError.value = dispatchError(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) }
onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓排车列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @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-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { CalendarCheck2, Eye, Pencil } from '@lucide/vue' import { CalendarCheck2, Eye, Pencil, Truck } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -14,6 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders' import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import type { InboundOrderListItem } from '../types' import type { InboundOrderListItem } from '../types'
@@ -22,30 +22,27 @@ const access = useAccessPreviewStore()
const router = useRouter() const router = useRouter()
const rows = ref<InboundOrderListItem[]>([]) const rows = ref<InboundOrderListItem[]>([])
const selectedRows = ref<InboundOrderListItem[]>([]) const selectedRows = ref<InboundOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const loading = ref(false) const loading = ref(false)
const customersLoading = ref(false)
const saving = ref(false) const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null) const mutationError = ref<ApiErrorDetails | null>(null)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] }) const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }, { key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
{ key: 'orderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号' }, { key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' }, { key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '入库中', value: 'IN_PROGRESS' }, { label: '已完成', value: 'COMPLETED' }] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '入库中', value: 'IN_PROGRESS' }, { label: '已完成', value: 'COMPLETED' }] }, { key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
{ key: 'warehouse', type: 'input', label: '海外仓', placeholder: '仓库编码或名称' }, { key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', placeholder: '请输入FBA仓库编码' }, { key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
{ 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: '排车状态', width: 140, placeholder: '全部排车状态', options: [{ label: '未排车', value: 'UNSCHEDULED' }, { label: '已排车', value: 'SCHEDULED' }] },
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '全部排车状态', options: [{ label: '未排车', value: 'UNSCHEDULED' }, { label: '已排车', value: 'SCHEDULED' }] }, { key: 'inboundDate', type: 'daterange', label: '入库时间', width: 260 },
{ key: 'inboundDate', type: 'daterange', label: '入库时间' },
]) ])
const appointmentOpen = ref(false) const appointmentOpen = ref(false)
const smartDispatchOpen = ref(false)
const appointmentLoading = ref(false) const appointmentLoading = ref(false)
const appointmentMode = ref<'create' | 'edit'>('create') const appointmentMode = ref<'create' | 'edit'>('create')
const editingAppointmentNo = ref('') const editingAppointmentNo = ref('')
@@ -59,7 +56,7 @@ function selectableRow(source: object) {
const row = source as InboundOrderListItem const row = source as InboundOrderListItem
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
const anchor = selectedRows.value[0] const anchor = selectedRows.value[0]
return !anchor || (anchor.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse) return !anchor || anchor.fbaWarehouse === row.fbaWarehouse
} }
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
@@ -115,6 +112,13 @@ function openAppointment() {
appointmentOpen.value = true appointmentOpen.value = true
} }
function openSmartDispatch() {
const error = !selectedRows.value.length ? '请至少选择 1 条入库单'
: new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1 ? '所选入库单必须属于同一 FBA 仓库' : ''
if (error) { ElMessage.warning(error); return }
smartDispatchOpen.value = true
}
async function openAppointmentEdit(source: object) { async function openAppointmentEdit(source: object) {
const row = source as InboundOrderListItem const row = source as InboundOrderListItem
if (!row.appointmentNo || row.appointmentNo === '—') return if (!row.appointmentNo || row.appointmentNo === '—') return
@@ -144,16 +148,6 @@ async function submitAppointment() {
finally { saving.value = false } finally { saving.value = false }
} }
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { customersLoading.value = false }
}
function dateRange() { function dateRange() {
const range = Array.isArray(filters.value.inboundDate) ? filters.value.inboundDate : [] 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 } 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 }
@@ -161,12 +155,10 @@ function dateRange() {
async function load(model: QueryModel = filters.value) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '').trim()
selectedRows.value = [] selectedRows.value = []
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { try {
const result = await listInboundOrders({ customerId, page: page.value, size: pageSize.value, planNo: String(filters.value.orderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, warehouse: String(filters.value.warehouse || '').trim() || undefined, fbaWarehouseCode: String(filters.value.fbaWarehouse || '').trim() || undefined, appointmentStatus: String(filters.value.appointmentStatus || '').trim() || undefined, dispatchStatus: String(filters.value.dispatchStatus || '').trim() || undefined, ...dateRange() }) const result = await listInboundOrders({ 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 rows.value = result.records.map(toListItem); total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '入库单加载失败,请重试') } } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '入库单加载失败,请重试') }
finally { loading.value = false } finally { loading.value = false }
@@ -175,20 +167,20 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) } function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(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 } }) } 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) onMounted(() => { void load() })
</script> </script>
<template> <template>
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表"> <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 #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> <template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="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()"> <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-inbound-orders" row-key="lineId" empty-text="暂无入库单" @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 #toolbar-actions><el-button v-if="access.can(permission.inboundOrderEdit)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderEdit)" type="primary" :disabled="!selectedRows.length" @click="openSmartDispatch"><Truck :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="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />编辑</el-button></template> <template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderEdit)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template> <template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="appointmentOpen" :title="appointmentTitle" width="min(720px, calc(100vw - 28px))" confirm-text="保存预约" :confirm-loading="saving" @confirm="submitAppointment"> <AppDialog v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" confirm-text="保存预约" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading"> <div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div> <div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<el-form label-position="top" class="appointment-form"> <el-form label-position="top" class="appointment-form">
@@ -200,6 +192,7 @@ onMounted(loadCustomers)
</el-form> </el-form>
</div> </div>
</AppDialog> </AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
</AdminPage> </AdminPage>
</template> </template>
@@ -3,12 +3,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue' import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { inventoryProductName } from '../inventoryPresentation'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue'
const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || '')); const customerId = computed(() => String(route.query.customerId || '')) const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || ''))
const balance = ref<InventoryBalanceDto | null>(null); const positions = ref<InventoryPositionDto[]>([]); const ledger = ref<InventoryLedgerDto[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null) 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() || '—' } function text(value: unknown) { return String(value ?? '').trim() || '—' }
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 productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: text(balance.value?.skuCode), 产品名称: balance.value ? inventoryProductName(balance.value) : '—', 更新时间: 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 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 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: '—' })))
@@ -17,7 +17,7 @@ const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.
const locationColumns: DataTableColumn[] = [{ key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'status', prop: 'status', label: '库存状态', width: 120 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }, { key: 'available', prop: 'available', label: '可用数量', width: 110, align: 'right' }, { key: 'inboundDate', prop: 'inboundDate', label: '最近更新', width: 160 }, { key: 'ageDays', prop: 'ageDays', label: '库龄(天)', width: 100, align: 'right' }] const locationColumns: DataTableColumn[] = [{ key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'status', prop: 'status', label: '库存状态', width: 120 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }, { key: 'available', prop: 'available', label: '可用数量', width: 110, align: 'right' }, { key: 'inboundDate', prop: 'inboundDate', label: '最近更新', width: 160 }, { key: 'ageDays', prop: 'ageDays', label: '库龄(天)', width: 100, align: 'right' }]
const palletColumns: DataTableColumn[] = [{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '关联箱数', width: 110, align: 'right' }, { key: 'cartonRange', prop: 'cartonRange', label: '箱号范围', minWidth: 200 }, { key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }] const palletColumns: DataTableColumn[] = [{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '关联箱数', width: 110, align: 'right' }, { key: 'cartonRange', prop: 'cartonRange', label: '箱号范围', minWidth: 200 }, { key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }]
const ledgerColumns: DataTableColumn[] = [{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 }, { key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 }, { key: 'location', prop: 'location', label: '库位', minWidth: 120 }, { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }] const ledgerColumns: DataTableColumn[] = [{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 }, { key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 }, { key: 'location', prop: 'location', label: '库位', minWidth: 120 }, { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
async function load() { if (!inventoryId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从库存列表重新进入' }; return }; loading.value = true; loadError.value = null; try { const inboundOrderNo = String(route.query.inboundOrderNo || ''); const inboundCode = String(route.query.inboundCode || ''); const skuCode = String(route.query.skuCode || ''); const overseasWarehouseId = String(route.query.overseasWarehouseId || ''); const result = await listInventoryBalances({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: inboundOrderNo || undefined, inboundCode: inboundCode || undefined, skuCode: skuCode || undefined, overseasWarehouseId: overseasWarehouseId || undefined }); balance.value = result.records.find((item) => String(item.id) === inventoryId.value) || null; if (!balance.value) throw new Error('Inventory balance not found'); const [positionRows, ledgerPage] = await Promise.all([listInventoryPositions(inventoryId.value, customerId.value), listInventoryLedger({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: balance.value.inboundOrderNo || undefined, inboundCode: balance.value.inboundCode || undefined })]); positions.value = positionRows; ledger.value = ledgerPage.records } catch (error) { loadError.value = getApiErrorDetails(error, '库存详情加载失败,请重试') } finally { loading.value = false } } async function load() { if (!inventoryId.value) { loadError.value = { message: '详情缺少库存记录编号,请从库存列表重新进入' }; return }; loading.value = true; loadError.value = null; try { const inboundOrderNo = String(route.query.inboundOrderNo || ''); const inboundCode = String(route.query.inboundCode || ''); const skuCode = String(route.query.skuCode || ''); const overseasWarehouseId = String(route.query.overseasWarehouseId || ''); const result = await listInventoryBalances({ page: 1, size: 200, inboundOrderNo: inboundOrderNo || undefined, inboundCode: inboundCode || undefined, skuCode: skuCode || undefined, overseasWarehouseId: overseasWarehouseId || undefined }); balance.value = result.records.find((item) => String(item.id) === inventoryId.value) || null; if (!balance.value) throw new Error('Inventory balance not found'); const [positionRows, ledgerPage] = await Promise.all([listInventoryPositions(inventoryId.value), listInventoryLedger({ page: 1, size: 200, inboundOrderNo: balance.value.inboundOrderNo || undefined, inboundCode: balance.value.inboundCode || undefined })]); positions.value = positionRows; ledger.value = ledgerPage.records } catch (error) { loadError.value = getApiErrorDetails(error, '库存详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load) function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
</script> </script>
@@ -2,15 +2,14 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ArrowLeft } from '@lucide/vue' import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' 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 AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types' import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const customersLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const customersError = ref<ApiErrorDetails | null>(null) const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ occurredDate: [], customerId: String(route.query.customerId || ''), warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' }) const filters = ref<QueryModel>({ occurredDate: [], warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields = computed<QueryField[]>(() => [ 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: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
{ key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入 SKU 编码', width: 170 }, { 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: '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 }, { key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
@@ -23,15 +22,13 @@ const columns: DataTableColumn[] = [
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
] ]
function text(value: unknown) { return String(value ?? '').trim() || '—' } function text(value: unknown) { return String(value ?? '').trim() || '—' }
function customerName(id: string) { return customers.value.find((item) => String(item.id) === id)?.tenantName || '—' } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ 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: text(item.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 } }
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 } } 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(load)
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> </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><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-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供产品名称与海外仓名称,对应列显示空值。</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> <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-transactions" row-key="id" empty-text="暂无进销存记录" @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> </ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style> <style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { BookOpen, Eye } from '@lucide/vue' import { BookOpen, Eye } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -10,12 +10,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, listInventoryBalances, type InventoryBalanceDto, type InventoryDashboardDto } from '../api/inventory' import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets' import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
import { overseasWarehousePermissions } from '../permissions'
import type { InventoryListItem } from '../types' import type { InventoryListItem } from '../types'
const router = useRouter() const router = useRouter()
const customers = ref<PlatformTenantResource[]>([]) const access = useAccessPreviewStore()
const warehouses = ref<CustomerWarehouseDto[]>([]) const warehouses = ref<CustomerWarehouseDto[]>([])
const rows = ref<InventoryListItem[]>([]) const rows = ref<InventoryListItem[]>([])
const summary = ref<InventoryDashboardDto | null>(null) const summary = ref<InventoryDashboardDto | null>(null)
@@ -25,10 +27,9 @@ const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' }) const filters = ref<QueryModel>({ warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 230, placeholder: '请选择客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, clearable: true } }, ...(access.can(overseasWarehousePermissions.warehouseList) ? [{ key: 'warehouseId', type: 'select' as const, label: '海外仓', width: 230, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } }] : []),
{ 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: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' },
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' }, { key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
@@ -57,44 +58,29 @@ const columns: DataTableColumn[] = [
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
] ]
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' } async function loadWarehouses() {
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 } if (!access.can(overseasWarehousePermissions.warehouseList)) return
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 contextLoading.value = true; loadError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records } try { warehouses.value = (await listCustomerWarehouses()).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, '海外仓列表加载失败,请重试') } catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false } finally { contextLoading.value = false }
} }
function query() { 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 } return { 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) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
if (!filters.value.customerId) { rows.value = []; total.value = 0; summary.value = null; return }
loading.value = true; loadError.value = null 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 } try { const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(query()), getInventorySummary(query())]); rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult }
catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') } catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
finally { loading.value = false } finally { loading.value = false }
} }
function search(model: QueryModel) { page.value = 1; void load(model) } 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] } }) } function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { 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(() => { void loadWarehouses(); void load() })
onMounted(loadCustomers)
</script> </script>
<template> <template>
@@ -102,7 +88,7 @@ onMounted(loadCustomers)
<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 #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 #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> <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()"> <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="暂无库存记录" @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 #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 #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 {{ total }} 条库存记录</template> <template #pagination-left>共 {{ total }} 条库存记录</template>
@@ -17,7 +17,6 @@ interface LogRow { id: string; occurredAt: string; node: string; operator: strin
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '')) const orderId = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<LastMileOrderDetailDto | null>(null) const detail = ref<LastMileOrderDetailDto | null>(null)
const loading = ref(false) const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
@@ -111,14 +110,18 @@ const logColumns: DataTableColumn[] = [
] ]
async function load() { async function load() {
if (!orderId.value || !customerId.value) { if (!orderId.value) {
loadError.value = { message: '详情缺少客户范围,请从尾程订单列表重新进入' } loadError.value = { message: '详情缺少尾程订单号' }
return return
} }
loading.value = true loading.value = true
loadError.value = null loadError.value = null
try { detail.value = await getLastMileOrder(orderId.value, customerId.value) } try { detail.value = await getLastMileOrder(orderId.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '尾程订单详情加载失败,请重试') } catch (error) {
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
? { message: error.message }
: getApiErrorDetails(error, '尾程订单详情加载失败,请重试')
}
finally { loading.value = false } finally { loading.value = false }
} }
function back() { void router.push({ name: 'overseas-last-mile-orders' }) } function back() { void router.push({ name: 'overseas-last-mile-orders' }) }
@@ -2,7 +2,6 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue' import { Eye } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -15,19 +14,15 @@ import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types' import type { LastMileOrderListItem } from '../types'
const rows = ref<LastMileOrderListItem[]>([]) const rows = ref<LastMileOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const loading = ref(false) const loading = ref(false)
const customersLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const router = useRouter() const router = useRouter()
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const statusTab = ref<'全部' | '待分配'>('全部') const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] }) const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' }, { key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '接口暂不支持', options: [], props: { disabled: true } }, { key: 'warehouse', type: 'select', label: '海外仓', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
@@ -55,7 +50,6 @@ const columns: DataTableColumn[] = [
{ key: 'transferWarehouse', prop: 'transferWarehouse', label: '转仓', minWidth: 140 }, { key: 'transferWarehouse', prop: 'transferWarehouse', label: '转仓', minWidth: 140 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 }, { key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
{ key: 'containerStatus', prop: 'containerStatus', label: '货柜状态', width: 110 },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 }, { key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 }, { key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' }, { key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
@@ -78,41 +72,12 @@ const columns: DataTableColumn[] = [
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 }, { key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
] ]
async function loadCustomers() {
customersLoading.value = true
customersError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试')
} finally {
customersLoading.value = false
}
}
async function load(model: QueryModel = filters.value) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '').trim()
if (!customerId) {
rows.value = []
total.value = 0
loadError.value = null
return
}
loading.value = true loading.value = true
loadError.value = null loadError.value = null
try { try {
const result = await listLastMileOrders({ const result = await listLastMileOrders({
customerId,
page: page.value, page: page.value,
size: pageSize.value, size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined, orderNo: String(filters.value.orderNo || '').trim() || undefined,
@@ -120,12 +85,14 @@ async function load(model: QueryModel = filters.value) {
containerNo: String(filters.value.containerNo || '').trim() || undefined, containerNo: String(filters.value.containerNo || '').trim() || undefined,
status: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined, status: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
}) })
rows.value = result.records.map((item) => toLastMileOrderListItem(item, customerId)) rows.value = result.records.map(toLastMileOrderListItem)
total.value = result.total total.value = result.total
} catch (error) { } catch (error) {
rows.value = [] rows.value = []
total.value = 0 total.value = 0
loadError.value = getApiErrorDetails(error, '尾程订单加载失败,请重试') loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
? { message: error.message }
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -137,10 +104,10 @@ function changeStatusTab() { page.value = 1; void load() }
function openDetail(source: object) { function openDetail(source: object) {
const row = source as LastMileOrderListItem const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id }, query: { customerId: row.customerId } }) void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
} }
onMounted(loadCustomers) onMounted(() => { void load() })
</script> </script>
<template> <template>
@@ -154,12 +121,12 @@ onMounted(loadCustomers)
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" /> <QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
</section> </section>
</template> </template>
<template v-if="customersError || loadError" #state> <template v-if="loadError" #state>
<LoadErrorState title="尾程订单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"> <LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </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-last-mile-orders" row-key="id" :empty-text="filters.customerId ? '暂无尾程订单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"> <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-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @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 #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 {{ total }} 条尾程订单</template> <template #pagination-left>共 {{ total }} 条尾程订单</template>
</DataTable></section> </DataTable></section>
@@ -10,7 +10,6 @@ import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const palletId = computed(() => String(route.params.palletId || '—')) const palletId = computed(() => String(route.params.palletId || '—'))
const customerId = computed(() => String(route.query.customerId || ''))
const warehouseId = computed(() => String(route.query.warehouseId || '')) const warehouseId = computed(() => String(route.query.warehouseId || ''))
const detail = ref<PalletDto | null>(null) const detail = ref<PalletDto | null>(null)
const warehouseName = ref('—') const warehouseName = ref('—')
@@ -22,15 +21,15 @@ const groups = [
] ]
const values = computed(() => ({ const values = computed(() => ({
托盘编码: detail.value?.palletCode || '—', 托盘类型: detail.value?.palletType || '—', 仓库: warehouseName.value, 托盘编码: detail.value?.palletCode || '—', 托盘类型: detail.value?.palletType || '—', 仓库: warehouseName.value,
'容积(m³)': detail.value?.capacityCbm ?? '—', 采购价格: detail.value?.unitPrice ?? '—', 所属FBA仓: '—', 状态: detail.value?.status || '—', '容积(m³)': detail.value?.capacityCbm ?? '—', 采购价格: detail.value?.unitPrice ?? '—', 所属FBA仓: detail.value?.fbaWarehouseCode || '—', 状态: detail.value?.status || '—',
库位ID: detail.value?.locationId || '—', 创建时间: detail.value?.createdAt || '—', 更新时间: detail.value?.updatedAt || '—', 备注: detail.value?.remark || '—', 库位ID: detail.value?.locationId || '—', 创建时间: detail.value?.createdAt || '—', 更新时间: detail.value?.updatedAt || '—', 备注: detail.value?.remark || '—',
})) }))
async function load() { async function load() {
if (!customerId.value || !warehouseId.value || palletId.value === '—') { loadError.value = getApiErrorDetails(new Error('托盘详情缺少客户或仓库参数')); return } if (!warehouseId.value || palletId.value === '—') { loadError.value = getApiErrorDetails(new Error('托盘详情缺少仓库参数')); return }
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { try {
const [pallet, warehouses] = await Promise.all([getPallet(customerId.value, warehouseId.value, palletId.value), listCustomerWarehouses(customerId.value)]) const [pallet, warehouses] = await Promise.all([getPallet(warehouseId.value, palletId.value), listCustomerWarehouses()])
detail.value = pallet detail.value = pallet
const warehouse = warehouses.records.find((item) => String(item.id) === warehouseId.value) const warehouse = warehouses.records.find((item) => String(item.id) === warehouseId.value)
warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : warehouseId.value warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : warehouseId.value
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus, Trash2 } from '@lucide/vue' import { Eye, Pencil, Plus, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -20,8 +20,8 @@ import type { PalletListItem } from '../types'
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const router = useRouter() const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<CustomerWarehouseDto[]>([]) const warehouses = ref<CustomerWarehouseDto[]>([])
const fbaWarehouses = ref<PlatformWarehouseResource[]>([])
const rows = ref<PalletListItem[]>([]) const rows = ref<PalletListItem[]>([])
const loading = ref(false) const loading = ref(false)
const contextLoading = ref(false) const contextLoading = ref(false)
@@ -32,10 +32,9 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', warehouseId: '', code: '', type: '', status: '' }) const filters = ref<QueryModel>({ warehouseId: '', code: '', type: '', status: '' })
const fields = computed<QueryField[]>(() => [ 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: contextLoading.value } },
{ 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: '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: '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' }] }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [{ label: '闲置', value: 'IDLE' }, { label: '占用', value: 'OCCUPIED' }, { label: '已报废', value: 'SCRAPPED' }] },
@@ -49,65 +48,61 @@ const columns: DataTableColumn[] = [
] ]
const dialogOpen = ref(false) const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create') const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ id: '', customerId: '', warehouseId: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }) const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' })
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' } 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 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 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 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 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) } } function fbaWarehouseName(code: string) { const warehouse = fbaWarehouses.value.find((item) => item.warehouseCode === code); return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : text(code) }
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: fbaWarehouseName(dto.fbaWarehouseCode), status: statusLabel(dto.status), remark: text(dto.remark) } }
async function loadCustomers() { async function loadWarehouses() {
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 warehouses.value = []; filters.value.warehouseId = ''; rows.value = []; total.value = 0
if (!customerId) return
contextLoading.value = true; loadError.value = null contextLoading.value = true; loadError.value = null
try { warehouses.value = (await listCustomerWarehouses(customerId)).records } try {
const [warehousePage, platformWarehouses] = await Promise.all([listCustomerWarehouses(), listAllPlatformWarehouses()])
warehouses.value = warehousePage.records
fbaWarehouses.value = platformWarehouses.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'FBA')
}
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false } finally { contextLoading.value = false }
} }
async function load(model: QueryModel = filters.value) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '')
const warehouseId = String(filters.value.warehouseId || '') const warehouseId = String(filters.value.warehouseId || '')
if (!customerId || !warehouseId) { rows.value = []; total.value = 0; return } if (!warehouseId) { rows.value = []; total.value = 0; return }
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { 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 }) const result = await listPallets(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 rows.value = result.records.map(toRow); total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请重试') } } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请重试') }
finally { loading.value = false } finally { loading.value = false }
} }
function openCreate() { function openCreate() {
if (!filters.value.customerId || !filters.value.warehouseId) { ElMessage.warning('请先选择客户和仓库'); return } if (!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 dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: String(filters.value.warehouseId), fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
} }
async function openEdit(source: object) { async function openEdit(source: object) {
const row = source as PalletListItem; saving.value = true; mutationError.value = null const row = source as PalletListItem; saving.value = true; mutationError.value = null
try { try {
const detail = await getPallet(row.customerId, row.warehouseId, row.id) const detail = await getPallet(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 dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, 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, '托盘详情加载失败,请重试') } } catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { saving.value = false } finally { saving.value = false }
} }
async function save() { async function save() {
if (!form.code.trim() || !form.type || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写托盘编码、类型、容积、采购价格和状态'); return } if (!form.code.trim() || !form.type || !form.fbaWarehouseCode || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写托盘编码、类型、容积、采购价格、所属 FBA 仓和状态'); return }
saving.value = true; mutationError.value = null 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 } const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
try { try {
if (dialogMode.value === 'edit') await updatePallet(form.customerId, form.warehouseId, form.id, payload) if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, payload)
else await createPallet(form.customerId, form.warehouseId, payload) else await createPallet(form.warehouseId, payload)
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load() dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') } } catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
finally { saving.value = false } finally { saving.value = false }
@@ -118,22 +113,21 @@ async function remove(source: object) {
try { await ElMessageBox.confirm(`确认删除托盘 ${row.code}?`, '删除托盘', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) } try { await ElMessageBox.confirm(`确认删除托盘 ${row.code}?`, '删除托盘', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) }
catch { return } catch { return }
deletingId.value = row.id; mutationError.value = null deletingId.value = row.id; mutationError.value = null
try { await deletePallet(row.customerId, row.warehouseId, row.id); ElMessage.success('托盘已删除'); await load() } try { await deletePallet(row.warehouseId, row.id); ElMessage.success('托盘已删除'); await load() }
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘删除失败,请重试') } catch (error) { mutationError.value = getApiErrorDetails(error, '托盘删除失败,请重试') }
finally { deletingId.value = '' } finally { deletingId.value = '' }
} }
function search(model: QueryModel) { page.value = 1; void load(model) } 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 } }) } function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
watch(() => String(filters.value.customerId || ''), (value, oldValue) => { if (value !== oldValue) void loadWarehouses(value) }) onMounted(loadWarehouses)
onMounted(loadCustomers)
</script> </script>
<template> <template>
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="托盘管理列表"> <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 #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> <template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="loadWarehouses"><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()"> <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.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 #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 #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> <template #pagination-left>共 {{ total }} 条托盘</template>
@@ -141,12 +135,12 @@ onMounted(loadCustomers)
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" @confirm="save"> <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-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-input v-model="form.code" 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="托盘类型" 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="容积(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="采购价格(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="仓库" required><el-select v-model="form.warehouseId" filterable :disabled="dialogMode === 'edit'"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :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 placeholder="当前托盘不绑定 FBA 仓" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></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="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-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> </el-row></el-form>
@@ -11,7 +11,6 @@ import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '')) const orderId = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<RelabelDetailDto | null>(null) const detail = ref<RelabelDetailDto | null>(null)
const loading = ref(false) const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
@@ -47,9 +46,9 @@ const fieldValues = computed<Record<string, string | number>>(() => {
} }
}) })
async function load() { async function load() {
if (!orderId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从换标单列表重新进入' }; return } if (!orderId.value) { loadError.value = { message: '详情缺少换标单号,请从换标单列表重新进入' }; return }
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { detail.value = await getRelabelOrder(orderId.value, customerId.value) } try { detail.value = await getRelabelOrder(orderId.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') } catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
finally { loading.value = false } finally { loading.value = false }
} }
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus, Upload } from '@lucide/vue' import { Eye, Pencil, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -11,25 +11,23 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { listRelabelOrders } from '../api/relabelOrders' import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders } from '../api/relabelOrders'
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import type { RelabelOrderListItem } from '../types' import type { RelabelOrderListItem } from '../types'
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const router = useRouter() const router = useRouter()
const rows = ref<RelabelOrderListItem[]>([]) const rows = ref<RelabelOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const loading = ref(false) const loading = ref(false)
const customersLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null) const warehouses = ref<CustomerWarehouseDto[]>([])
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', sku: '', status: '' }) const filters = ref<QueryModel>({ sku: '', status: '' })
const fields = computed<QueryField[]>(() => [ 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: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 }, { key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
]) ])
@@ -46,44 +44,57 @@ const columns: DataTableColumn[] = [
] ]
const dialogOpen = ref(false) const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create') const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: '待处理', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '' }) const form = reactive({ orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number } function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) { function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelOrderListItem | undefined const row = source as RelabelOrderListItem | undefined
dialogMode.value = mode dialogMode.value = mode
Object.assign(form, { orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: '待处理', labelRequirement: '', relabelCost: undefined, remark: '' }) Object.assign(form, { orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
if (mode === 'edit' && row) Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: String(row.status || '待处理'), labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') }) if (mode === 'edit' && row) Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: relabelStatusCode(row.status), labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
if (mode === 'create') void loadWarehouses()
dialogOpen.value = true dialogOpen.value = true
} }
async function loadWarehouses() {
try { warehouses.value = (await listCustomerWarehouses()).records }
catch (error) { ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message) }
}
async function submitForm() {
try {
if (dialogMode.value === 'create') {
await createRelabelOrder({ customerOrderNo: form.relatedOrderNo, overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName, contactPhone: form.contactPhone, remark: form.remark || undefined, idempotencyKey: `admin-${Date.now()}`, items: [{ skuCode: form.sku, productNameZh: form.productName, totalPackageCount: Number(form.cartons || 0), notifiedPackageCount: Number(form.cartons || 0), shipmentNo: form.shipmentNo, newSkuCode: form.newSkuCode || form.sku, labelRequirement: form.labelRequirement }] })
ElMessage.success('换标单已创建')
} else {
await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined)
ElMessage.success('状态已更新')
}
dialogOpen.value = false
await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
}
function openDetail(source: object) { function openDetail(source: object) {
const row = source as RelabelOrderListItem const row = source as RelabelOrderListItem
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) }, query: { customerId: row.customerId } }) void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
} }
function relabelStatus(value: unknown) { function relabelStatus(value: unknown) {
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
return labels[String(value || '')] || String(value || '—') return labels[String(value || '')] || String(value || '—')
} }
async function loadCustomers() { function relabelStatusCode(value: unknown) {
customersLoading.value = true; customersError.value = null const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 已完成: 'COMPLETED', 已驳回: 'REJECTED', 已取消: 'CANCELLED' }
try { return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
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 }
} }
async function load(model: QueryModel = filters.value) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
const customerId = String(filters.value.customerId || '').trim()
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { try {
const result = await listRelabelOrders({ customerId, page: page.value, size: pageSize.value, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }) const result = await listRelabelOrders({ page: page.value, size: pageSize.value, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({ rows.value = result.records.map((item) => ({
customerId, orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'), customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '', productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '',
})) }))
total.value = result.total total.value = result.total
@@ -92,35 +103,39 @@ async function load(model: QueryModel = filters.value) {
} }
function search(model: QueryModel) { page.value = 1; void load(model) } function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(loadCustomers) onMounted(load)
</script> </script>
<template> <template>
<AdminPage><ListPageLayout aria-label="换标单列表"> <AdminPage><ListPageLayout aria-label="换标单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" @search="search" @reset="reset" /></template> <template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-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> <template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" :empty-text="filters.customerId ? '暂无换标单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"> <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-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template> <template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #pagination-left>共 {{ total }} 条换标单</template> <template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" domain="换标单" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'"> <AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" @confirm="submitForm">
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16"> <el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col> <el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="item.warehouseName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" placeholder="请输入商品SKU" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="预算总价(USD)" required><el-input-number v-model="form.budgetTotal" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col> <el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态"><el-select v-model="form.status" :disabled="dialogMode === 'create'"><el-option v-for="value in ['待处理', '处理中', '已完成']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col> <el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="状态"><el-select v-model="form.status"><el-option v-for="value in ['PROCESSING', 'COMPLETED', 'REJECTED', 'CANCELLED']" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
</el-row><h3>作业要求</h3><el-row :gutter="16"> </el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><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="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><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="上传文件"><el-button disabled><Upload :size="16" />选择文件</el-button></el-form-item></el-col> <el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(USD)"><el-input-number v-model="form.relabelCost" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col> <el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(USD)"><el-input-number v-model="form.relabelCost" :min="0" :precision="2" controls-position="right" /></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" show-word-limit /></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" show-word-limit /></el-form-item></el-col>
</el-row></el-form> </el-row></el-form>
</ContractPendingFormDialog> </AppDialog>
</AdminPage> </AdminPage>
</template> </template>
@@ -18,6 +18,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
const expected = new Map([ const expected = new Map([
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'], ['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
['InboundOrdersView.vue', 'overseas-inbound-orders-query'], ['InboundOrdersView.vue', 'overseas-inbound-orders-query'],
['DispatchesView.vue', 'overseas-dispatches-query'],
['InventoryView.vue', 'overseas-inventory-query'], ['InventoryView.vue', 'overseas-inventory-query'],
['InventoryTransactionsView.vue', 'overseas-inventory-transactions-query'], ['InventoryTransactionsView.vue', 'overseas-inventory-transactions-query'],
['RelabelOrdersView.vue', 'overseas-relabel-orders-query'], ['RelabelOrdersView.vue', 'overseas-relabel-orders-query'],
@@ -49,7 +50,6 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('preserves the prototype dialog carriers and desktop widths', () => { it('preserves the prototype dialog carriers and desktop widths', () => {
const expected = [ const expected = [
['RelabelOrdersView.vue', '760px', '新增换标单', '审核换标单'],
['CartonsView.vue', '860px', '新增箱号', '编辑箱号'], ['CartonsView.vue', '860px', '新增箱号', '编辑箱号'],
['ProductsView.vue', '560px', '新增产品', '编辑产品'], ['ProductsView.vue', '560px', '新增产品', '编辑产品'],
] ]
@@ -61,6 +61,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain(editTitle) expect(value).toContain(editTitle)
expect(value).not.toContain('AppDrawer') expect(value).not.toContain('AppDrawer')
} }
const relabel = source('RelabelOrdersView.vue')
expect(relabel).toContain('<AppDialog')
expect(relabel).toContain('760px')
expect(relabel).toContain('新增换标单')
expect(relabel).toContain('审核换标单')
expect(relabel).not.toContain('AppDrawer')
const pallets = source('PalletsView.vue') const pallets = source('PalletsView.vue')
expect(pallets).toContain('<AppDialog') expect(pallets).toContain('<AppDialog')
expect(pallets).toContain('760px') expect(pallets).toContain('760px')
@@ -84,11 +90,15 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).toContain('confirm-disabled') expect(dialog).toContain('confirm-disabled')
expect(dialog).toContain('不可提交') expect(dialog).toContain('不可提交')
expect(dialog).not.toContain('@confirm=') expect(dialog).not.toContain('@confirm=')
for (const fileName of ['RelabelOrdersView.vue', 'CartonsView.vue', 'ProductsView.vue']) { for (const fileName of ['CartonsView.vue', 'ProductsView.vue']) {
const value = source(fileName) const value = source(fileName)
expect(value).not.toContain('ElMessage.success') expect(value).not.toContain('ElMessage.success')
expect(value).not.toMatch(/https?:\/\//) expect(value).not.toMatch(/https?:\/\//)
} }
const relabel = source('RelabelOrdersView.vue')
expect(relabel).toContain('createRelabelOrder')
expect(relabel).toContain('changeRelabelOrderStatus')
expect(relabel).toContain('ElMessage.success')
const pallets = source('PalletsView.vue') const pallets = source('PalletsView.vue')
expect(pallets).toContain('createPallet') expect(pallets).toContain('createPallet')
expect(pallets).toContain('updatePallet') expect(pallets).toContain('updatePallet')
@@ -126,9 +136,14 @@ describe('overseas warehouse first-batch prototype contract', () => {
const inventory = source('InventoryDetailView.vue') const inventory = source('InventoryDetailView.vue')
expect(inventory).toContain('listInventoryPositions') expect(inventory).toContain('listInventoryPositions')
expect(inventory).toContain(':data="locationRows"') expect(inventory).toContain(':data="locationRows"')
expect(inventory).toContain('balance.value?.skuCodes') expect(inventory).toContain('balance.value?.skuCode')
expect(inventory).toContain('inventoryProductName(balance.value)')
expect(inventory).not.toContain('当前流水接口未返回 SKU') expect(inventory).not.toContain('当前流水接口未返回 SKU')
expect(inventory).not.toContain('ElMessage.success') expect(inventory).not.toContain('ElMessage.success')
const inventoryList = source('InventoryView.vue')
expect(inventoryList).toContain('access.can(overseasWarehousePermissions.warehouseList)')
expect(inventoryList).toContain('if (!access.can(overseasWarehousePermissions.warehouseList)) return')
expect(inventoryList).not.toContain('请先选择客户后查询')
}) })
it('connects pallet CRUD after the overseas contract is available', () => { it('connects pallet CRUD after the overseas contract is available', () => {
@@ -139,6 +154,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('createPallet') expect(value).toContain('createPallet')
expect(value).toContain('updatePallet') expect(value).toContain('updatePallet')
expect(value).toContain('deletePallet') expect(value).toContain('deletePallet')
expect(value).toContain('v-model="form.fbaWarehouseCode"')
expect(value).toContain("warehouseType === 'FBA'")
expect(value).toContain('palletCode: form.code.trim()')
expect(value).not.toContain(':readonly="dialogMode === \'edit\'"')
expect(value).not.toContain('ContractPendingFormDialog') expect(value).not.toContain('ContractPendingFormDialog')
}) })
@@ -150,9 +169,20 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('createFbaAppointment') expect(value).toContain('createFbaAppointment')
expect(value).toContain('预计装车时间') expect(value).toContain('预计装车时间')
expect(value).toContain('所选入库单必须属于同一 FBA 仓库') expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
expect(value).toContain('<CalendarCheck2 :size="16" />预约')
expect(value).toContain('<Truck :size="16" />智能排车')
expect(value).toContain('<SmartDispatchDialog')
expect(value).not.toContain('ContractPendingNotice') expect(value).not.toContain('ContractPendingNotice')
}) })
it('connects intelligent dispatch without a frontend customerId constraint', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
expect(dialog).toContain('listDispatchCandidates')
expect(dialog).toContain('preArrangeDispatch')
expect(dialog).toContain('planPalletIds: selectedIds.value')
expect(dialog).not.toContain('customerId:')
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => { it('adds the prototype inventory transaction page as a read-only inventory route', () => {
const inventory = source('InventoryView.vue') const inventory = source('InventoryView.vue')
const transactions = source('InventoryTransactionsView.vue') const transactions = source('InventoryTransactionsView.vue')
@@ -216,7 +246,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('keeps every prototype list column in its frozen order', () => { it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([ expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'orderNo', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse', 'actions', 'orderNo', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'containerStatus', 'abnormal', 'held', 'pallets', 'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', 'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'consolidated', 'status', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'consolidated', 'status',
'internalRemark', 'customerRemark', 'warehouseRemark', 'internalRemark', 'customerRemark', 'warehouseRemark',
@@ -24,5 +24,5 @@ describe('overseas warehouse mounted real API pages', () => {
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
}) })
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') }) it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
it('mounts inventory ledger as read-only facts', async () => { routing.route.query = { customerId: '10001' }; const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ customerId: '10001', page: 1, size: 20 })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') }) it('mounts inventory ledger as read-only facts', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
}) })
+24 -23
View File
@@ -1,27 +1,28 @@
export const overseasWarehousePermissions = { export const overseasWarehousePermissions = {
lastMileOrderList: 'platform:lastleg:order:list', lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
lastMileOrderExport: 'platform:lastleg:order:export', lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export',
lastMileOrderTransfer: 'platform:lastleg:order:transfer', lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit',
inboundOrderList: 'platform:lastleg:inbound-plan:list', inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
inboundOrderCreate: 'platform:lastleg:inbound-plan:create', inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
inboundOrderEdit: 'platform:lastleg:inbound-plan:edit', inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
inboundOrderAppointment: 'platform:lastleg:inbound-plan:appointment', inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:edit',
inventoryList: 'platform:lastleg:inventory:list', dispatchList: 'platform:overseas-warehouse:dispatch:list',
inventoryExport: 'platform:lastleg:inventory:export', inventoryList: 'platform:overseas-warehouse:inventory:list',
inventoryEdit: 'platform:lastleg:inventory:manage', inventoryExport: 'platform:overseas-warehouse:inventory:export',
inventoryAdjust: 'platform:lastleg:inventory:adjust', inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
relabelOrderList: 'platform:lastleg:relabel:list', inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust',
relabelOrderCreate: 'platform:lastleg:relabel:create', relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
relabelOrderStatus: 'platform:lastleg:relabel:status', relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
locationList: 'platform:location:list', relabelOrderStatus: 'platform:overseas-warehouse:relabel-order:status',
locationCreate: 'platform:location:create', locationList: 'platform:overseas-warehouse:location:list',
locationEdit: 'platform:location:edit', locationCreate: 'platform:overseas-warehouse:location:create',
locationDelete: 'platform:location:delete', locationEdit: 'platform:overseas-warehouse:location:edit',
warehouseList: 'platform:warehouse:list', locationDelete: 'platform:overseas-warehouse:location:delete',
palletList: 'platform:pallet:list', warehouseList: 'platform:overseas-warehouse:warehouse:list',
palletCreate: 'platform:pallet:create', palletList: 'platform:overseas-warehouse:pallet:list',
palletEdit: 'platform:pallet:edit', palletCreate: 'platform:overseas-warehouse:pallet:create',
palletDelete: 'platform:pallet:delete', palletEdit: 'platform:overseas-warehouse:pallet:edit',
palletDelete: 'platform:overseas-warehouse:pallet:delete',
// 箱号、产品库本期暂缓;权限常量随保留源码留存,但不再由模块正式路由引用。 // 箱号、产品库本期暂缓;权限常量随保留源码留存,但不再由模块正式路由引用。
cartonList: 'platform:overseas-warehouse:carton:list', cartonList: 'platform:overseas-warehouse:carton:list',
cartonCreate: 'platform:overseas-warehouse:carton:create', cartonCreate: 'platform:overseas-warehouse:carton:create',
@@ -9,6 +9,7 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList], ['overseas-warehouse/last-mile-orders/:orderId', permission.lastMileOrderList],
['overseas-warehouse/inbound-orders', permission.inboundOrderList], ['overseas-warehouse/inbound-orders', permission.inboundOrderList],
['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList], ['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList],
['overseas-warehouse/dispatches', permission.dispatchList],
['overseas-warehouse/inventory', permission.inventoryList], ['overseas-warehouse/inventory', permission.inventoryList],
['overseas-warehouse/inventory-transactions', permission.inventoryList], ['overseas-warehouse/inventory-transactions', permission.inventoryList],
['overseas-warehouse/inventory/:inventoryId', permission.inventoryList], ['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
+6
View File
@@ -26,6 +26,12 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/InboundOrderDetailView.vue'), component: () => import('./pages/InboundOrderDetailView.vue'),
meta: { title: '入库单详情', description: '查看海外仓入库单与应收费用', permission: permission.inboundOrderList, showPageHeading: false }, meta: { title: '入库单详情', description: '查看海外仓入库单与应收费用', permission: permission.inboundOrderList, showPageHeading: false },
}, },
{
path: 'overseas-warehouse/dispatches',
name: 'overseas-dispatches',
component: () => import('./pages/DispatchesView.vue'),
meta: { title: '排车列表', description: '查看海外仓车辆调度、预约与发车状态', permission: permission.dispatchList },
},
{ {
path: 'overseas-warehouse/inventory', path: 'overseas-warehouse/inventory',
name: 'overseas-inventory', name: 'overseas-inventory',
+1 -1
View File
@@ -7,7 +7,7 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehou
describe('overseas warehouse presentation contracts', () => { describe('overseas warehouse presentation contracts', () => {
it('covers every formal list without freezing an API transport', () => { it('covers every formal list without freezing an API transport', () => {
for (const name of [ for (const name of [
'LastMileOrderListItem', 'InboundOrderListItem', 'InventoryListItem', 'LastMileOrderListItem', 'InboundOrderListItem', 'DispatchListItem', 'InventoryListItem',
'RelabelOrderListItem', 'PalletListItem', 'CartonListItem', 'RelabelOrderListItem', 'PalletListItem', 'CartonListItem',
'ProductListItem', 'InventoryTransactionListItem', 'ProductListItem', 'InventoryTransactionListItem',
]) expect(source).toContain(`interface ${name}`) ]) expect(source).toContain(`interface ${name}`)
+20
View File
@@ -65,6 +65,26 @@ export interface InboundOrderListItem {
dispatchStatus: string dispatchStatus: string
} }
export interface DispatchListItem {
id: string
dispatchNo: string
vehiclePlate: string
driverName: string
driverPhone: string
carrierName: string
orderNos: string
route: string
overseasWarehouse: string
departAt: string
fbaAppointment: string
vehicleAppointment: string
estimatedArrivalAt: string
status: string
palletCount: number
packageCount: number
createdAt: string
}
export interface InventoryListItem { export interface InventoryListItem {
id: string id: string
customerId: string customerId: string
+11
View File
@@ -85,4 +85,15 @@ describe('overseas HTTP client', () => {
expect(refresh).toHaveBeenCalledTimes(1) expect(refresh).toHaveBeenCalledTimes(1)
expect(authorization).toBe('Bearer overseas-refreshed') expect(authorization).toBe('Bearer overseas-refreshed')
}) })
it('fails before sending a business request when refresh is domestic-only', async () => {
const refresh = vi.fn(async () => 'domestic-only')
registerAuthRefreshHandler(refresh)
const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => success(config, { code: '200', message: 'success', data: [] }))
overseasHttp.defaults.adapter = adapter
await expect(overseasHttp.get('/platform/v1/last-leg/orders')).rejects.toThrow('当前环境未签发海外访问令牌')
expect(refresh).toHaveBeenCalledTimes(1)
expect(adapter).not.toHaveBeenCalled()
})
}) })