From d95c90bd1a060d910547f1fd59ea5b24abde14cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 16:45:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/platformContainerLoading.test.ts | 10 +- .../api/platformContainerLoading.ts | 13 +- .../pages/GlobalSmartLoadingView.test.ts | 62 +++- .../pages/GlobalSmartLoadingView.vue | 64 ++-- .../pages/SmartLoadingWorkbenchView.test.ts | 25 +- .../pages/SmartLoadingWorkbenchView.vue | 41 +-- src/modules/container-loading/types.ts | 11 + src/modules/finance/api/finance.test.ts | 15 +- src/modules/finance/api/finance.ts | 12 +- .../pages/FinanceCustomerAccountsView.test.ts | 22 +- .../pages/FinanceCustomerAccountsView.vue | 287 ++++++++++++++++- .../pages/FinanceCustomerArapView.test.ts | 2 +- .../finance/pages/FinanceCustomerArapView.vue | 295 +++++++++++++++--- .../FinanceCustomerStatementsView.test.ts | 65 ++-- .../pages/FinanceCustomerStatementsView.vue | 227 +++++++------- .../FinanceFormalSettlementLayout.test.ts | 3 + .../pages/FinanceFormalSettlementPage.vue | 116 +++++-- .../pages/FinanceG3G4Operations.test.ts | 8 +- .../finance/pages/FinanceG3G4Views.test.ts | 8 +- .../finance/pages/FinanceManualPage.test.ts | 5 +- .../finance/pages/FinanceManualPage.vue | 5 +- .../finance/pages/FinancePresentation.test.ts | 40 ++- src/modules/finance/routes.ts | 1 + src/modules/finance/status.test.ts | 14 +- src/modules/finance/status.ts | 27 +- src/modules/finance/types.ts | 12 +- .../ocean-bookings/bookingAgents.test.ts | 15 + src/modules/ocean-bookings/bookingAgents.ts | 9 + .../pages/OceanBookingDetailView.test.ts | 13 +- .../pages/OceanBookingDetailView.vue | 18 +- .../suppliers/api/platformSuppliers.test.ts | 7 +- .../suppliers/api/platformSuppliers.ts | 2 +- .../suppliers/pages/SupplierDetailView.vue | 4 +- .../suppliers/pages/SupplierFormView.vue | 11 +- .../pages/SupplierRouteReuse.behavior.test.ts | 2 +- .../suppliers/pages/SuppliersView.test.ts | 5 + src/modules/suppliers/pages/SuppliersView.vue | 13 +- src/modules/suppliers/types.ts | 5 - .../trucking/api/platformTrucking.test.ts | 13 +- src/modules/trucking/api/platformTrucking.ts | 12 +- src/shared/components/AdminPage.vue | 8 + src/shared/components/DataTable.vue | 17 + src/shared/components/DetailPageLayout.vue | 4 + src/shared/components/FormSection.vue | 7 + src/shared/components/ListPageLayout.vue | 4 + src/shared/components/PageToolbar.vue | 5 + src/shared/components/QueryToolbar.vue | 7 + src/styles/index.css | 20 ++ 48 files changed, 1217 insertions(+), 374 deletions(-) create mode 100644 src/modules/ocean-bookings/bookingAgents.test.ts create mode 100644 src/modules/ocean-bookings/bookingAgents.ts diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index ee354726..32a14e33 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -16,7 +16,12 @@ vi.mock('@/shared/api/http', () => ({ })) const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '76', maxWeightKg: '28000' } -const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] } +const plan = { + id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING', + canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', + pdaTaskId: 9007199254740996, pdaTaskStatus: 'LOADING', pdaTaskVersion: '4', + items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1', pickedPackageCount: '2', minPackageCount: '2', canAdjust: true, canRemove: false }], +} describe('platform container loading API', () => { beforeEach(() => vi.clearAllMocks()) @@ -71,12 +76,13 @@ describe('platform container loading API', () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) - await createSmartLoadingPlan('7', null) + const normalized = await createSmartLoadingPlan('7', null) await assignSmartLoadingItem('10', { inboundId: '2', inboundDetailId: '3', packageCount: 4, expectedVersion: 5 }) await adjustSmartLoadingItem('10', '11', 6, 7, 8) await removeSmartLoadingItem('10', '11', 9, 10) await pushSmartLoadingPreload('10', 11) expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null }) + expect(normalized).toMatchObject({ id: String(plan.id), version: 3, canAssign: true, canEdit: true, pdaTaskId: String(plan.pdaTaskId), pdaTaskVersion: 4, items: [{ version: 1, pickedPackageCount: 2, minPackageCount: 2, canAdjust: true, canRemove: false }] }) expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-') }) expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') }) expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index ab9ffb88..37aa7861 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -89,6 +89,9 @@ function item(raw: SmartLoadingItem): SmartLoadingItem { inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version), packageCount: numberValue(raw.packageCount), + pickedPackageCount: numberValue(raw.pickedPackageCount), + minPackageCount: numberValue(raw.minPackageCount, 1), + canAdjust: Boolean(raw.canAdjust), canRemove: Boolean(raw.canRemove), productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity), grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg), volumeCbm: raw.volumeCbm == null ? null : numberValue(raw.volumeCbm), @@ -96,7 +99,15 @@ function item(raw: SmartLoadingItem): SmartLoadingItem { } function plan(raw: SmartLoadingPlan): SmartLoadingPlan { - return { ...raw, id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), items: (raw.items || []).map(item) } + return { + ...raw, + id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), + canAssign: Boolean(raw.canAssign), canEdit: Boolean(raw.canEdit), + blockedReason: raw.blockedReason || null, + pdaTaskId: optionalId(raw.pdaTaskId), + pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion), + items: (raw.items || []).map(item), + } } export async function listContainers(query: ContainerQuery): Promise { diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 8cf3294d..ee2bb7d4 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -50,7 +50,9 @@ const container = { } const plan = { id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, - items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1 }], + canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, + pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, + items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], } describe('GlobalSmartLoadingView', () => { @@ -191,7 +193,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).not.toContain('是否退税') }) - it('prefers an editable container on first load so pushed plans do not show a false editing error', async () => { + it('prefers an editing container on first load while keeping loading containers available', async () => { const pushed = { ...container, id: '9007199254740995', planStatus: 'LOADING' as const } vi.mocked(listContainers).mockResolvedValue({ records: [pushed, container], page: 1, size: 100, total: 2, pages: 1 }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) @@ -224,16 +226,19 @@ describe('GlobalSmartLoadingView', () => { expect(waitingOption.text()).not.toContain('拣货中') }) - it('excludes picking containers from the empty-container workbench', async () => { + it('keeps picking containers available when the plan capability allows increments', async () => { const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const } vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 }) + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, bookingId: pushed.id, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled() - expect(listSmartLoadingCandidates).not.toHaveBeenCalled() - expect(wrapper.findAll('.container-option')).toHaveLength(0) - expect(wrapper.text()).not.toContain('CB-2') + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id) + expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId) + expect(wrapper.findAll('.container-option')).toHaveLength(1) + expect(wrapper.text()).toContain('CB-2') + expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细') + expect(wrapper.text()).toContain('刷新 PDA 明细与版本') }) it('hides completed containers from the smart loading workbench', async () => { @@ -267,7 +272,7 @@ describe('GlobalSmartLoadingView', () => { expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2) - expect(warningMessage).toHaveBeenCalledWith('方案已被其他人修改,已刷新最新版本,请重新确认后提交') + expect(warningMessage).toHaveBeenCalledWith('计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作') expect(wrapper.text()).not.toContain('装柜操作失败') expect(wrapper.text()).not.toContain('request-1') }) @@ -307,7 +312,7 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - const removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('全部移除')) + const removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('移除未拣')) expect(removeAllButton).toBeDefined() await removeAllButton!.trigger('click') await flushPromises() @@ -317,6 +322,43 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('当前柜次暂无已装货物') }) + it('uses item capabilities to protect picked quantities and removal', async () => { + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ + ...plan, + status: 'LOADING', + pdaTaskId: '700', + pdaTaskStatus: 'LOADING', + pdaTaskVersion: 4, + items: [{ ...plan.items[0], pickedPackageCount: 3, minPackageCount: 3, canAdjust: true, canRemove: false }], + }) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.text()).toContain('已拣 3 箱') + expect(wrapper.findAll('.row-actions .el-button--danger')).toHaveLength(0) + const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6) + expect(quantityInput?.props('min')).toBe(3) + expect(wrapper.findAll('button').some((button) => button.text().includes('移除未拣'))).toBe(false) + }) + + it('renders final completion capability as read-only with the server reason', async () => { + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ + ...plan, + canAssign: false, + canEdit: false, + blockedReason: '正在校验装柜证据', + finalCompletionStatus: 'IN_PROGRESS', + }) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.text()).toContain('正在最终确认,装柜方案暂时只读') + expect(wrapper.text()).toContain('正在校验装柜证据') + expect(wrapper.text()).not.toContain('请先完成拖车') + expect(wrapper.findAll('.row-actions button')).toHaveLength(0) + expect(wrapper.findAll('button').some((button) => button.text().includes('加入'))).toBe(false) + }) + it('limits adjusted package count to the assigned count plus remaining availability', async () => { vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 4, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) @@ -360,7 +402,7 @@ describe('GlobalSmartLoadingView', () => { expect(source).not.toContain('channel.containerCount') expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"') expect(source).toContain('
候选货物
') - expect(source).toContain('
空柜
') + expect(source).toContain('
柜次
') expect(source).toContain('
已装
') expect(source).toContain('
总重量
') expect(source).toContain('
总体积
') diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index c7b309e1..ef18e128 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -63,7 +63,7 @@ const selectedContainer = computed(() => containers.value.find((item) => item.id const channelTabs = computed(() => { const channels = new Map() for (const item of containers.value) { - if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue + if (!['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus) || !item.channelCode) continue if (!channels.has(item.channelCode)) channels.set(item.channelCode, { code: item.channelCode, name: item.channelName || item.channelCode, @@ -72,7 +72,8 @@ const channelTabs = computed(() => { return [...channels.values()] }) const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null) -const editable = computed(() => plan.value?.status === 'EDITING') +const editable = computed(() => plan.value?.canEdit === true) +const assignable = computed(() => plan.value?.canAssign === true) const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null) const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId))) const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) @@ -90,7 +91,7 @@ const queryFields = computed(() => [ ]) const filteredContainers = computed(() => containers.value.filter((item) => ( - ['WAIT_LOADING', 'EDITING'].includes(item.planStatus) + ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus) && Boolean(activeChannelCode.value) && item.channelCode === activeChannelCode.value ))) @@ -106,6 +107,7 @@ const filteredCandidates = computed(() => { function preferredContainerId() { const visible = filteredContainers.value return visible.find((item) => item.planStatus === 'EDITING')?.id + || visible.find((item) => item.planStatus === 'LOADING')?.id || visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || '' } @@ -136,7 +138,7 @@ function itemRow(source: object) { return source as SmartLoadingItem } function adjustmentMax(source: object) { const item = itemRow(source) const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0 - return Math.max(1, item.packageCount + remaining) + return Math.max(item.minPackageCount, item.packageCount + remaining) } function displayCabinetNo(container: ContainerBookingResource | null | undefined) { if (!container) return '待生成' @@ -300,7 +302,10 @@ async function adjust(source: object) { const item = itemRow(source) if (!plan.value) return const count = Number(quantityDrafts[item.id]) - if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return } + if (!Number.isInteger(count) || count < item.minPackageCount) { + ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`) + return + } const max = adjustmentMax(item) if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return } await mutate(async () => { @@ -311,7 +316,7 @@ async function adjust(source: object) { } async function remove(source: object) { const item = itemRow(source) - if (!plan.value) return + if (!plan.value || !item.canRemove) return const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消', }).catch(() => false) @@ -323,22 +328,23 @@ async function remove(source: object) { }, '移除装柜明细失败', { pickingConflict: true }) } async function removeAll() { - if (!plan.value?.items.length) return - const itemCount = plan.value.items.length - const confirmed = await ElMessageBox.confirm(`确认移除当前方案的全部 ${itemCount} 条装柜明细吗?`, '全部移除', { - type: 'warning', confirmButtonText: '全部移除', cancelButtonText: '取消', + const removableItems = plan.value?.items.filter((item) => item.canRemove) || [] + if (!removableItems.length) return + const itemCount = removableItems.length + const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 条未拣明细吗?已拣明细会保留。`, '移除未拣明细', { + type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消', }).catch(() => false) if (!confirmed || !plan.value) return await mutate(async () => { let current = plan.value! - for (const original of [...current.items]) { + for (const original of removableItems) { const item = current.items.find((value) => value.id === original.id) if (!item) continue current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version) acceptPlan(current) } await loadSelectedData() - ElMessage.success(`已移除全部 ${itemCount} 条装柜明细`) + ElMessage.success(`已移除 ${itemCount} 条未拣明细`) }, '全部移除失败', { pickingConflict: true }) } async function pushPreload() { @@ -368,7 +374,7 @@ async function mutate( const pickingMessage = recovery.pickingConflict && details.message.includes('拣货') ? '该订单在拣货中不许删除' : null - const message = pickingMessage || '方案已被其他人修改,已刷新最新版本,请重新确认后提交' + const message = pickingMessage || '计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作' if (recovery.preserveAssign) assignOpen.value = true if (pickingMessage) ElMessage.error(pickingMessage) else ElMessage.warning(message) @@ -392,7 +398,7 @@ onMounted(() => { void load(); void loadCustomers() })
候选货物
{{ filteredCandidates.length }} 条
-
空柜
{{ filteredContainers.length }} 个
+
柜次
{{ filteredContainers.length }} 个
已装
{{ totalPackages }} 箱
总重量
{{ formatNumber(totalWeight, 1) }} kg
总体积
{{ formatNumber(totalVolume) }} m³
@@ -426,7 +432,7 @@ onMounted(() => { void load(); void loadCustomers() }) >