Release/test 合并上生产 #2
@@ -18,6 +18,7 @@ describe('adminNavigation', () => {
|
||||
'货柜管理',
|
||||
'海外业务',
|
||||
'海外仓',
|
||||
'客户服务',
|
||||
'财务管理',
|
||||
'销售管理',
|
||||
'通用',
|
||||
@@ -34,7 +35,7 @@ describe('adminNavigation', () => {
|
||||
'库位管理',
|
||||
'整柜业务',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'service-center')).toBeUndefined()
|
||||
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
|
||||
'供应商列表',
|
||||
'快递公司',
|
||||
@@ -51,6 +52,8 @@ describe('adminNavigation', () => {
|
||||
'尾程订单',
|
||||
'入库单',
|
||||
'排车列表',
|
||||
'出库单',
|
||||
'派送单列表',
|
||||
'换标单',
|
||||
'托盘管理',
|
||||
'库存管理',
|
||||
@@ -141,6 +144,10 @@ describe('adminNavigation', () => {
|
||||
'platform:overseas-warehouse:last-mile-order:list',
|
||||
'platform:overseas-warehouse:inbound-order:list',
|
||||
'platform:overseas-warehouse:dispatch:list',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:relabel-order:list',
|
||||
'platform:overseas-warehouse:pallet:list',
|
||||
'platform:overseas-warehouse:inventory:list',
|
||||
@@ -157,6 +164,10 @@ describe('adminNavigation', () => {
|
||||
'/overseas-warehouse/last-mile-orders',
|
||||
'/overseas-warehouse/inbound-orders',
|
||||
'/overseas-warehouse/dispatches',
|
||||
'/overseas-warehouse/outbounds',
|
||||
'/overseas-warehouse/delivery-orders',
|
||||
'/overseas-warehouse/exception-orders',
|
||||
'/overseas-warehouse/customer-visits',
|
||||
'/overseas-warehouse/relabel-orders',
|
||||
'/overseas-warehouse/pallets',
|
||||
'/overseas-warehouse/inventory',
|
||||
@@ -169,7 +180,7 @@ describe('adminNavigation', () => {
|
||||
]))
|
||||
expect(paths).not.toContain('/overseas-warehouse/cartons')
|
||||
expect(paths).not.toContain('/overseas-warehouse/products')
|
||||
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
|
||||
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
|
||||
})
|
||||
|
||||
it('filters protected pages without mutating the source tree', () => {
|
||||
|
||||
@@ -124,11 +124,21 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
|
||||
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
|
||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
||||
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
|
||||
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'customer-service',
|
||||
title: '客户服务',
|
||||
icon: ContactRound,
|
||||
children: [
|
||||
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
title: '财务管理',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import {
|
||||
getCustomerVisit,
|
||||
listCustomerVisits,
|
||||
saveCustomerVisit,
|
||||
updateDeliveryOrderStatus,
|
||||
uploadCustomerVisitAttachment,
|
||||
} from './customerVisits'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
|
||||
describe('customer visit API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(overseasHttp.get).mockReset()
|
||||
vi.mocked(overseasHttp.post).mockReset()
|
||||
vi.mocked(overseasHttp.put).mockReset()
|
||||
})
|
||||
|
||||
it('loads the customer visit page and detail in customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], current: 1, size: 10, total: 0, pages: 0, visitable: 3, pending: 2, completed: 1 } })
|
||||
await listCustomerVisits({ page: 1, size: 10, keyword: 'DEL' })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/customer-visits', { params: { page: 1, size: 10, keyword: 'DEL' } })
|
||||
|
||||
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
|
||||
await getCustomerVisit('9', '100')
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } })
|
||||
})
|
||||
|
||||
it('updates delivery status so selecting delivered can generate a visit', async () => {
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERED' } })
|
||||
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', remark: '完成' })
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', remark: '完成' }, { params: { customerId: '100' } })
|
||||
})
|
||||
|
||||
it('uploads and saves repeatable visit material', async () => {
|
||||
const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' })
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 } })
|
||||
await uploadCustomerVisitAttachment('9', '100', file)
|
||||
const body = vi.mocked(overseasHttp.post).mock.calls[0]?.[1] as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
|
||||
await saveCustomerVisit('9', '100', { selectedInboundOrderId: '7', visitResult: 'NORMAL', attachments: [] })
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/customer-visits/9', expect.objectContaining({ selectedInboundOrderId: '7' }), { params: { customerId: '100' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface CustomerVisitAttachment {
|
||||
fileId: string
|
||||
fileName: string
|
||||
contentType?: string | null
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface CustomerVisitRow {
|
||||
id: string
|
||||
customerId: string
|
||||
deliveryNo: string
|
||||
customerName?: string | null
|
||||
contactName?: string | null
|
||||
contactPhone?: string | null
|
||||
destination?: string | null
|
||||
deliveryCompletedAt: string
|
||||
status: 'PENDING' | 'COMPLETED'
|
||||
selectedInboundCode?: string | null
|
||||
visitResult?: string | null
|
||||
visitedAt?: string | null
|
||||
visitCount: number
|
||||
attachments: CustomerVisitAttachment[]
|
||||
}
|
||||
|
||||
export interface CustomerVisitPage {
|
||||
records: CustomerVisitRow[]
|
||||
current: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
visitable: number
|
||||
pending: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
export interface CustomerVisitInboundOption {
|
||||
inboundOrderId: string
|
||||
inboundCode: string
|
||||
lastMileOrderId: string
|
||||
lastMileOrderNo: string
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
packageCount: number
|
||||
}
|
||||
|
||||
export interface CustomerVisitDetail {
|
||||
visit: CustomerVisitRow
|
||||
inboundOrders: CustomerVisitInboundOption[]
|
||||
customerFeedback?: string | null
|
||||
remark?: string | null
|
||||
visitedBy?: string | null
|
||||
}
|
||||
|
||||
export async function listCustomerVisits(params: {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
customerId?: string
|
||||
completedFrom?: string
|
||||
completedTo?: string
|
||||
}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<CustomerVisitPage>, ApiEnvelope<CustomerVisitPage>>(
|
||||
'/platform/v1/last-leg/customer-visits', { params },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getCustomerVisit(visitId: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<CustomerVisitDetail>, ApiEnvelope<CustomerVisitDetail>>(
|
||||
`/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function saveCustomerVisit(visitId: string, customerId: string, payload: {
|
||||
selectedInboundOrderId: string
|
||||
visitResult: string
|
||||
customerFeedback?: string
|
||||
remark?: string
|
||||
attachments: CustomerVisitAttachment[]
|
||||
}) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<CustomerVisitDetail>, ApiEnvelope<CustomerVisitDetail>>(
|
||||
`/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}`, payload, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadCustomerVisitAttachment(visitId: string, customerId: string, file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const response = await overseasHttp.post<ApiEnvelope<CustomerVisitAttachment>, ApiEnvelope<CustomerVisitAttachment>>(
|
||||
`/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}/attachments`, body,
|
||||
{ params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completeDeliveryOrder(deliveryNo: string, customerId: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<{ visitId: string; deliveryNo: string; deliveryStatus: string; visitStatus: string; replayed: boolean }>, ApiEnvelope<{ visitId: string; deliveryNo: string; deliveryStatus: string; visitStatus: string; replayed: boolean }>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, undefined,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; remark?: string }) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getDeliveryOrder, listDeliveryOrders } from './deliveryOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('delivery order API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
|
||||
it('passes paging and expanded filters and normalizes page numbers', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } })
|
||||
const result = await listDeliveryOrders({ page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders', { params: { page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' } })
|
||||
expect(result).toMatchObject({ page: 2, size: 20, total: 41, pages: 3 })
|
||||
})
|
||||
|
||||
it('loads an encoded detail in its explicit customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1' }, skus: [], fees: [], operations: [] } })
|
||||
await getDeliveryOrder('DEL/1', '1001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import type { ExceptionRelation } from './exceptionOrders'
|
||||
|
||||
export interface DeliveryOrderQuery {
|
||||
page: number
|
||||
size: number
|
||||
customerId?: string
|
||||
documentNo?: string
|
||||
dispatchNo?: string
|
||||
waybillNo?: string
|
||||
warehouseId?: string
|
||||
status?: string
|
||||
vehiclePlate?: string
|
||||
driverName?: string
|
||||
deliveryMethod?: string
|
||||
plannedFrom?: string
|
||||
plannedTo?: string
|
||||
}
|
||||
|
||||
export interface DeliveryOrderRow {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
deliveryNo: string
|
||||
outboundNo: string
|
||||
dispatchNo: string
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
status: string
|
||||
waybillNo?: string | null
|
||||
deliveryMethod?: string | null
|
||||
carrierName?: string | null
|
||||
pickupMethod?: string | null
|
||||
loadingType?: string | null
|
||||
vehicleType?: string | null
|
||||
vehicleSize?: string | null
|
||||
vehiclePlate?: string | null
|
||||
driverName?: string | null
|
||||
driverPhone?: string | null
|
||||
appointmentAccount?: string | null
|
||||
sellerName?: string | null
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
totalWeightKg?: number | null
|
||||
totalVolumeCbm?: number | null
|
||||
plannedDeliveryAt?: string | null
|
||||
dispatchedAt?: string | null
|
||||
expectedArrivalAt?: string | null
|
||||
signedAt?: string | null
|
||||
remark?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; weightKg?: number | null; volumeCbm?: number | null }
|
||||
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null }
|
||||
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
|
||||
|
||||
export async function listDeliveryOrders(query: DeliveryOrderQuery) {
|
||||
const { customerId, ...params } = query
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>, ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>>(
|
||||
'/platform/v1/last-leg/delivery-orders', { params: { ...params, ...(customerId ? { customerId } : {}) } },
|
||||
)
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<DeliveryOrderDetail>, ApiEnvelope<DeliveryOrderDetail>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ExceptionRelation[]>, ApiEnvelope<ExceptionRelation[]>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: { inboundOrderId: string; reason: string; returnDirection: string; returnLogisticsNo?: string; packageCount?: number; skuCode?: string }) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<{ id: string; exceptionNo: string; status: string }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -171,7 +171,25 @@ export async function releaseDispatchDraft(draftToken: string) {
|
||||
}
|
||||
|
||||
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; deliveryNo: string; deliveryStatus: string; palletCount: number; packageCount: number; replayed: boolean }
|
||||
export async function transferDispatchToDelivery(dispatchNo: string, payload: { idempotencyKey: string; remark?: string }) {
|
||||
export interface TransferDeliveryPayload {
|
||||
idempotencyKey: string
|
||||
plannedDeliveryAt: string
|
||||
deliveryMethod: string
|
||||
waybillNo?: string
|
||||
carrierName?: string
|
||||
pickupMethod: string
|
||||
loadingType: string
|
||||
vehicleType?: string
|
||||
vehicleSize?: string
|
||||
driverName?: string
|
||||
driverPhone?: string
|
||||
vehiclePlate?: string
|
||||
appointmentAccount?: string
|
||||
sellerName?: string
|
||||
remark?: string
|
||||
fees: Array<{ feeType: string; currency: string; amount: number; remark?: string }>
|
||||
}
|
||||
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
|
||||
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, payload,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from './exceptionOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('exception order API', () => {
|
||||
beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset() })
|
||||
|
||||
it('queries the real paginated exception endpoint', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '1', size: '20', total: '2', pages: '1' } })
|
||||
const result = await listExceptionOrders({ page: 1, size: 20, keyword: 'EXC' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders', { params: { page: 1, size: 20, keyword: 'EXC' } })
|
||||
expect(result.total).toBe(2)
|
||||
})
|
||||
|
||||
it('resolves relation before creating an exception document', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundOrderId: '11', inboundCode: 'IN-1', lastMileOrderId: '12', lastMileOrderNo: 'LM-1', customerId: '1' } })
|
||||
await resolveExceptionRelation({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/resolve', { params: { inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' } })
|
||||
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '99', exceptionNo: 'EXC-1', status: 'PENDING' } })
|
||||
await createExceptionOrder({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', exceptionType: 'RETURN', reason: '拒收' })
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders', expect.objectContaining({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface ExceptionOrderQuery { page: number; size: number; keyword?: string; exceptionType?: string; status?: string; customerId?: string; warehouseId?: string; createdFrom?: string; createdTo?: string }
|
||||
export interface ExceptionOrderRow {
|
||||
id: string; exceptionNo: string; exceptionType: string; status: string; reason: string
|
||||
customerId: string; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null
|
||||
lastMileOrderId?: string | null; lastMileOrderNo?: string | null; inboundOrderId?: string | null; inboundCode?: string | null
|
||||
deliveryOrderId?: string | null; deliveryNo?: string | null; outboundNo?: string | null
|
||||
skuCode?: string | null; packageCount?: number | null; returnDirection?: string | null; returnLogisticsNo?: string | null
|
||||
handlingAction?: string | null; handlerName?: string | null; exceptionAt: string; updatedAt: string
|
||||
}
|
||||
export interface ExceptionRelation {
|
||||
lastMileOrderId?: string | null; lastMileOrderNo?: string | null; inboundOrderId?: string | null; inboundCode?: string | null
|
||||
customerId: string; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null
|
||||
deliveryNo?: string | null; outboundNo?: string | null; availablePackages?: number | null
|
||||
}
|
||||
export interface CreateExceptionOrderPayload { customerId?: string; inboundCode?: string; lastMileOrderNo?: string; exceptionType: string; reason: string; packageCount?: number; skuCode?: string }
|
||||
|
||||
export async function listExceptionOrders(query: ExceptionOrderQuery) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ExceptionOrderRow>>, ApiEnvelope<OverseasPagePayload<ExceptionOrderRow>>>('/platform/v1/last-leg/exception-orders', { params: query })
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
}
|
||||
export async function resolveExceptionRelation(params: { customerId?: string; inboundCode?: string; lastMileOrderNo?: string }) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ExceptionRelation>, ApiEnvelope<ExceptionRelation>>('/platform/v1/last-leg/exception-orders/resolve', { params })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createExceptionOrder(payload: CreateExceptionOrderPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<{ id: string; exceptionNo: string; status: string }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>('/platform/v1/last-leg/exception-orders', payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -12,3 +12,7 @@ export async function createOneClickDropshipOutbound(customerId: string, payload
|
||||
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function cancelOutbound(outboundNo: string, reason: string, customerId?: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason }, { params: customerId ? { customerId } : undefined })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { FileUp, MessageSquareText, Pencil, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import {
|
||||
getCustomerVisit,
|
||||
listCustomerVisits,
|
||||
saveCustomerVisit,
|
||||
uploadCustomerVisitAttachment,
|
||||
type CustomerVisitAttachment,
|
||||
type CustomerVisitDetail,
|
||||
type CustomerVisitRow,
|
||||
} from '../api/customerVisits'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<CustomerVisitRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const summary = reactive({ visitable: 0, pending: 0, completed: 0 })
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ keyword: '', status: '', completedAt: [] })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派送单号 / 客户 / 联系人' },
|
||||
{ key: 'status', type: 'select', label: '回访状态', placeholder: '全部状态', options: [{ label: '待回访', value: 'PENDING' }, { label: '已回访', value: 'COMPLETED' }] },
|
||||
{ key: 'completedAt', type: 'daterange', label: '派送完成时间' },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 130, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 180, fixed: 'left' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
|
||||
{ key: 'contact', label: '联系人', minWidth: 170, formatter: (row: CustomerVisitRow) => [row.contactName, row.contactPhone].filter(Boolean).join(' ') || '—' },
|
||||
{ key: 'deliveryCompletedAt', prop: 'deliveryCompletedAt', label: '完成时间', minWidth: 170, formatter: (row: CustomerVisitRow) => formatOverseasDateTime(row.deliveryCompletedAt) },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 140 },
|
||||
{ key: 'status', prop: 'status', label: '回访状态', width: 110, slotName: 'status' },
|
||||
{ key: 'visitedAt', prop: 'visitedAt', label: '回访时间', minWidth: 170, formatter: (row: CustomerVisitRow) => formatOverseasDateTime(row.visitedAt) },
|
||||
{ key: 'visitResult', prop: 'visitResult', label: '回访结果', minWidth: 130, formatter: (row: CustomerVisitRow) => resultLabels[row.visitResult || ''] || '—' },
|
||||
{ key: 'attachments', label: '资料', minWidth: 110, formatter: (row: CustomerVisitRow) => row.attachments.length ? `${row.attachments.length} 份` : '—' },
|
||||
]
|
||||
const resultLabels: Record<string, string> = { SATISFIED: '满意', NORMAL: '正常', FOLLOW_UP: '需跟进', NO_CONTACT: '未联系上' }
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const range = Array.isArray(filters.value.completedAt) ? filters.value.completedAt.map(String) : []
|
||||
const result = await listCustomerVisits({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
keyword: text('keyword'),
|
||||
status: text('status'),
|
||||
completedFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
|
||||
completedTo: range[1] ? `${range[1]}T23:59:59` : undefined,
|
||||
})
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
Object.assign(summary, { visitable: result.visitable, pending: result.pending, completed: result.completed })
|
||||
} catch (cause) {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
error.value = getApiErrorDetails(cause, '客户回访列表加载失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const detail = ref<CustomerVisitDetail | null>(null)
|
||||
const form = reactive({ selectedInboundOrderId: '', visitResult: '', customerFeedback: '', remark: '', attachments: [] as CustomerVisitAttachment[] })
|
||||
|
||||
async function openVisit(row: CustomerVisitRow) {
|
||||
dialogOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
Object.assign(form, { selectedInboundOrderId: '', visitResult: '', customerFeedback: '', remark: '', attachments: [] })
|
||||
try {
|
||||
const value = await getCustomerVisit(row.id, row.customerId)
|
||||
detail.value = value
|
||||
Object.assign(form, {
|
||||
selectedInboundOrderId: value.inboundOrders.find(option => option.inboundCode === value.visit.selectedInboundCode)?.inboundOrderId || (value.inboundOrders.length === 1 ? value.inboundOrders[0]?.inboundOrderId : '') || '',
|
||||
visitResult: value.visit.visitResult || '',
|
||||
customerFeedback: value.customerFeedback || '',
|
||||
remark: value.remark || '',
|
||||
attachments: [...value.visit.attachments],
|
||||
})
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorDetails(cause, '回访信息加载失败').message)
|
||||
dialogOpen.value = false
|
||||
} finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function selectFiles(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = Array.from(input.files || [])
|
||||
input.value = ''
|
||||
if (!detail.value || !selected.length) return
|
||||
if (form.attachments.length + selected.length > 20) { ElMessage.warning('最多保留20份回访资料'); return }
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of selected) {
|
||||
const uploaded = await uploadCustomerVisitAttachment(detail.value.visit.id, detail.value.visit.customerId, file)
|
||||
if (!form.attachments.some(item => item.fileId === uploaded.fileId)) form.attachments.push(uploaded)
|
||||
}
|
||||
ElMessage.success('回访资料上传成功')
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '回访资料上传失败').message) }
|
||||
finally { uploading.value = false }
|
||||
}
|
||||
|
||||
function removeAttachment(fileId: string) {
|
||||
form.attachments = form.attachments.filter(item => item.fileId !== fileId)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!detail.value || !form.selectedInboundOrderId || !form.visitResult) {
|
||||
ElMessage.warning('请选择本次回访的入仓单和回访结果')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await saveCustomerVisit(detail.value.visit.id, detail.value.visit.customerId, {
|
||||
selectedInboundOrderId: form.selectedInboundOrderId,
|
||||
visitResult: form.visitResult,
|
||||
customerFeedback: form.customerFeedback.trim() || undefined,
|
||||
remark: form.remark.trim() || undefined,
|
||||
attachments: form.attachments,
|
||||
})
|
||||
dialogOpen.value = false
|
||||
ElMessage.success(detail.value.visit.status === 'COMPLETED' ? '再次回访已保存' : '回访已完成')
|
||||
await load()
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '回访保存失败').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
function fileSize(value: number) { return value >= 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB` }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="客户回访列表">
|
||||
<template #toolbar>
|
||||
<section class="visit-summary surface-panel" aria-label="回访统计">
|
||||
<div><strong>{{ summary.visitable }}</strong><span>可回访订单</span></div>
|
||||
<div><strong>{{ summary.pending }}</strong><span>待回访</span></div>
|
||||
<div><strong>{{ summary.completed }}</strong><span>已完成回访</span></div>
|
||||
</section>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" @search="search" @reset="reset" />
|
||||
</template>
|
||||
<template v-if="error" #state><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" 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-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><el-button v-if="access.can(permission.customerVisitEdit)" size="small" type="primary" plain @click="openVisit(row as CustomerVisitRow)"><component :is="row.status === 'COMPLETED' ? Pencil : MessageSquareText" :size="15" />{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条回访记录</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
|
||||
<AppDialog v-model="dialogOpen" title="客户回访" width="min(920px, calc(100vw - 28px))" confirm-text="保存回访" :loading="detailLoading" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="submit">
|
||||
<template v-if="detail">
|
||||
<section class="visit-order-summary">
|
||||
<div><span>派送单号</span><strong>{{ detail.visit.deliveryNo }}</strong></div>
|
||||
<div><span>客户</span><strong>{{ detail.visit.customerName || '—' }}</strong></div>
|
||||
<div><span>完成时间</span><strong>{{ formatOverseasDateTime(detail.visit.deliveryCompletedAt) }}</strong></div>
|
||||
<div><span>目的地</span><strong>{{ detail.visit.destination || '—' }}</strong></div>
|
||||
</section>
|
||||
<el-form label-position="top" class="visit-form">
|
||||
<el-form-item label="选择本次回访的入仓单" required>
|
||||
<div class="inbound-options">
|
||||
<label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }">
|
||||
<el-radio v-model="form.selectedInboundOrderId" :value="option.inboundOrderId"><strong>{{ option.inboundCode }}</strong></el-radio>
|
||||
<span>尾程单:{{ option.lastMileOrderNo }}</span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访结果" required><el-select v-model="form.visitResult" placeholder="请选择回访结果"><el-option v-for="(label, value) in resultLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="客户反馈"><el-input v-model="form.customerFeedback" type="textarea" :rows="4" maxlength="2000" show-word-limit placeholder="记录客户对派送服务的反馈" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="补充后续跟进事项" /></el-form-item>
|
||||
<el-form-item label="回访资料">
|
||||
<label class="file-picker" :class="{ disabled: uploading }"><FileUp :size="16" />{{ uploading ? '上传中...' : '选择文件' }}<input type="file" multiple :disabled="uploading" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.webp" @change="selectFiles" /></label>
|
||||
<span class="file-tip">支持 PDF、Office、JPG、PNG、WEBP,单个不超过20MB</span>
|
||||
<div v-if="form.attachments.length" class="attachment-list">
|
||||
<div v-for="file in form.attachments" :key="file.fileId"><span>{{ file.fileName }}</span><small>{{ fileSize(file.sizeBytes) }}</small><el-button text type="danger" aria-label="移除资料" @click="removeAttachment(file.fileId)"><Trash2 :size="15" /></el-button></div>
|
||||
</div>
|
||||
<div v-else class="attachment-empty">暂未上传回访资料</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visit-summary { display: flex; gap: 12px; padding: 12px; }
|
||||
.visit-summary > div { display: flex; align-items: baseline; gap: 8px; min-width: 140px; padding: 8px 14px; border: 1px solid var(--cc-border-color); border-radius: 8px; }
|
||||
.visit-summary strong { font-size: 22px; color: var(--cc-text-primary); } .visit-summary span { color: var(--cc-text-secondary); }
|
||||
.visit-order-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 28px; padding: 18px; margin-bottom: 18px; border: 1px solid var(--cc-border-color); border-radius: 8px; background: var(--cc-bg-muted); }
|
||||
.visit-order-summary div { display: grid; gap: 5px; } .visit-order-summary span { color: var(--cc-text-secondary); } .visit-order-summary strong { color: var(--cc-text-primary); }
|
||||
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; }
|
||||
.inbound-option { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--cc-border-color); border-radius: 8px; cursor: pointer; color: var(--cc-text-secondary); }
|
||||
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.file-picker { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; color: var(--el-color-primary); border: 1px solid var(--el-color-primary); border-radius: 6px; cursor: pointer; }
|
||||
.file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); }
|
||||
.attachment-list, .attachment-empty { width: 100%; margin-top: 12px; }
|
||||
.attachment-list > div { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--cc-border-color); }
|
||||
.attachment-list small, .attachment-empty { color: var(--cc-text-secondary); }
|
||||
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const detail = ref<DeliveryOrderDetail | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const activeTab = ref('order')
|
||||
const deliveryNo = computed(() => String(route.params.deliveryNo || '')); const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
|
||||
const methodLabels: Record<string, string> = { SELF_DELIVERY: '自送', EXPRESS: '快递', THIRD_PARTY: '第三方配送' }
|
||||
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用' }
|
||||
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收' }
|
||||
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
|
||||
function back() { void router.push({ name: 'overseas-delivery-orders' }) }
|
||||
async function load() { if (!deliveryNo.value || !customerId.value) { error.value = { message: '缺少派送单客户范围,无法安全查询详情' }; return }; loading.value = true; error.value = null; try { detail.value = await getDeliveryOrder(deliveryNo.value, customerId.value) } catch (cause) { error.value = getApiErrorDetails(cause, '派送单详情加载失败') } finally { loading.value = false } }
|
||||
load()
|
||||
</script>
|
||||
<template><AdminPage><DetailPageLayout aria-label="派送单详情"><template #header><div class="detail-heading"><el-button @click="back"><ArrowLeft :size="16" />返回</el-button><div><h1>派送单详情 <StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" /></h1><p>派送单号 {{ deliveryNo }}</p></div></div></template><LoadErrorState v-if="error" title="派送单详情加载失败" :message="error.message" :loading="loading" @retry="load" /><section v-else v-loading="loading" class="surface-panel delivery-detail"><el-tabs v-model="activeTab"><el-tab-pane label="订单信息" name="order"><h3>订单信息</h3><el-descriptions v-if="detail" :column="2" border><el-descriptions-item label="派送单号">{{ show(detail.order.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[detail.order.status] || detail.order.status }}</el-descriptions-item><el-descriptions-item label="仓库">{{ show(detail.order.overseasWarehouseName || detail.order.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.order.waybillNo) }}</el-descriptions-item><el-descriptions-item label="派送人">{{ show(detail.order.carrierName) }}</el-descriptions-item><el-descriptions-item label="派送时间">{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="提货方式">{{ show(detail.order.pickupMethod) }}</el-descriptions-item><el-descriptions-item label="装车类型">{{ show(detail.order.loadingType) }}</el-descriptions-item><el-descriptions-item label="派送方式">{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</el-descriptions-item><el-descriptions-item label="司机/车队">{{ show(detail.order.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.order.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="总箱数">{{ detail.order.packageCount }}</el-descriptions-item><el-descriptions-item label="总重量(kg)">{{ show(detail.order.totalWeightKg) }}</el-descriptions-item><el-descriptions-item label="总体积(m³)">{{ show(detail.order.totalVolumeCbm) }}</el-descriptions-item><el-descriptions-item label="预计到达时间">{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.order.remark) }}</el-descriptions-item></el-descriptions></el-tab-pane><el-tab-pane label="SKU明细" name="sku"><h3>SKU明细</h3><el-table :data="detail?.skus || []" border stripe><el-table-column prop="inboundCode" label="入库码" min-width="150" /><el-table-column prop="customerName" label="客户" min-width="160" /><el-table-column prop="destinationWarehouseCode" label="目的仓编码" min-width="130" /><el-table-column prop="receivingAddress" label="收货地址" min-width="220" /><el-table-column prop="shipmentNo" label="货件编号" min-width="150" /><el-table-column prop="fbaTrackingNo" label="FBA追踪号" min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="130" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="weightKg" label="重量" width="100" /><el-table-column prop="volumeCbm" label="体积" width="100" /></el-table></el-tab-pane><el-tab-pane label="费用明细" name="fees"><h3>派送单费用明细</h3><el-table :data="detail?.fees || []" border stripe><el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : '应收' }}</template></el-table-column><el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" /><el-table-column prop="amount" label="金额" width="140" /><el-table-column prop="remark" label="备注" min-width="220" /></el-table></el-tab-pane><el-tab-pane label="操作记录" name="operations"><h3>操作记录</h3><el-table :data="detail?.operations || []" border stripe><el-table-column label="操作时间" min-width="170"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column><el-table-column label="操作节点" min-width="160"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column><el-table-column prop="operatorName" label="操作人" min-width="140" /><el-table-column prop="result" label="处理结果" min-width="220" /></el-table></el-tab-pane></el-tabs></section></DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.detail-heading{display:flex;align-items:center;gap:14px}.detail-heading h1{display:flex;align-items:center;gap:10px;margin:0}.detail-heading p{margin:4px 0 0;color:var(--cc-muted)}.delivery-detail{padding:0 16px 20px}.delivery-detail h3{margin:12px 0}</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Pencil, RotateCcw } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn, type DeliveryOrderRow } from '../api/deliveryOrders'
|
||||
import { updateDeliveryOrderStatus } from '../api/customerVisits'
|
||||
import type { ExceptionRelation } from '../api/exceptionOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<DeliveryOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ documentNo: '', dispatchNo: '', waybillNo: '', warehouseId: '', status: '', vehiclePlate: '', driverName: '', deliveryMethod: '', plannedAt: [] })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待派送', value: 'WAIT_DELIVERY' }, { label: '派送中', value: 'DELIVERING' }, { label: '派送完成', value: 'DELIVERED' }, { label: '已签收(历史)', value: 'SIGNED' }, { label: '已拒签', value: 'REJECTED' }, { label: '退货处理中', value: 'RETURN_PENDING' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
{ key: 'documentNo', type: 'input', label: '派送单号', placeholder: '请输入派送单号' },
|
||||
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号' },
|
||||
{ key: 'waybillNo', type: 'input', label: '运单号', placeholder: '请输入运单号' },
|
||||
{ key: 'warehouseId', type: 'input', label: '仓库', placeholder: '请输入仓库ID' },
|
||||
{ key: 'deliveryMethod', type: 'select', label: '派送方式', placeholder: '全部方式', options: [{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' }, { label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' }, { label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' }, { label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' }] },
|
||||
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号' },
|
||||
{ key: 'driverName', type: 'input', label: '司机', placeholder: '请输入司机或车队' },
|
||||
{ key: 'plannedAt', type: 'daterange', label: '预计派送时间' },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 180, fixed: 'left' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ key: 'overseasWarehouseName', prop: 'overseasWarehouseName', label: '仓库', minWidth: 150 },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170 },
|
||||
{ key: 'waybillNo', prop: 'waybillNo', label: '运单号', minWidth: 150 },
|
||||
{ key: 'status', prop: 'status', label: '订单状态', width: 110, slotName: 'status' },
|
||||
{ key: 'deliveryMethod', prop: 'deliveryMethod', label: '派送方式', width: 120, formatter: (row: DeliveryOrderRow) => deliveryMethodLabel(row.deliveryMethod) },
|
||||
{ key: 'carrierName', prop: 'carrierName', label: '派送人', minWidth: 150 },
|
||||
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌', minWidth: 120 },
|
||||
{ key: 'driverName', prop: 'driverName', label: '司机/车队', minWidth: 140 },
|
||||
{ key: 'plannedDeliveryAt', prop: 'plannedDeliveryAt', label: '期望派送时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.plannedDeliveryAt) },
|
||||
{ key: 'dispatchedAt', prop: 'dispatchedAt', label: '派送时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.dispatchedAt) },
|
||||
{ key: 'pickupMethod', prop: 'pickupMethod', label: '提货方式', width: 110 },
|
||||
{ key: 'loadingType', prop: 'loadingType', label: '装车类型', width: 110 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '总箱数', width: 90, align: 'right' },
|
||||
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 120, align: 'right' },
|
||||
{ key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' },
|
||||
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.expectedArrivalAt) },
|
||||
{ key: 'signedAt', prop: 'signedAt', label: '签收时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.signedAt) },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 160 },
|
||||
]
|
||||
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
|
||||
function deliveryMethodLabel(value?: string | null) { return ({ FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' } as Record<string, string>)[String(value)] || value || '—' }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const range = Array.isArray(filters.value.plannedAt) ? filters.value.plannedAt.map(String) : []; const result = await listDeliveryOrders({ page: page.value, size: pageSize.value, documentNo: text('documentNo'), dispatchNo: text('dispatchNo'), waybillNo: text('waybillNo'), warehouseId: text('warehouseId'), status: text('status'), vehiclePlate: text('vehiclePlate'), driverName: text('driverName'), deliveryMethod: text('deliveryMethod'), plannedFrom: range[0] ? `${range[0]}T00:00:00` : undefined, plannedTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '派送单列表加载失败') } finally { loading.value = false } }
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
function open(row: DeliveryOrderRow) { void router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: row.deliveryNo }, query: { customerId: row.customerId } }) }
|
||||
const returnOpen = ref(false); const returnLoading = ref(false); const returnSaving = ref(false); const returnRow = ref<DeliveryOrderRow | null>(null); const returnOptions = ref<ExceptionRelation[]>([])
|
||||
const returnForm = reactive({ inboundOrderId: '', reason: '', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: '', packageCount: undefined as number | undefined, skuCode: '' })
|
||||
async function openReturn(row: DeliveryOrderRow) { returnRow.value = row; Object.assign(returnForm, { inboundOrderId: '', reason: '', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: '', packageCount: undefined, skuCode: '' }); returnOptions.value = []; returnOpen.value = true; returnLoading.value = true; try { returnOptions.value = await listDeliveryReturnInboundOptions(row.deliveryNo, row.customerId); if (returnOptions.value.length === 1) returnForm.inboundOrderId = String(returnOptions.value[0].inboundOrderId || '') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '退货入仓单加载失败').message) } finally { returnLoading.value = false } }
|
||||
async function submitReturn() { if (!returnRow.value || !returnForm.inboundOrderId || !returnForm.reason.trim()) { ElMessage.warning('请选择入仓单并填写退货原因'); return }; returnSaving.value = true; try { const result = await markDeliveryReturn(returnRow.value.deliveryNo, returnRow.value.customerId, { inboundOrderId: returnForm.inboundOrderId, reason: returnForm.reason.trim(), returnDirection: returnForm.returnDirection, returnLogisticsNo: returnForm.returnLogisticsNo.trim() || undefined, packageCount: returnForm.packageCount, skuCode: returnForm.skuCode.trim() || undefined }); returnOpen.value = false; ElMessage.success(`已生成异常退货单 ${result.exceptionNo}`); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '标记退货失败').message) } finally { returnSaving.value = false } }
|
||||
const statusOpen = ref(false); const statusSaving = ref(false); const statusRow = ref<DeliveryOrderRow | null>(null); const statusForm = reactive({ status: '', remark: '' })
|
||||
function openStatus(row: DeliveryOrderRow) { statusRow.value = row; Object.assign(statusForm, { status: row.status, remark: '' }); statusOpen.value = true }
|
||||
async function submitStatus() { if (!statusRow.value || !statusForm.status) return; statusSaving.value = true; try { const result = await updateDeliveryOrderStatus(statusRow.value.deliveryNo, statusRow.value.customerId, { status: statusForm.status, remark: statusForm.remark.trim() || undefined }); statusOpen.value = false; ElMessage.success(result.deliveryStatus === 'DELIVERED' ? '派送已完成,待回访记录已生成' : '派送状态已更新'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '派送状态更新失败').message) } finally { statusSaving.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-delivery-orders-query" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" 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-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
|
||||
|
||||
<style scoped>.return-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; } @media (max-width: 720px) { .return-grid { grid-template-columns: 1fr; } }</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Truck } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -24,7 +24,29 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' })
|
||||
const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false); const transferRemark = ref('')
|
||||
const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false)
|
||||
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, remark: '' })
|
||||
|
||||
const deliveryMethodOptions = [
|
||||
{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' },
|
||||
{ label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' },
|
||||
{ label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' },
|
||||
{ label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' },
|
||||
]
|
||||
const pickupMethodOptions = [
|
||||
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' }, { label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
|
||||
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' }, { label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
|
||||
{ label: '仓库自提', value: 'WAREHOUSE_PICKUP' },
|
||||
]
|
||||
const loadingTypeOptions = [{ label: '托盘', value: 'PALLET' }, { label: '地板', value: 'FLOOR' }, { label: '混装', value: 'MIXED' }]
|
||||
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
|
||||
const vehicleSizeOptions = ['53尺大车(30托)', '26尺中车(14托)', '16尺小车(8托)', 'Cargo Van(4托)'].map((label) => ({ label, value: label }))
|
||||
const appointmentAccountOptions = [
|
||||
{ label: '4号账号(美西)', value: '4' }, { label: '8号账号(美东)', value: '8' },
|
||||
{ label: '10号账号(美中)', value: '10' }, { label: '12号账号(加拿大)', value: '12' }, { label: '买约', value: 'BUY_APPOINTMENT' },
|
||||
]
|
||||
const isBuyAppointment = computed(() => transferForm.appointmentAccount === 'BUY_APPOINTMENT')
|
||||
const needsLoadingFee = computed(() => ['FLOOR', 'MIXED'].includes(transferForm.loadingType))
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待发车', value: 'PENDING_DEPARTURE' },
|
||||
@@ -141,8 +163,18 @@ async function load(model: QueryModel = filters.value) {
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(row: DispatchListItem) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
function openTransfer(row: DispatchListItem) { transferRow.value = row; transferRemark.value = ''; transferOpen.value = true }
|
||||
async function submitTransfer() { if (!transferRow.value) return; transferSaving.value = true; try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, remark: transferRemark.value.trim() || undefined }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单') } catch (error) { ElMessage.error(getApiErrorDetails(error, '转派送失败').message) } finally { transferSaving.value = false } }
|
||||
function openTransfer(row: DispatchListItem) { transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); transferOpen.value = true }
|
||||
async function submitTransfer() {
|
||||
if (!transferRow.value) return
|
||||
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) { ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型'); return }
|
||||
if (transferForm.deliveryFee == null) { ElMessage.warning('请填写派送费用'); return }
|
||||
if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return }
|
||||
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) { ElMessage.warning('买约时请填写卖家姓名和买约费用'); return }
|
||||
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
|
||||
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, remark]) => ({ feeType, currency: 'USD', amount: Number(amount), remark }))
|
||||
transferSaving.value = true
|
||||
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单') } catch (error) { ElMessage.error(getApiErrorDetails(error, '转派送失败').message) } finally { transferSaving.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -156,5 +188,6 @@ onMounted(load)
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="备注"><el-input v-model="transferRemark" type="textarea" :rows="3" placeholder="请输入备注" /></el-form-item><el-alert title="提交后将生成一张待出库单和一张待派送单" type="info" :closable="false" /></el-form></AppDialog>
|
||||
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
|
||||
</template>
|
||||
<style scoped>.transfer-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid{grid-template-columns:1fr}}</style>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
|
||||
|
||||
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const filters = ref<QueryModel>({ keyword: '', exceptionType: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '异常单号 / 关联单号 / 客户 / 原因' },
|
||||
{ key: 'exceptionType', type: 'select', label: '异常类型', placeholder: '全部类型', options: [{ label: '退货', value: 'RETURN' }, { label: '拒收', value: 'REJECT' }, { label: '破损', value: 'DAMAGED' }, { label: '其他', value: 'OTHER' }] },
|
||||
{ key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }] },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'exceptionNo', prop: 'exceptionNo', label: '异常订单号', minWidth: 180, fixed: 'left' },
|
||||
{ key: 'related', label: '关联订单号', minWidth: 230, slotName: 'related' },
|
||||
{ key: 'exceptionType', label: '异常类型', width: 100, formatter: (row: ExceptionOrderRow) => typeLabels[row.exceptionType] || row.exceptionType },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ key: 'overseasWarehouseName', prop: 'overseasWarehouseName', label: '仓库', minWidth: 140 },
|
||||
{ key: 'reason', prop: 'reason', label: '异常原因', minWidth: 200 },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 120 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 80 },
|
||||
{ key: 'exceptionAt', label: '异常时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.exceptionAt) },
|
||||
{ key: 'status', label: '处理状态', width: 100, slotName: 'status' },
|
||||
{ key: 'handlingAction', prop: 'handlingAction', label: '处理动作', minWidth: 140 },
|
||||
{ key: 'returnLogisticsNo', prop: 'returnLogisticsNo', label: '退回物流单号', minWidth: 160 },
|
||||
{ key: 'handlerName', prop: 'handlerName', label: '处理人', minWidth: 110 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.updatedAt) },
|
||||
]
|
||||
const typeLabels: Record<string, string> = { RETURN: '退货', REJECT: '拒收', DAMAGED: '破损', OTHER: '其他' }
|
||||
const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成' }
|
||||
|
||||
const createOpen = ref(false); const saving = ref(false); const resolving = ref(false); const relation = ref<ExceptionRelation | null>(null); const relationError = ref('')
|
||||
const form = reactive({ customerId: '', inboundCode: '', lastMileOrderNo: '', exceptionType: '', reason: '', packageCount: undefined as number | undefined, skuCode: '' })
|
||||
function openCreate() { Object.assign(form, { customerId: '', inboundCode: '', lastMileOrderNo: '', exceptionType: '', reason: '', packageCount: undefined, skuCode: '' }); relation.value = null; relationError.value = ''; createOpen.value = true }
|
||||
async function resolveRelation() {
|
||||
if (!form.inboundCode.trim() && !form.lastMileOrderNo.trim()) { relation.value = null; relationError.value = '入仓码和尾程订单号至少填写一个'; return }
|
||||
resolving.value = true; relationError.value = ''
|
||||
try { relation.value = await resolveExceptionRelation({ customerId: form.customerId.trim() || undefined, inboundCode: form.inboundCode.trim() || undefined, lastMileOrderNo: form.lastMileOrderNo.trim() || undefined }); if (!form.inboundCode && relation.value.inboundCode) form.inboundCode = relation.value.inboundCode; if (!form.lastMileOrderNo && relation.value.lastMileOrderNo) form.lastMileOrderNo = relation.value.lastMileOrderNo }
|
||||
catch (cause) { relation.value = null; relationError.value = getApiErrorDetails(cause, '关联单据校验失败').message }
|
||||
finally { resolving.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
await resolveRelation(); if (!relation.value) return
|
||||
if (!form.exceptionType || !form.reason.trim()) { ElMessage.warning('请选择异常类型并填写异常原因'); return }
|
||||
saving.value = true
|
||||
try { const result = await createExceptionOrder({ customerId: form.customerId.trim() || undefined, inboundCode: form.inboundCode.trim() || undefined, lastMileOrderNo: form.lastMileOrderNo.trim() || undefined, exceptionType: form.exceptionType, reason: form.reason.trim(), packageCount: form.packageCount, skuCode: form.skuCode.trim() || undefined }); createOpen.value = false; ElMessage.success(`异常单 ${result.exceptionNo} 创建成功`); page.value = 1; await load() }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '新增异常订单失败').message) } finally { saving.value = false }
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listExceptionOrders({ page: page.value, size: pageSize.value, keyword: text('keyword'), exceptionType: text('exceptionType'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '异常订单列表加载失败') } finally { loading.value = false } }
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
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-exception-orders-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" 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-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
|
||||
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo">尾程 {{ row.lastMileOrderNo }}</span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
|
||||
<el-alert title="入仓码和尾程订单号至少填写一个;两项都填写时必须属于同一订单。" type="warning" :closable="false" />
|
||||
<el-form label-position="top" class="create-form"><div class="form-grid">
|
||||
<el-form-item label="入仓码"><el-input v-model="form.inboundCode" placeholder="请输入入仓码" @blur="resolveRelation" /></el-form-item>
|
||||
<el-form-item label="尾程订单号"><el-input v-model="form.lastMileOrderNo" placeholder="请输入尾程订单号" @blur="resolveRelation" /></el-form-item>
|
||||
<el-form-item label="异常类型" required><el-select v-model="form.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="处理状态"><el-input model-value="待处理" readonly /></el-form-item>
|
||||
<el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item>
|
||||
<el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item>
|
||||
</div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-alert v-if="relation" :title="`已关联:${relation.lastMileOrderNo || '无尾程单'} / ${relation.inboundCode || '无入仓单'},客户 ${relation.customerName || relation.customerId}`" type="success" :closable="false" />
|
||||
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.related-orders { display: grid; gap: 2px; font-size: 12px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,24 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, XCircle } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { listOutbounds, createOneClickDropshipOutbound, type OutboundRow } from '../api/outbounds'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds'
|
||||
import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
|
||||
|
||||
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<any>(null)
|
||||
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
|
||||
const columns: DataTableColumn[] = [{ key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130 }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const fields = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }] as any
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e, '出库单列表加载失败') } finally { loading.value = false } }
|
||||
async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (e) { ElMessage.error(getApiErrorDetails(e, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
async function submit() { const selected = positionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selected.balance.customerId, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '一件代发出库失败').message) } finally { saving.value = false } }
|
||||
onMounted(() => load())
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
|
||||
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
|
||||
async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
async function submit() { const selected = positionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selected.balance.customerId, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
|
||||
async function cancel(row: OutboundRow) { let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '取消出库单失败').message) } }
|
||||
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" :fields="fields" :loading="loading" ariaLabel="出库单筛选" @search="(m) => { page = 1; load(m) }" @reset="(m) => { page = 1; load(m) }" /><div class="toolbar-actions"><el-button type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></div></template><template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load" /></template><section v-else class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()" /></section></ListPageLayout></AdminPage><AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || item.position.id} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog></template>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="出库单">
|
||||
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load" /></template>
|
||||
<section v-if="!error" 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-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="row.status === 'PICKED'" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || item.position.id} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
|
||||
['InboundOrdersView.vue', 'overseas-inbound-orders-query'],
|
||||
['DispatchesView.vue', 'overseas-dispatches-query'],
|
||||
['DeliveryOrdersView.vue', 'overseas-delivery-orders-query'],
|
||||
['CustomerVisitsView.vue', 'overseas-customer-visits-query'],
|
||||
['ExceptionOrdersView.vue', 'overseas-exception-orders-query'],
|
||||
['OutboundsView.vue', 'overseas-outbounds-query'],
|
||||
['InventoryView.vue', 'overseas-inventory-query'],
|
||||
['InventoryTransactionsView.vue', 'overseas-inventory-transactions-query'],
|
||||
['RelabelOrdersView.vue', 'overseas-relabel-orders-query'],
|
||||
|
||||
@@ -10,6 +10,13 @@ export const overseasWarehousePermissions = {
|
||||
dispatchList: 'platform:overseas-warehouse:dispatch:list',
|
||||
outboundList: 'platform:overseas-warehouse:outbound:list',
|
||||
outboundCreate: 'platform:overseas-warehouse:outbound:create',
|
||||
deliveryList: 'platform:overseas-warehouse:delivery:list',
|
||||
exceptionOrderList: 'platform:overseas-warehouse:exception-order:list',
|
||||
exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create',
|
||||
deliveryReturn: 'platform:overseas-warehouse:delivery:return',
|
||||
deliveryStatus: 'platform:overseas-warehouse:delivery:status',
|
||||
customerVisitList: 'platform:overseas-warehouse:customer-visit:list',
|
||||
customerVisitEdit: 'platform:overseas-warehouse:customer-visit:edit',
|
||||
inventoryList: 'platform:overseas-warehouse:inventory:list',
|
||||
inventoryExport: 'platform:overseas-warehouse:inventory:export',
|
||||
inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
|
||||
|
||||
@@ -11,6 +11,11 @@ describe('overseas warehouse routes', () => {
|
||||
['overseas-warehouse/inbound-orders/:orderId', permission.inboundOrderList],
|
||||
['overseas-warehouse/dispatches', permission.dispatchList],
|
||||
['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList],
|
||||
['overseas-warehouse/outbounds', permission.outboundList],
|
||||
['overseas-warehouse/delivery-orders', permission.deliveryList],
|
||||
['overseas-warehouse/delivery-orders/:deliveryNo', permission.deliveryList],
|
||||
['overseas-warehouse/exception-orders', permission.exceptionOrderList],
|
||||
['overseas-warehouse/customer-visits', permission.customerVisitList],
|
||||
['overseas-warehouse/inventory', permission.inventoryList],
|
||||
['overseas-warehouse/inventory-transactions', permission.inventoryList],
|
||||
['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
|
||||
@@ -23,7 +28,7 @@ describe('overseas warehouse routes', () => {
|
||||
|
||||
it('keeps all details as formal parameterized pages', () => {
|
||||
const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
|
||||
expect(details).toHaveLength(6)
|
||||
expect(details).toHaveLength(7)
|
||||
expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -39,6 +44,8 @@ describe('overseas warehouse routes', () => {
|
||||
permission.palletCreate, permission.palletEdit,
|
||||
permission.cartonCreate, permission.cartonEdit,
|
||||
permission.productCreate, permission.productEdit,
|
||||
]).size).toBe(8)
|
||||
permission.exceptionOrderCreate, permission.deliveryReturn, permission.deliveryStatus,
|
||||
permission.customerVisitEdit,
|
||||
]).size).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,30 @@ export const overseasWarehouseRoutes = [
|
||||
component: () => import('./pages/OutboundsView.vue'),
|
||||
meta: { title: '出库单', description: '查询海外仓出库单并创建一件代发出库', permission: permission.outboundList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/delivery-orders',
|
||||
name: 'overseas-delivery-orders',
|
||||
component: () => import('./pages/DeliveryOrdersView.vue'),
|
||||
meta: { title: '派送单列表', description: '查询海外仓派送订单及履约信息', permission: permission.deliveryList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/delivery-orders/:deliveryNo',
|
||||
name: 'overseas-delivery-order-detail',
|
||||
component: () => import('./pages/DeliveryOrderDetailView.vue'),
|
||||
meta: { title: '派送单详情', description: '查看派送订单、SKU、费用和操作记录', permission: permission.deliveryList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/exception-orders',
|
||||
name: 'overseas-exception-orders',
|
||||
component: () => import('./pages/ExceptionOrdersView.vue'),
|
||||
meta: { title: '异常订单', description: '查询和新增海外仓异常、拒收及退货单据', permission: permission.exceptionOrderList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/customer-visits',
|
||||
name: 'overseas-customer-visits',
|
||||
component: () => import('./pages/CustomerVisitsView.vue'),
|
||||
meta: { title: '客户回访', description: '跟进已完成派送单并维护入仓单维度回访资料', permission: permission.customerVisitList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/inventory',
|
||||
name: 'overseas-inventory',
|
||||
|
||||
Reference in New Issue
Block a user