优化
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
|
||||
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
|
||||
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
|
||||
- 正式范围包括客户资金账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。资金账户当前只接充值申请审核和平台手工充值/扣款,冻结/解冻、消费及支付回调后续接入;不要从历史原型自动扩大其他财务功能。
|
||||
- 正式范围包括客户资金账户、充值申请审核、平台手工充值/扣款、授信额度维护、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。业务冻结/结算/释放由服务端内部接口和跨区事件调用,不在 Admin 页面复制;第三方支付回调尚未接入,不要从历史原型自动扩大其他财务功能。
|
||||
|
||||
## 客户资金账户
|
||||
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- 平台手工充值和扣款必须提交账户版本与幂等键;扣款不得超过服务端可用余额。页面只展示服务端余额和流水结果,不在浏览器补算或回退静态数据。
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、已用授信、冻结授信和总可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- 平台手工充值、扣款和授信调整必须提交账户版本与幂等键;扣款及授信下限由服务端校验。页面只展示服务端余额、授信和流水结果,不在浏览器补算或回退静态数据。
|
||||
|
||||
## 客户筛选与统计
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
@@ -128,4 +128,17 @@ describe('last-mile order overseas API', () => {
|
||||
await expect(getContainerPlanProgress('2092873463568597001/unsafe')).resolves.toEqual(detail)
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe')
|
||||
})
|
||||
|
||||
it('changes one whole container receiving warehouse with version and idempotency evidence', async () => {
|
||||
const updated = { sourceContainerBookingId: '2092873463568597001', overseasWarehouseId: '9007199254740993' }
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: updated })
|
||||
await expect(changeContainerReceivingWarehouse('2092873463568597001/unsafe', {
|
||||
overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜',
|
||||
}, 'receiving-key-1')).resolves.toEqual(updated)
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe/receiving-warehouse',
|
||||
{ overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜' },
|
||||
{ headers: { 'Idempotency-Key': 'receiving-key-1' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -171,6 +171,12 @@ export interface ContainerPlanProgressQuery {
|
||||
export interface ContainerPlanProgressSummaryDto {
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
overseasWarehouseId: string | null
|
||||
overseasWarehouseCode: string | null
|
||||
overseasWarehouseName: string | null
|
||||
assignmentVersion: number
|
||||
canChangeReceivingWarehouse: boolean
|
||||
receivingWarehouseLockReason: string | null
|
||||
totalOrderCount: number
|
||||
inboundCodeCount: number
|
||||
missingInboundCodeCount: number
|
||||
@@ -204,6 +210,12 @@ export interface ConvertContainerInboundPlanPayload {
|
||||
sourceContainerBookingId: string
|
||||
}
|
||||
|
||||
export interface ChangeContainerReceivingWarehousePayload {
|
||||
overseasWarehouseId: string
|
||||
expectedVersion: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface LastMileOrderDetailDto {
|
||||
order: LastMileOrderDto
|
||||
cargo: LastMileCargoDto[]
|
||||
@@ -286,6 +298,19 @@ export async function getContainerPlanProgress(sourceContainerBookingId: string)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changeContainerReceivingWarehouse(
|
||||
sourceContainerBookingId: string,
|
||||
payload: ChangeContainerReceivingWarehousePayload,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<ContainerPlanProgressSummaryDto>, ApiEnvelope<ContainerPlanProgressSummaryDto>>(
|
||||
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/receiving-warehouse`,
|
||||
payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
|
||||
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
|
||||
|
||||
@@ -125,6 +125,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
|
||||
const id = palletId(item)
|
||||
const target = containers.value.find((container) => container.id === containerId)
|
||||
if (!target || target.palletIds.includes(id)) return
|
||||
if (!String(item.overseasWarehouseId || '').trim()) { ElMessage.warning('该托盘尚未关联海外仓,不能排车'); return }
|
||||
if (containerFor(id)) { ElMessage.warning('该托盘已在其他装车托盘中'); return }
|
||||
if (target.palletIds.length >= target.capacity) { ElMessage.warning('该装车托盘已达到设置的托数'); return }
|
||||
const nextWeight = containerWeightLb(target) + weightLb(item)
|
||||
@@ -171,6 +172,7 @@ async function close(value = false) {
|
||||
|
||||
async function submit() {
|
||||
if (!assignedIds.value.length) { ElMessage.warning('请至少移入一个托盘'); return }
|
||||
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) { ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘'); return }
|
||||
saving.value = true; mutationError.value = null
|
||||
try {
|
||||
await preArrangeDispatch({ planPalletIds: assignedIds.value, draftToken: ensureDraftToken() })
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
it('keeps action columns first and wide enough for every visible action', () => {
|
||||
const widths: Record<string, number> = {
|
||||
'LastMileOrdersView.vue': 270,
|
||||
'InboundOrdersView.vue': 190,
|
||||
'InboundOrdersView.vue': 260,
|
||||
'DispatchesView.vue': 240,
|
||||
'OutboundsView.vue': 190,
|
||||
'DeliveryOrdersView.vue': 300,
|
||||
@@ -25,6 +25,13 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps all inbound-order row actions on one line', () => {
|
||||
const source = page('InboundOrdersView.vue')
|
||||
expect(source).toContain('<template #actions="{ row }"><div class="row-actions">')
|
||||
expect(source).toContain('.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }')
|
||||
expect(source).toContain('.row-actions :deep(.el-button + .el-button) { margin-left: 0; }')
|
||||
})
|
||||
|
||||
it('assigns explicit desktop widths to dense query inputs', () => {
|
||||
for (const name of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'InventoryView.vue']) {
|
||||
const source = page(name)
|
||||
|
||||
@@ -64,7 +64,7 @@ function selectableRow(source: object) {
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: selectableRow },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
|
||||
@@ -183,7 +183,7 @@ onMounted(() => { void load() })
|
||||
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
@@ -208,5 +208,7 @@ onMounted(() => { void load() })
|
||||
.appointment-summary { margin-bottom: 16px; padding: 10px 12px; border: 1px solid #cdddfd; border-radius: 6px; background: #f5f9ff; color: #52627a; font-size: 13px; }
|
||||
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
|
||||
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import DataTable from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
convertLastMileContainerToInboundPlans,
|
||||
convertLastMileOrderToInboundPlan,
|
||||
getContainerPlanProgress,
|
||||
@@ -21,6 +22,7 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
changeContainerReceivingWarehouse: vi.fn(),
|
||||
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
downloadLastMileOrders: vi.fn(),
|
||||
}))
|
||||
@@ -38,6 +40,8 @@ const bookingId = '2092873463568597001'
|
||||
const customerId = '2092618717861048321'
|
||||
const summary = {
|
||||
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
|
||||
overseasWarehouseId: '9007199254740995', overseasWarehouseCode: 'LAX01', overseasWarehouseName: '洛杉矶接柜仓',
|
||||
assignmentVersion: 7, canChangeReceivingWarehouse: true, receivingWarehouseLockReason: null,
|
||||
inboundCodeCount: 15, missingInboundCodeCount: 0, planCount: 10, plannedInboundCodeCount: 10,
|
||||
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false,
|
||||
blockingReason: '存在5票尚未生成有效入库计划',
|
||||
@@ -53,6 +57,10 @@ const fbaWarehouse = {
|
||||
},
|
||||
addressSummary: 'CA Ontario',
|
||||
}
|
||||
const receivingWarehouse = {
|
||||
...fbaWarehouse,
|
||||
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
|
||||
}
|
||||
function selectedRow(overrides: Record<string, unknown> = {}) {
|
||||
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
|
||||
}
|
||||
@@ -108,6 +116,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
|
||||
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
|
||||
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
||||
})
|
||||
@@ -276,6 +285,88 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(getContainerPlanProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the receiving warehouse and changes it from ACTIVE OVERSEAS master data with versioning', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([receivingWarehouse] as never)
|
||||
const wrapper = await mountView()
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('海外接柜仓')
|
||||
expect(wrapper.text()).toContain('LAX01 · 洛杉矶接柜仓')
|
||||
await button(wrapper, '修改接柜仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listAllPlatformWarehouses).toHaveBeenLastCalledWith({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
|
||||
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
|
||||
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
|
||||
await wrapper.vm.$nextTick()
|
||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(changeContainerReceivingWarehouse).toHaveBeenCalledWith(bookingId, {
|
||||
overseasWarehouseId: '9007199254740996', expectedVersion: 7, remark: undefined,
|
||||
}, expect.stringMatching(/^container-receiving-warehouse-/))
|
||||
})
|
||||
|
||||
it('does not expose a receiving-warehouse edit when the server locks the container', async () => {
|
||||
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({
|
||||
records: [{ ...summary, canChangeReceivingWarehouse: false, receivingWarehouseLockReason: '海外仓已开始拆柜收货' }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('海外仓已开始拆柜收货')
|
||||
expect(button(wrapper, '修改接柜仓')).toBeUndefined()
|
||||
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps conflict metadata visible and refreshes the stale container summary', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([receivingWarehouse] as never)
|
||||
vi.mocked(changeContainerReceivingWarehouse).mockRejectedValueOnce(new ApiResponseError(
|
||||
'version conflict', 'A0409', 200, undefined, 'request-receiving', 'trace-receiving',
|
||||
))
|
||||
const wrapper = await mountView()
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await button(wrapper, '修改接柜仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
|
||||
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
|
||||
await wrapper.vm.$nextTick()
|
||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('接柜仓信息已被其他操作更新,请刷新后重试')
|
||||
expect(wrapper.text()).toContain('request-receiving')
|
||||
expect(wrapper.text()).toContain('trace-receiving')
|
||||
expect(listContainerPlanProgress).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders an explicit empty state when there are no ACTIVE OVERSEAS receiving warehouses', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
|
||||
const wrapper = await mountView()
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await button(wrapper, '修改接柜仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('暂无可用的 ACTIVE 海外接柜仓')
|
||||
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps receiving-warehouse candidate failure metadata visible without an FBA fallback', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
|
||||
'receiving warehouse candidate failed', 'A0503', 200, undefined, 'request-receiving-options', 'trace-receiving-options',
|
||||
))
|
||||
const wrapper = await mountView()
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await button(wrapper, '修改接柜仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('request-receiving-options')
|
||||
expect(document.body.textContent).toContain('trace-receiving-options')
|
||||
expect(document.body.textContent).not.toContain('请选择目的 FBA 仓')
|
||||
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Download, Eye } from '@lucide/vue'
|
||||
import { Download, Eye, Pencil } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -50,6 +50,14 @@ const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
|
||||
const containerOptionsLoading = ref(false)
|
||||
const containerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedContainerBookingId = ref('')
|
||||
const receivingWarehouseOpen = ref(false)
|
||||
const receivingWarehouseLoading = ref(false)
|
||||
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const receivingWarehouseActionError = ref<ApiErrorDetails | null>(null)
|
||||
const receivingWarehouseOptions = ref<TransferWarehouseOption[]>([])
|
||||
const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(null)
|
||||
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
|
||||
let receivingWarehouseIdempotencyKey = ''
|
||||
let containerOptionRequest = 0
|
||||
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
|
||||
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||
@@ -112,6 +120,17 @@ function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource):
|
||||
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
|
||||
}
|
||||
}
|
||||
|
||||
function receivingWarehouseLabel(container: ContainerPlanProgressSummaryDto | null | undefined) {
|
||||
if (!container) return '—'
|
||||
const code = String(container.overseasWarehouseCode || '').trim()
|
||||
const name = String(container.overseasWarehouseName || '').trim()
|
||||
return [code, name].filter(Boolean).join(' · ') || '—'
|
||||
}
|
||||
|
||||
function newReceivingWarehouseIdempotencyKey() {
|
||||
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
@@ -256,6 +275,65 @@ async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
|
||||
containerDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
async function loadReceivingWarehouseOptions() {
|
||||
receivingWarehouseLoading.value = true
|
||||
receivingWarehouseError.value = null
|
||||
receivingWarehouseOptions.value = []
|
||||
try {
|
||||
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
|
||||
receivingWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
|
||||
} catch (error) {
|
||||
receivingWarehouseError.value = getApiErrorDetails(error, '海外接柜仓候选加载失败,请重试')
|
||||
} finally {
|
||||
receivingWarehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto) {
|
||||
if (!container.canChangeReceivingWarehouse) return
|
||||
receivingWarehouseTarget.value = container
|
||||
Object.assign(receivingWarehouseForm, { warehouseId: container.overseasWarehouseId || '', remark: '' })
|
||||
receivingWarehouseIdempotencyKey = newReceivingWarehouseIdempotencyKey()
|
||||
receivingWarehouseOptions.value = []
|
||||
receivingWarehouseError.value = null
|
||||
receivingWarehouseActionError.value = null
|
||||
actionError.value = null
|
||||
receivingWarehouseOpen.value = true
|
||||
void loadReceivingWarehouseOptions()
|
||||
}
|
||||
async function submitReceivingWarehouseChange() {
|
||||
const target = receivingWarehouseTarget.value
|
||||
const selected = receivingWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === receivingWarehouseForm.warehouseId)
|
||||
if (!target || !selected) {
|
||||
ElMessage.warning('请选择有效的海外接柜仓')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await changeContainerReceivingWarehouse(target.sourceContainerBookingId, {
|
||||
overseasWarehouseId: selected.warehouseId,
|
||||
expectedVersion: target.assignmentVersion,
|
||||
remark: receivingWarehouseForm.remark.trim() || undefined,
|
||||
}, receivingWarehouseIdempotencyKey)
|
||||
receivingWarehouseOpen.value = false
|
||||
receivingWarehouseTarget.value = null
|
||||
ElMessage.success('海外接柜仓修改成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '海外接柜仓修改失败,请重试')
|
||||
receivingWarehouseActionError.value = details.code === 'A0409'
|
||||
? { ...details, message: '接柜仓信息已被其他操作更新,请刷新后重试' }
|
||||
: details
|
||||
actionError.value = receivingWarehouseActionError.value
|
||||
if (details.code === 'A0409') {
|
||||
receivingWarehouseOpen.value = false
|
||||
receivingWarehouseTarget.value = null
|
||||
await load()
|
||||
}
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
|
||||
async function loadTransferWarehouseOptions() {
|
||||
transferWarehouseLoading.value = true
|
||||
@@ -418,6 +496,10 @@ onMounted(() => { void load() })
|
||||
<div v-if="containerRows.length" class="container-workbench__grid">
|
||||
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
|
||||
<span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong></span>
|
||||
<span class="container-workbench-card__warehouse">
|
||||
<small>海外接柜仓</small><strong>{{ receivingWarehouseLabel(container) }}</strong>
|
||||
<small v-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked">{{ container.receivingWarehouseLockReason }}</small>
|
||||
</span>
|
||||
<span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
|
||||
<span class="container-workbench-card__pending">
|
||||
<strong>{{ pendingInboundCodeCount(container) }}</strong><span>个入仓码待生成计划</span>
|
||||
@@ -427,6 +509,7 @@ onMounted(() => { void load() })
|
||||
</span>
|
||||
<span class="container-workbench-card__actions">
|
||||
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
|
||||
<el-button v-if="access.can(permission.lastMileOrderTransfer) && container.canChangeReceivingWarehouse" plain @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button>
|
||||
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">生成入库计划</el-button>
|
||||
</span>
|
||||
</article>
|
||||
@@ -479,16 +562,36 @@ onMounted(() => { void load() })
|
||||
<template v-else-if="containerDetail">
|
||||
<div class="container-detail-summary">
|
||||
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
|
||||
<span>海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
|
||||
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
|
||||
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
|
||||
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
|
||||
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
|
||||
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
|
||||
</div>
|
||||
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
|
||||
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!receivingWarehouseForm.warehouseId || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
|
||||
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
|
||||
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-form v-else label-position="top">
|
||||
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
|
||||
<el-form-item label="新的海外接柜仓" required>
|
||||
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
|
||||
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert v-if="!receivingWarehouseLoading && !receivingWarehouseOptions.length" title="暂无可用的 ACTIVE 海外接柜仓" type="info" :closable="false" show-icon />
|
||||
<el-form-item label="备注"><el-input v-model="receivingWarehouseForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入修改备注(选填)" /></el-form-item>
|
||||
<LoadErrorState v-if="receivingWarehouseActionError" title="海外接柜仓修改失败" :message="receivingWarehouseActionError.message" compact>
|
||||
<template #details><ApiErrorMeta :details="receivingWarehouseActionError" /></template>
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -510,6 +613,10 @@ onMounted(() => { void load() })
|
||||
.container-workbench-card__arrival { display: grid; gap: 4px; }
|
||||
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
|
||||
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
|
||||
.container-workbench-card__warehouse { display: grid; gap: 4px; }
|
||||
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: 12px; }
|
||||
.container-workbench-card__warehouse strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; }
|
||||
.container-workbench-card__warehouse .is-locked { color: #c2410c; }
|
||||
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
|
||||
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
|
||||
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
|
||||
|
||||
@@ -247,6 +247,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(dialog).not.toContain(':model-value="fbaWarehouseCode" readonly')
|
||||
expect(dialog).toContain("sourceStage: 'RECEIVED'")
|
||||
expect(dialog).toContain('一次排车只能装同一客户的托盘')
|
||||
expect(dialog).toContain('该托盘尚未关联海外仓,不能排车')
|
||||
expect(dialog).toContain('所选托盘中存在尚未关联海外仓的托盘')
|
||||
expect(dialog).toContain('function ensureDraftToken()')
|
||||
expect(dialog).toContain('lockDispatchDraftPallet(id, ensureDraftToken(), target.id)')
|
||||
expect(dialog).toContain('function hydrateDraftPallets()')
|
||||
|
||||
Reference in New Issue
Block a user