feat(admin): support indivisible coload groups

This commit is contained in:
2026-08-29 10:57:01 +08:00
parent aee562d998
commit 1aa2dfcdff
13 changed files with 621 additions and 38 deletions
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { batchQueryPlatformCoLoadGroups, cancelPlatformCoLoadGroup, createPlatformCoLoadGroup } from './coLoadGroups'
const group = {
groupId: 2093000000000000001n, groupNo: 'CLG-2093000000000000001', status: 'ACTIVE',
warehouseId: 2093000000000000002n, channelCode: 'NY-EXX', destinationType: 'WAREHOUSE',
destinationWarehouseId: 2093000000000000003n, destinationCode: 'ONT8', lockedPlanId: null,
version: 0, fullPackageCount: 18, createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:00:00',
members: [
{ tenantId: 2093000000000000004n, orderId: 2093000000000000005n, inboundId: 2093000000000000006n, fullPackageCount: 8 },
{ tenantId: 2093000000000000007n, orderId: 2093000000000000008n, inboundId: 2093000000000000009n, fullPackageCount: 10 },
],
}
describe('co-load group API', () => {
beforeEach(() => vi.restoreAllMocks())
it('creates one group from two stable order ids with idempotency', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: group })
const orderIds = ['2093000000000000005', '2093000000000000008']
const result = await createPlatformCoLoadGroup(orderIds)
expect(post).toHaveBeenCalledWith('/platform/v1/first-leg/coload-groups', { orderIds }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(result.groupId).toBe('2093000000000000001')
expect(result.members.map((member) => member.orderId)).toEqual(orderIds)
expect(result.members.map((member) => member.tenantId)).toEqual(['2093000000000000004', '2093000000000000007'])
})
it('cancels only the whole group with optimistic version and an optional reason', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { ...group, status: 'CANCELLED', version: 1 } })
await cancelPlatformCoLoadGroup('2093000000000000001', 0, '路线调整')
expect(post).toHaveBeenCalledWith(
'/platform/v1/first-leg/coload-groups/2093000000000000001/cancel',
{ expectedVersion: 0, reason: '路线调整' },
{ headers: { 'Idempotency-Key': expect.any(String) } },
)
})
it('batch-enriches three list rows and normalizes every long id without numeric coercion', async () => {
const third = { ...group.members[0], orderId: 2093000000000000010n, inboundId: 2093000000000000011n }
const threeMemberGroup = { ...group, fullPackageCount: 23, members: [...group.members, third] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: threeMemberGroup.members.map((member) => ({ orderId: member.orderId, inboundId: member.inboundId, group: threeMemberGroup })) })
const orderIds = ['2093000000000000005', '2093000000000000008', '2093000000000000010']
const result = await batchQueryPlatformCoLoadGroups(orderIds)
expect(post).toHaveBeenCalledWith('/platform/v1/first-leg/coload-groups/batch-query', { orderIds })
expect(result).toHaveLength(3)
expect(result[2]).toMatchObject({ orderId: '2093000000000000010', inboundId: '2093000000000000011', group: { groupId: '2093000000000000001', fullPackageCount: 23 } })
})
})
@@ -0,0 +1,100 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type CoLoadGroupStatus = 'ACTIVE' | 'LOCKED' | 'CONSUMED' | 'CANCELLED'
export interface CoLoadGroupMember {
tenantId: string
orderId: string
inboundId: string
fullPackageCount: number
}
export interface CoLoadGroup {
groupId: string
groupNo: string
status: CoLoadGroupStatus
warehouseId: string
channelCode: string
destinationType: string
destinationWarehouseId: string | null
destinationCode: string | null
lockedPlanId: string | null
version: number
members: CoLoadGroupMember[]
fullPackageCount: number
createdAt: string
updatedAt: string
}
export interface OrderCoLoadGroupInfo {
orderId: string
inboundId: string
group: CoLoadGroup
}
type RawId = string | number
type RawCoLoadGroup = Omit<CoLoadGroup, 'groupId' | 'warehouseId' | 'destinationWarehouseId' | 'lockedPlanId' | 'members'> & {
groupId: RawId
warehouseId: RawId
destinationWarehouseId: RawId | null
lockedPlanId: RawId | null
members: Array<Omit<CoLoadGroupMember, 'tenantId' | 'orderId' | 'inboundId'> & { tenantId: RawId; orderId: RawId; inboundId: RawId }>
}
type RawOrderCoLoadGroupInfo = Omit<OrderCoLoadGroupInfo, 'orderId' | 'inboundId' | 'group'> & {
orderId: RawId
inboundId: RawId
group: RawCoLoadGroup
}
const basePath = '/platform/v1/first-leg/coload-groups'
const optionalId = (value: RawId | null) => value == null ? null : String(value)
const operationId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
function normalizeGroup(raw: RawCoLoadGroup): CoLoadGroup {
return {
...raw,
groupId: String(raw.groupId),
warehouseId: String(raw.warehouseId),
destinationWarehouseId: optionalId(raw.destinationWarehouseId),
lockedPlanId: optionalId(raw.lockedPlanId),
version: Number(raw.version),
fullPackageCount: Number(raw.fullPackageCount),
members: (raw.members || []).map((member) => ({
...member,
tenantId: String(member.tenantId),
orderId: String(member.orderId),
inboundId: String(member.inboundId),
fullPackageCount: Number(member.fullPackageCount),
})),
}
}
export async function createPlatformCoLoadGroup(orderIds: string[]) {
const response = await http.post<ApiEnvelope<RawCoLoadGroup>, ApiEnvelope<RawCoLoadGroup>>(
basePath,
{ orderIds },
{ headers: { 'Idempotency-Key': operationId('admin-coload-create') } },
)
return normalizeGroup(unwrapApiResponse(response))
}
export async function cancelPlatformCoLoadGroup(groupId: string, expectedVersion: number, reason?: string | null) {
const response = await http.post<ApiEnvelope<RawCoLoadGroup>, ApiEnvelope<RawCoLoadGroup>>(
`${basePath}/${encodeURIComponent(groupId)}/cancel`,
{ expectedVersion, reason: reason?.trim() || null },
{ headers: { 'Idempotency-Key': operationId('admin-coload-cancel') } },
)
return normalizeGroup(unwrapApiResponse(response))
}
export async function batchQueryPlatformCoLoadGroups(orderIds: string[]) {
const response = await http.post<ApiEnvelope<RawOrderCoLoadGroupInfo[]>, ApiEnvelope<RawOrderCoLoadGroupInfo[]>>(
`${basePath}/batch-query`,
{ orderIds },
)
return unwrapApiResponse(response).map((item) => ({
orderId: String(item.orderId),
inboundId: String(item.inboundId),
group: normalizeGroup(item.group),
}))
}