feat: confirm container inbound plans
This commit is contained in:
@@ -114,11 +114,17 @@ describe('last-mile order overseas API', () => {
|
|||||||
|
|
||||||
it('uses one atomic container request without looping individual conversions', async () => {
|
it('uses one atomic container request without looping individual conversions', async () => {
|
||||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] })
|
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] })
|
||||||
await expect(convertLastMileContainerToInboundPlans({ sourceContainerBookingId: '2092873463568597001' })).resolves.toEqual([])
|
await expect(convertLastMileContainerToInboundPlans({
|
||||||
|
sourceContainerBookingId: '2092873463568597001',
|
||||||
|
snapshotVersion: 9,
|
||||||
|
snapshotHash: 'a'.repeat(64),
|
||||||
|
}, 'container-plan-idempotency-key')).resolves.toEqual([])
|
||||||
expect(overseasHttp.post).toHaveBeenCalledTimes(1)
|
expect(overseasHttp.post).toHaveBeenCalledTimes(1)
|
||||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', {
|
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', {
|
||||||
sourceContainerBookingId: '2092873463568597001',
|
sourceContainerBookingId: '2092873463568597001',
|
||||||
})
|
snapshotVersion: 9,
|
||||||
|
snapshotHash: 'a'.repeat(64),
|
||||||
|
}, { headers: { 'Idempotency-Key': 'container-plan-idempotency-key' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads server-side container progress with pagination and filters', async () => {
|
it('loads server-side container progress with pagination and filters', async () => {
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ export interface ContainerPlanProgressDetailDto {
|
|||||||
|
|
||||||
export interface ConvertContainerInboundPlanPayload {
|
export interface ConvertContainerInboundPlanPayload {
|
||||||
sourceContainerBookingId: string
|
sourceContainerBookingId: string
|
||||||
|
snapshotVersion: number
|
||||||
|
snapshotHash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED'
|
export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED'
|
||||||
@@ -460,9 +462,10 @@ export async function getContainerInboundPlanPreview(sourceContainerBookingId: s
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) {
|
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload, idempotencyKey: string) {
|
||||||
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
|
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
|
||||||
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
|
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
|
||||||
|
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
changeContainerReceivingWarehouse,
|
changeContainerReceivingWarehouse,
|
||||||
createManualInboundPlan,
|
createManualInboundPlan,
|
||||||
convertLastMileContainerToInboundPlans,
|
convertLastMileContainerToInboundPlans,
|
||||||
|
getContainerInboundPlanPreview,
|
||||||
getContainerPlanProgress,
|
getContainerPlanProgress,
|
||||||
listContainerPlanProgress,
|
listContainerPlanProgress,
|
||||||
listLastMileOrders,
|
listLastMileOrders,
|
||||||
@@ -30,7 +31,7 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
|||||||
vi.mock('../api/lastMileOrders', () => ({
|
vi.mock('../api/lastMileOrders', () => ({
|
||||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||||
changeContainerReceivingWarehouse: vi.fn(),
|
changeContainerReceivingWarehouse: vi.fn(),
|
||||||
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
|
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||||
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
||||||
downloadLastMileOrders: vi.fn(),
|
downloadLastMileOrders: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -65,6 +66,26 @@ const fbaWarehouse = {
|
|||||||
},
|
},
|
||||||
addressSummary: 'CA Ontario',
|
addressSummary: 'CA Ontario',
|
||||||
}
|
}
|
||||||
|
const preview = {
|
||||||
|
sourceContainerBookingId: bookingId, containerNo: 'CONT-15',
|
||||||
|
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
|
||||||
|
overseasWarehouseName: summary.overseasWarehouseName, snapshotVersion: 9, snapshotHash: 'a'.repeat(64),
|
||||||
|
totalOrderCount: 5, readyOrderCount: 5, blockedOrderCount: 0, pendingOrderCount: 5,
|
||||||
|
pendingPalletCount: 5, existingPlannedPalletCount: 0, totalSuggestedPalletCount: 5,
|
||||||
|
totalPlannedPalletCount: 0, ready: true,
|
||||||
|
groups: [{
|
||||||
|
destinationType: 'FBA', destinationWarehouseId: null, destinationWarehouseCode: 'ONT8',
|
||||||
|
destinationWarehouseName: 'Ontario FBA', orderCount: 5, pendingOrderCount: 5, pendingPalletCount: 5,
|
||||||
|
existingPlannedPalletCount: 0, suggestedPalletCount: 5, plannedPalletCount: 0,
|
||||||
|
orders: [{
|
||||||
|
customerId, customerName: '测试客户', orderId: '2092873463568596994', businessOrderNo: 'LMO-1',
|
||||||
|
inboundCode: 'IN-1', orderType: 'FIRST_LEG', destinationType: 'FBA', destinationWarehouseId: null,
|
||||||
|
destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Ontario FBA', packageCount: 10,
|
||||||
|
volumeCbm: 1, suggestedPalletCount: 5, plannedPalletCount: null, existingPlanNo: null,
|
||||||
|
planStatus: null, orderVersion: 3, readiness: 'READY', blockers: [],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
const receivingWarehouse = {
|
const receivingWarehouse = {
|
||||||
...fbaWarehouse,
|
...fbaWarehouse,
|
||||||
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
|
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
|
||||||
@@ -118,6 +139,8 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
|||||||
const containerSelect = selects[selects.length - 1]
|
const containerSelect = selects[selects.length - 1]
|
||||||
expect(containerSelect).toBeDefined()
|
expect(containerSelect).toBeDefined()
|
||||||
containerSelect.vm.$emit('update:modelValue', bookingId)
|
containerSelect.vm.$emit('update:modelValue', bookingId)
|
||||||
|
containerSelect.vm.$emit('change', bookingId)
|
||||||
|
await flushPromises()
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +156,14 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
||||||
})
|
})
|
||||||
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||||
|
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
|
||||||
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
|
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
|
||||||
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
|
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
|
||||||
vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 })
|
vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 })
|
||||||
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
|
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([
|
||||||
|
{ planNo: 'LIP-1', pallets: [{ id: 'PALLET-1', palletNo: 'PLT-001' }] },
|
||||||
|
{ planNo: 'LIP-2', pallets: [{ id: 'PALLET-2', palletNo: 'PLT-002' }] },
|
||||||
|
] as never)
|
||||||
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
|
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
|
||||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
|
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
|
||||||
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
||||||
@@ -253,7 +280,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
|
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
|
||||||
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
|
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
|
||||||
expect(document.body.textContent).toContain('选择待分配柜')
|
expect(document.body.textContent).toContain('选择待分配柜')
|
||||||
expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划')
|
expect(document.body.textContent).toContain('确认后整柜只提交一次生成命令')
|
||||||
|
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
|
||||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,14 +289,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
const wrapper = await openPending()
|
const wrapper = await openPending()
|
||||||
await openContainerDialog(wrapper)
|
await openContainerDialog(wrapper)
|
||||||
await chooseContainer(wrapper)
|
await chooseContainer(wrapper)
|
||||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
|
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
|
||||||
expect(confirm.disabled).toBe(false)
|
expect(confirm.disabled).toBe(false)
|
||||||
confirm.click()
|
confirm.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
|
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
|
||||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
|
expect(getContainerInboundPlanPreview).toHaveBeenCalledTimes(2)
|
||||||
|
expect(getContainerInboundPlanPreview).toHaveBeenNthCalledWith(1, bookingId)
|
||||||
|
expect(getContainerInboundPlanPreview).toHaveBeenNthCalledWith(2, bookingId)
|
||||||
|
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({
|
||||||
|
sourceContainerBookingId: bookingId,
|
||||||
|
snapshotVersion: 9,
|
||||||
|
snapshotHash: 'a'.repeat(64),
|
||||||
|
}, expect.stringContaining('container-inbound-plan-'))
|
||||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||||
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
|
expect(document.body.textContent).toContain('新建 2 个 / 已有 0 个')
|
||||||
|
expect(document.body.textContent).toContain('PLT-001')
|
||||||
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
|
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -279,7 +315,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
const wrapper = await openPending()
|
const wrapper = await openPending()
|
||||||
await openContainerDialog(wrapper)
|
await openContainerDialog(wrapper)
|
||||||
await chooseContainer(wrapper)
|
await chooseContainer(wrapper)
|
||||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
|
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
|
||||||
confirm.click()
|
confirm.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
|
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
|
||||||
@@ -288,6 +324,51 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
expect(wrapper.text()).toContain(`trace-${code}`)
|
expect(wrapper.text()).toContain(`trace-${code}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the latest cabinet preview when an older request resolves last', async () => {
|
||||||
|
let resolveFirst!: (value: typeof preview) => void
|
||||||
|
let resolveSecond!: (value: typeof preview) => void
|
||||||
|
vi.mocked(getContainerInboundPlanPreview).mockImplementation((id) => new Promise((resolve) => {
|
||||||
|
if (id === bookingId) resolveFirst = resolve
|
||||||
|
else resolveSecond = resolve
|
||||||
|
}) as never)
|
||||||
|
const wrapper = await openPending()
|
||||||
|
await openContainerDialog(wrapper)
|
||||||
|
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||||
|
const containerSelect = selects[selects.length - 1]
|
||||||
|
containerSelect.vm.$emit('update:modelValue', bookingId)
|
||||||
|
containerSelect.vm.$emit('change', bookingId)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
containerSelect.vm.$emit('update:modelValue', '2092873463568597002')
|
||||||
|
containerSelect.vm.$emit('change', '2092873463568597002')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
resolveSecond({ ...preview, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NEW', pendingPalletCount: 2 })
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('.container-preview__identity')?.textContent).toContain('CONT-NEW')
|
||||||
|
resolveFirst(preview)
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('.container-preview__identity')?.textContent).toContain('CONT-NEW')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses the same idempotency key when an uncertain confirmation is retried', async () => {
|
||||||
|
vi.mocked(convertLastMileContainerToInboundPlans)
|
||||||
|
.mockRejectedValueOnce(new Error('network uncertain'))
|
||||||
|
.mockResolvedValueOnce([{ planNo: 'LIP-RETRY', pallets: [{ id: 'PALLET-R', palletNo: 'PLT-RETRY' }] }] as never)
|
||||||
|
const wrapper = await openPending()
|
||||||
|
await openContainerDialog(wrapper)
|
||||||
|
await chooseContainer(wrapper)
|
||||||
|
const findConfirm = () => [...document.body.querySelectorAll('button')]
|
||||||
|
.find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
|
||||||
|
findConfirm().click()
|
||||||
|
await flushPromises()
|
||||||
|
findConfirm().click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2)
|
||||||
|
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0][1]).toBe(
|
||||||
|
vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1][1],
|
||||||
|
)
|
||||||
|
expect(document.body.textContent).toContain('PLT-RETRY')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders all cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => {
|
it('renders all cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => {
|
||||||
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
|
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
|
||||||
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 })
|
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 })
|
||||||
@@ -369,12 +450,16 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(document.body.textContent).toContain('确认后将为该柜剩余 5 个入仓码分别生成独立入库计划')
|
expect(document.body.textContent).toContain('预览已就绪,本次将新增 5 个计划、5 个托盘码')
|
||||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
|
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
|
||||||
expect(confirm.disabled).toBe(false)
|
expect(confirm.disabled).toBe(false)
|
||||||
confirm.click()
|
confirm.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
|
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({
|
||||||
|
sourceContainerBookingId: bookingId,
|
||||||
|
snapshotVersion: 9,
|
||||||
|
snapshotHash: 'a'.repeat(64),
|
||||||
|
}, expect.stringContaining('container-inbound-plan-'))
|
||||||
expect(getContainerPlanProgress).not.toHaveBeenCalled()
|
expect(getContainerPlanProgress).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Download, Eye, Pencil } from '@lucide/vue'
|
import { Download, Eye, Pencil } from '@lucide/vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { changeContainerReceivingWarehouse, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||||
@@ -75,6 +75,22 @@ const selectedContainerBookingId = ref('')
|
|||||||
const containerPreview = ref<ContainerInboundPlanPreviewDto | null>(null)
|
const containerPreview = ref<ContainerInboundPlanPreviewDto | null>(null)
|
||||||
const containerPreviewLoading = ref(false)
|
const containerPreviewLoading = ref(false)
|
||||||
const containerPreviewError = ref('')
|
const containerPreviewError = ref('')
|
||||||
|
type ContainerConversionPlan = Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>[number]
|
||||||
|
interface ContainerConversionResult {
|
||||||
|
plans: ContainerConversionPlan[]
|
||||||
|
existingPlanNos: Set<string>
|
||||||
|
newPlanCount: number
|
||||||
|
existingPlanCount: number
|
||||||
|
}
|
||||||
|
const containerConversionResult = ref<ContainerConversionResult | null>(null)
|
||||||
|
let containerPreviewRequest = 0
|
||||||
|
let containerPreviewLoadingBookingId = ''
|
||||||
|
let containerConversionIdempotencyKey = ''
|
||||||
|
const containerConfirmDisabled = computed(() => !containerPreview.value?.ready
|
||||||
|
|| containerPreview.value.pendingOrderCount <= 0
|
||||||
|
|| containerPreviewLoading.value
|
||||||
|
|| actionLoading.value
|
||||||
|
|| Boolean(containerConversionResult.value))
|
||||||
const receivingWarehouseOpen = ref(false)
|
const receivingWarehouseOpen = ref(false)
|
||||||
const receivingWarehouseLoading = ref(false)
|
const receivingWarehouseLoading = ref(false)
|
||||||
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
|
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -548,6 +564,8 @@ function openContainerConversion() {
|
|||||||
containerLoadError.value = null
|
containerLoadError.value = null
|
||||||
containerPreview.value = null
|
containerPreview.value = null
|
||||||
containerPreviewError.value = ''
|
containerPreviewError.value = ''
|
||||||
|
containerConversionResult.value = null
|
||||||
|
containerConversionIdempotencyKey = ''
|
||||||
actionError.value = null
|
actionError.value = null
|
||||||
containerConvertOpen.value = true
|
containerConvertOpen.value = true
|
||||||
void loadContainerOptions()
|
void loadContainerOptions()
|
||||||
@@ -558,21 +576,76 @@ function openContainerConversionFor(container: ContainerPlanProgressSummaryDto)
|
|||||||
containerLoadError.value = null
|
containerLoadError.value = null
|
||||||
containerPreview.value = null
|
containerPreview.value = null
|
||||||
containerPreviewError.value = ''
|
containerPreviewError.value = ''
|
||||||
|
containerConversionResult.value = null
|
||||||
|
containerConversionIdempotencyKey = ''
|
||||||
actionError.value = null
|
actionError.value = null
|
||||||
containerConvertOpen.value = true
|
containerConvertOpen.value = true
|
||||||
void loadContainerPreview(container.sourceContainerBookingId)
|
void loadContainerPreview(container.sourceContainerBookingId)
|
||||||
}
|
}
|
||||||
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value) {
|
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value, preserveResult = false) {
|
||||||
|
if (containerPreviewLoading.value && containerPreviewLoadingBookingId === sourceContainerBookingId) return
|
||||||
|
if (containerPreview.value?.sourceContainerBookingId === sourceContainerBookingId) return
|
||||||
|
const request = ++containerPreviewRequest
|
||||||
containerPreview.value = null
|
containerPreview.value = null
|
||||||
containerPreviewError.value = ''
|
containerPreviewError.value = ''
|
||||||
if (!sourceContainerBookingId) return
|
if (!preserveResult) {
|
||||||
|
containerConversionResult.value = null
|
||||||
|
containerConversionIdempotencyKey = ''
|
||||||
|
}
|
||||||
|
if (!sourceContainerBookingId) {
|
||||||
|
containerPreviewLoading.value = false
|
||||||
|
containerPreviewLoadingBookingId = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
containerPreviewLoading.value = true
|
containerPreviewLoading.value = true
|
||||||
|
containerPreviewLoadingBookingId = sourceContainerBookingId
|
||||||
try {
|
try {
|
||||||
containerPreview.value = await getContainerInboundPlanPreview(sourceContainerBookingId)
|
const preview = await getContainerInboundPlanPreview(sourceContainerBookingId)
|
||||||
|
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
|
||||||
|
containerPreview.value = preview
|
||||||
|
if (!preserveResult) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
|
||||||
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
|
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
|
||||||
} finally {
|
} finally {
|
||||||
containerPreviewLoading.value = false
|
if (request === containerPreviewRequest) {
|
||||||
|
containerPreviewLoading.value = false
|
||||||
|
containerPreviewLoadingBookingId = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmContainerInboundPlans() {
|
||||||
|
const preview = containerPreview.value
|
||||||
|
if (!preview?.ready || preview.pendingOrderCount <= 0 || actionLoading.value) return
|
||||||
|
if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
|
||||||
|
const bookingId = preview.sourceContainerBookingId
|
||||||
|
const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders)
|
||||||
|
.filter((order) => order.readiness === 'PLANNED' && order.existingPlanNo)
|
||||||
|
.map((order) => String(order.existingPlanNo)))
|
||||||
|
actionLoading.value = true
|
||||||
|
actionError.value = null
|
||||||
|
try {
|
||||||
|
const plans = await convertLastMileContainerToInboundPlans({
|
||||||
|
sourceContainerBookingId: bookingId,
|
||||||
|
snapshotVersion: preview.snapshotVersion,
|
||||||
|
snapshotHash: preview.snapshotHash,
|
||||||
|
}, containerConversionIdempotencyKey)
|
||||||
|
if (selectedContainerBookingId.value !== bookingId || !containerConvertOpen.value) return
|
||||||
|
const existingPlanCount = plans.filter((plan) => existingPlanNos.has(plan.planNo)).length
|
||||||
|
containerConversionResult.value = {
|
||||||
|
plans,
|
||||||
|
existingPlanNos,
|
||||||
|
newPlanCount: plans.length - existingPlanCount,
|
||||||
|
existingPlanCount,
|
||||||
|
}
|
||||||
|
ElMessage.success(`已生成 ${plans.length - existingPlanCount} 个入库计划`)
|
||||||
|
containerPreview.value = null
|
||||||
|
await Promise.all([load(), loadContainerPreview(bookingId, true)])
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = getApiErrorDetails(error, '按柜生成入库计划失败,请使用原操作重试')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,10 +688,22 @@ function blockerText(blockers: string[]) {
|
|||||||
DESTINATION_NOT_READY: '目的地未就绪',
|
DESTINATION_NOT_READY: '目的地未就绪',
|
||||||
CARGO_FACT_INVALID: '货物数据无效',
|
CARGO_FACT_INVALID: '货物数据无效',
|
||||||
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
|
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
|
||||||
|
PALLET_COUNT_NOT_READY: '计划托数暂未就绪',
|
||||||
}
|
}
|
||||||
return blockers.map((code) => labels[code] || code).join(';') || '—'
|
return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(containerConvertOpen, (open) => {
|
||||||
|
if (open) return
|
||||||
|
containerPreviewRequest += 1
|
||||||
|
containerPreviewLoadingBookingId = ''
|
||||||
|
containerPreviewLoading.value = false
|
||||||
|
containerPreview.value = null
|
||||||
|
containerPreviewError.value = ''
|
||||||
|
containerConversionResult.value = null
|
||||||
|
containerConversionIdempotencyKey = ''
|
||||||
|
})
|
||||||
|
|
||||||
function openDetail(source: object) {
|
function openDetail(source: object) {
|
||||||
const row = source as LastMileOrderListItem
|
const row = source as LastMileOrderListItem
|
||||||
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
|
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
|
||||||
@@ -703,14 +788,14 @@ onMounted(() => { void load() })
|
|||||||
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
<AppDialog v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" confirm-text="确认生成(接口待就绪)" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-disabled="true">
|
<AppDialog v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
|
||||||
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因。当前仅开放预览,确认接口就绪前不会提交生成请求。</p>
|
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
|
||||||
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
|
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
|
||||||
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
|
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<el-form v-else label-position="top">
|
<el-form v-else label-position="top">
|
||||||
<el-form-item label="选择待分配柜" required>
|
<el-form-item label="选择待分配柜" required>
|
||||||
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
|
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
|
||||||
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
|
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -728,7 +813,17 @@ onMounted(() => { void load() })
|
|||||||
<div class="container-preview__metric" :class="{ 'container-preview__metric--danger': containerPreview.blockedOrderCount > 0 }"><span>阻断订单</span><strong>{{ containerPreview.blockedOrderCount }}</strong></div>
|
<div class="container-preview__metric" :class="{ 'container-preview__metric--danger': containerPreview.blockedOrderCount > 0 }"><span>阻断订单</span><strong>{{ containerPreview.blockedOrderCount }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
|
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
|
||||||
<el-alert v-else class="container-convert-alert" type="warning" :closable="false" show-icon title="预览已就绪;确认接口完成接入前,暂不开放提交。" />
|
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪,本次将新增 ${containerPreview.pendingOrderCount} 个计划、${containerPreview.pendingPalletCount} 个托盘码。`" />
|
||||||
|
<section v-if="containerConversionResult" class="container-conversion-result">
|
||||||
|
<header><strong>生成完成</strong><span>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
|
||||||
|
<div class="container-conversion-result__plans">
|
||||||
|
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
|
||||||
|
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
|
||||||
|
<p v-if="plan.pallets.length"><span v-for="pallet in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet" :title="pallet.palletNo || '托盘码暂未返回'">{{ pallet.palletNo || '—' }}</span></p>
|
||||||
|
<p v-else class="container-conversion-result__empty">暂无托盘码</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="container-preview__scroll">
|
<div class="container-preview__scroll">
|
||||||
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
|
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
|
||||||
<header class="container-preview__group-header">
|
<header class="container-preview__group-header">
|
||||||
@@ -839,6 +934,14 @@ onMounted(() => { void load() })
|
|||||||
.container-preview__group + .container-preview__group { margin-top: 16px; }
|
.container-preview__group + .container-preview__group { margin-top: 16px; }
|
||||||
.container-preview__group-header { min-height: 42px; padding: 0 14px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
|
.container-preview__group-header { min-height: 42px; padding: 0 14px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
|
||||||
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
|
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
|
||||||
|
.container-conversion-result { margin-top: 14px; padding: 14px; border: 1px solid #86efac; border-radius: 8px; background: #f0fdf4; }
|
||||||
|
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #166534; }
|
||||||
|
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||||
|
.container-conversion-result__plan { min-width: 0; padding: 10px 12px; border: 1px solid #bbf7d0; border-radius: 6px; background: #fff; }
|
||||||
|
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
|
||||||
|
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: 4px; background: #dcfce7; color: #166534; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.container-conversion-result__empty { color: var(--cc-muted); font-size: 12px; }
|
||||||
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
|
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
|
||||||
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|||||||
@@ -20,9 +20,28 @@ describe('container inbound plan preview contract', () => {
|
|||||||
expect(pageSource).not.toContain('containerPreview.groups.sort(')
|
expect(pageSource).not.toContain('containerPreview.groups.sort(')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requests one preview per selection and does not submit the legacy conversion command', () => {
|
it('requests one preview per selection and submits one atomic snapshot command', () => {
|
||||||
expect(pageSource.match(/await getContainerInboundPlanPreview\(sourceContainerBookingId\)/g)).toHaveLength(1)
|
expect(pageSource.match(/await getContainerInboundPlanPreview\(sourceContainerBookingId\)/g)).toHaveLength(1)
|
||||||
expect(pageSource).not.toContain('convertLastMileContainerToInboundPlans')
|
expect(pageSource).toContain('containerPreviewLoadingBookingId === sourceContainerBookingId')
|
||||||
expect(pageSource).toContain(':confirm-disabled="true"')
|
expect(pageSource).toContain('request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value')
|
||||||
|
expect(pageSource).toContain('containerPreviewRequest += 1')
|
||||||
|
expect(pageSource.match(/await convertLastMileContainerToInboundPlans\(\{/g)).toHaveLength(1)
|
||||||
|
expect(pageSource).toContain('snapshotVersion: preview.snapshotVersion')
|
||||||
|
expect(pageSource).toContain('snapshotHash: preview.snapshotHash')
|
||||||
|
expect(pageSource).toContain('containerConversionIdempotencyKey')
|
||||||
|
expect(pageSource).toContain(':confirm-disabled="containerConfirmDisabled"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows returned plan numbers and real pallet numbers then refreshes the page and preview', () => {
|
||||||
|
expect(pageSource).toContain('containerConversionResult.plans')
|
||||||
|
expect(pageSource).toContain('plan.pallets')
|
||||||
|
expect(pageSource).toContain('pallet.palletNo')
|
||||||
|
expect(pageSource).toContain('Promise.all([load(), loadContainerPreview(bookingId, true)])')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates known blockers and never exposes unknown internal blocker codes', () => {
|
||||||
|
expect(pageSource).toContain("PALLET_COUNT_NOT_READY: '计划托数暂未就绪'")
|
||||||
|
expect(pageSource).toContain("labels[code] || '计划数据暂未就绪,请刷新或联系管理员'")
|
||||||
|
expect(pageSource).not.toContain('labels[code] || code')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user