优化
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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', {})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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('不提供出库单跳转') })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user