diff --git a/package.json b/package.json index c9b32f72..6933dd7f 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 5173", - "build": "vue-tsc --noEmit && vite build", - "typecheck": "vue-tsc --noEmit", + "build": "vue-tsc --noEmit -p tsconfig.app.json && vite build", + "typecheck": "vue-tsc --noEmit -p tsconfig.app.json", + "typecheck:all": "vue-tsc --noEmit -p tsconfig.json", "test": "vitest run", "test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs", "preview": "vite preview --port 4173" diff --git a/src/modules/overseas-warehouse/api/cargoDiscards.ts b/src/modules/overseas-warehouse/api/cargoDiscards.ts index 509aa253..fad7215a 100644 --- a/src/modules/overseas-warehouse/api/cargoDiscards.ts +++ b/src/modules/overseas-warehouse/api/cargoDiscards.ts @@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string } -export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null } +export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null } export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null } export async function getCargoDiscardStatistics(query: CargoDiscardQuery) { diff --git a/src/modules/overseas-warehouse/api/inventory.ts b/src/modules/overseas-warehouse/api/inventory.ts index 094ee2af..7b6c8b69 100644 --- a/src/modules/overseas-warehouse/api/inventory.ts +++ b/src/modules/overseas-warehouse/api/inventory.ts @@ -42,6 +42,36 @@ export interface InventoryDashboardDto { procurementPackages: number } +export interface InventoryPieceGroup { + customerId: string + warehouseId: string + destinationCode: string | null + anchorSkuCode: string | null + availablePieceCount: number | string | null + knownAvailablePieceCount: number | string + unknownCartonCount: number | string + availableCartonCount: number | string + availablePalletCount: number | string + skuContents: { skuCode: string; quantity: number | string }[] + cartons: { cartonMark: string; inventoryPositionId: string; palletId: string | null; quantity: number | string | null; items: { skuCode: string; quantity: number | string }[] }[] +} +export interface InventoryPieceProjection { + availablePieceCount: number | string | null + knownAvailablePieceCount: number | string + unknownCartonCount: number | string + availableCartonCount: number | string + availablePalletCount: number | string + groups: InventoryPieceGroup[] +} + +/** 完整筛选的真实可用件数及混箱全部SKU,不按记账SKU换算。 */ +export async function getInventoryPieceProjection(query: InventoryBalanceQuery) { + const response = await http.get, ApiEnvelope>( + '/platform/v1/last-leg/inventory/piece-projection', { params: params(query) }, + ) + return unwrapApiResponse(response) +} + export interface InventoryPositionDto { id: string balanceId: string diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 53c39ca1..18bd6409 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -51,8 +51,17 @@ export interface LastMileBusinessSource { businessOrderNo: string | null externalBookingNo: string | null } +export interface ReturnOrderStatistics { + totals: { orderCount: number; skuTypes: number; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number } + pageDimensions: Array<{ returnNo: string; lastlegOrderId: string; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }> +} +export async function getReturnOrderStatistics(params: LastMileOrderQuery) { + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/return-orders/statistics', { params }) + return unwrapApiResponse(response) +} export interface LastMileOrderSummaryDto { + warehouseTransferQuantity?: import('./transferQuantityFacts').TransferQuantityFacts | null businessSource?: LastMileBusinessSource | null id: string customerId: string diff --git a/src/modules/overseas-warehouse/api/relabelOrders.test.ts b/src/modules/overseas-warehouse/api/relabelOrders.test.ts index 187543a8..e0b873f9 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.test.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam' import { http } from '@/shared/api/http' -import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders' +import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type CreateRelabelPayload } from './relabelOrders' vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() })) vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) @@ -29,7 +29,7 @@ describe('relabel overseas API', () => { it('uses the real create and status endpoints', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} }) vi.mocked(http.put).mockResolvedValue({ code: '200', data: {} }) - const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] } + const payload: CreateRelabelPayload = { customerOrderNo: 'CO-1', operationWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ sourceInventoryBalanceId: '11', sourceInventoryPositionId: '12', sourceInboundOrderId: '13', cartonMark: 'BOX', operationType: 'SKU_LABEL', skuItems: [{ skuCode: 'SKU', newSkuCode: 'SKU-2', quantity: 8 }], skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] } await createRelabelOrder('10001', payload) await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok') expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } }) diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index d0fe7718..92db5abd 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -12,6 +12,15 @@ export interface RelabelOrderQuery { } export interface CreateRelabelItemPayload { + sourceInventoryBalanceId?: string + sourceInventoryPositionId?: string + sourceInboundOrderId?: string + returnCartonId?: string + cartonMark: string + fbaCartonMark?: string + destinationFbaWarehouseCode?: string + operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' + skuItems: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }> skuCode: string productNameZh: string totalPackageCount: number @@ -30,9 +39,9 @@ export interface CreateRelabelItemPayload { export interface CreateRelabelPayload { customerOrderNo: string - operationWarehouseId?: string - /** @deprecated compatibility alias for older platform fixtures */ - overseasWarehouseId?: string + operationWarehouseId: string + deliveryMethod?: 'TRUCK' | 'EXPRESS' | 'PICKUP' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' + printShippingLabel?: boolean labelDeadline: string contactName: string contactPhone: string @@ -131,6 +140,7 @@ export interface RelabelDetailDto { } export interface RelabelQuantity { + sourceUnitCounts?: Record | null labelPieceCount: number | string | null cartonCount: number | string | null skuCount: number | string @@ -140,6 +150,10 @@ export interface RelabelQuantity { targetPalletCount: number | string | null } export interface RelabelQuantityTotals { + knownSourcePalletCount?: number | string + unknownSourcePalletOrders?: number | string + knownTargetPalletCount?: number | string + unknownTargetPalletOrders?: number | string orderCount: number | string skuCount: number | string knownLabelPieceCount: number | string @@ -148,6 +162,26 @@ export interface RelabelQuantityTotals { unknownCartonOrders: number | string orders: Record } +export interface RelabelInventoryCandidate { + inventoryBalanceId?: string | null + inboundOrderId?: string | null + inboundOrderNo: string + inboundCode?: string + skuCode: string + productNameZh: string + availablePackages: number | string + sourceType: string + returnCartonId?: string | null + cartonMark?: string | null + cartonAvailablePackages?: number | string + cartonSkuItems?: Array<{ skuCode: string; quantity: number }> + positions: Array<{ positionId: string; palletNo: string; availablePackages: number | string }> +} +/** 候选归属和可用量由服务端决定,长整型来源ID按字符串原样提交。 */ +export async function listRelabelInventoryCandidates(customerId: string, operationWarehouseId: string) { + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/relabel-applications/inventory-candidates', { params: { customerId, operationWarehouseId } }) + return unwrapApiResponse(response) +} /** 汇总同一筛选下全部单据,不以当前页合计代替。 */ export async function getRelabelQuantities(query: RelabelOrderQuery) { const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/relabel-applications/quantity-summary', { params: query }) diff --git a/src/modules/overseas-warehouse/api/transferQuantityFacts.ts b/src/modules/overseas-warehouse/api/transferQuantityFacts.ts new file mode 100644 index 00000000..dce1235e --- /dev/null +++ b/src/modules/overseas-warehouse/api/transferQuantityFacts.ts @@ -0,0 +1,26 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { LastMileOrderQuery } from './lastMileOrders' +export interface TransferQuantityFacts { + declaredPieceCount: number | string | null + receivedPieceCount: number | string | null + declaredCartonCount: number | string + receivedCartonCount: number | string + receivedPalletCount: number | string + availableCartonCount: number | string + shippedCartonCount: number | string + remainingPalletCount: number | string + shippedPalletCount: number | string +} +export interface TransferQuantityTotals extends TransferQuantityFacts { + orderCount: number | string + knownDeclaredPieceCount: number | string + unknownDeclaredOrderCount: number | string + knownReceivedPieceCount: number | string + unknownReceivedOrderCount: number | string +} +export async function getTransferQuantityTotals(query: LastMileOrderQuery) { + const response = await http.get, ApiEnvelope>( + '/platform/v1/last-leg/orders/warehouse-transfer-quantities', { params: query }, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/components/RelabelInventoryItemsEditor.test.ts b/src/modules/overseas-warehouse/components/RelabelInventoryItemsEditor.test.ts new file mode 100644 index 00000000..6ea57635 --- /dev/null +++ b/src/modules/overseas-warehouse/components/RelabelInventoryItemsEditor.test.ts @@ -0,0 +1,27 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { listRelabelInventoryCandidates } from '../api/relabelOrders' +import RelabelInventoryItemsEditor from './RelabelInventoryItemsEditor.vue' +vi.mock('../api/relabelOrders', () => ({ listRelabelInventoryCandidates: vi.fn() })) +describe('formal relabel source tasks', () => { + it('submits one carton with long source IDs and independently editable SKU pieces; warehouse change clears it', async () => { + vi.mocked(listRelabelInventoryCandidates).mockResolvedValue([{ inventoryBalanceId: '9007199254740993', inboundOrderId: '9007199254740994', inboundOrderNo: 'IN-1', skuCode: 'A', productNameZh: '商品', availablePackages: 1, sourceType: 'INBOUND', positions: [{ positionId: '9007199254740995', palletNo: 'P1', availablePackages: 1 }] }]) + const wrapper = mount(RelabelInventoryItemsEditor, { props: { customerId:'C', warehouseId:'W',operationType:'CARTON_AND_SKU',shipmentNo:'S',destination:'SCK8',requirement:'要求' }, global:{plugins:[ElementPlus]} }) + await flushPromises() + wrapper.findComponent({name:'ElSelect'}).vm.$emit('update:modelValue','inventory:9007199254740995') + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '添加箱任务')!.trigger('click'); await flushPromises() + await wrapper.findAll('input').find(input => input.attributes('maxlength') === '255')!.setValue('BOX-1') + await wrapper.get('input[aria-label="新 SKU"]').setValue('B') + wrapper.findComponent({name:'ElInputNumber'}).vm.$emit('update:modelValue',8) + await flushPromises() + const items = wrapper.emitted('update:items')!.at(-1)![0] as Array> + expect(items).toMatchObject([{sourceInventoryBalanceId:'9007199254740993',sourceInventoryPositionId:'9007199254740995',sourceInboundOrderId:'9007199254740994',cartonMark:'BOX-1',totalPackageCount:1,notifiedPackageCount:1,destinationFbaWarehouseCode:'SCK8',skuItems:[{skuCode:'A',newSkuCode:'B',quantity:8}]}]) + expect(items[0]).not.toHaveProperty('sourceLabel') + await wrapper.setProps({warehouseId:'OTHER'}); await flushPromises() + expect(wrapper.emitted('update:items')!.at(-1)![0]).toEqual([]) + expect(listRelabelInventoryCandidates).toHaveBeenLastCalledWith('C','OTHER') + wrapper.unmount() + }) +}) diff --git a/src/modules/overseas-warehouse/components/RelabelInventoryItemsEditor.vue b/src/modules/overseas-warehouse/components/RelabelInventoryItemsEditor.vue new file mode 100644 index 00000000..e464c997 --- /dev/null +++ b/src/modules/overseas-warehouse/components/RelabelInventoryItemsEditor.vue @@ -0,0 +1,69 @@ + + diff --git a/src/modules/overseas-warehouse/components/TransferQuantitySummary.test.ts b/src/modules/overseas-warehouse/components/TransferQuantitySummary.test.ts new file mode 100644 index 00000000..d61060bd --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransferQuantitySummary.test.ts @@ -0,0 +1,28 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import TransferQuantitySummary from './TransferQuantitySummary.vue' +import { getTransferQuantityTotals } from '../api/transferQuantityFacts' +vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() })) + +describe('转运完整筛选数量', () => { + it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => { + vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' }) + const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } }) + await flushPromises() + expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)') + expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托') + expect(wrapper.text()).toContain('剩余 2 托') + expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)') + await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } }) + await flushPromises() + expect(wrapper.text()).toContain('全筛选 2 票') + expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' }) + }) + it('shows a recoverable error instead of zero totals', async () => { + vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline')) + const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } }) + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toContain('加载失败') + expect(wrapper.text()).not.toContain('全筛选 0') + }) +}) diff --git a/src/modules/overseas-warehouse/components/TransferQuantitySummary.vue b/src/modules/overseas-warehouse/components/TransferQuantitySummary.vue new file mode 100644 index 00000000..2c76d233 --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransferQuantitySummary.vue @@ -0,0 +1,24 @@ + + diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 9acabc41..55af9b4c 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -183,7 +183,9 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO cartons: metric(dto.packageCount), cartonDifference: warehouseTransfer ? metric(dto.packageDifference) : '—', volume: metric(dto.volumeCbm), - quantity: metric(dto.productQuantity), + quantity: warehouseTransfer ? (dto.warehouseTransferQuantity?.receivedPieceCount ?? '未记录') : metric(dto.productQuantity), + declaredPieces: warehouseTransfer ? (dto.warehouseTransferQuantity?.declaredPieceCount ?? '未记录') : '—', + receivedPieces: warehouseTransfer ? (dto.warehouseTransferQuantity?.receivedPieceCount ?? '未记录') : '—', volumeWeight: metric(dto.volumeWeightKg), portEta: formatOverseasDateTime(dto.estimatedArrivalAt), portAta: formatOverseasDateTime(dto.actualArrivalAt), diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts b/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts index cd5611ca..83847504 100644 --- a/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts +++ b/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts @@ -10,11 +10,11 @@ import { overseasWarehousePermissions as permission } from '../permissions' import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards' import CargoDiscardsView from './CargoDiscardsView.vue' -const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn() })) +const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn(), statistics: vi.fn() })) const can = vi.hoisted(() => vi.fn((_code: string) => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers })) -vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo })) +vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics })) enableAutoUnmount(afterEach) afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() }) @@ -25,6 +25,7 @@ beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] }) + api.statistics.mockResolvedValue({ orderCount: 1, skuCount: 1, requestedPackages: 2, actualPackages: null, requestedPieces: null, actualPieces: null, palletCount: null }) api.list.mockResolvedValue({ records: [summary()], total: 1 }) api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail()) api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' })) diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue index 3c1d86c8..3a2410fd 100644 --- a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue +++ b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue @@ -8,7 +8,10 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import AppDialog from '@/shared/components/AppDialog.vue' -import { getApiErrorDetails } from '@/shared/api/http' +import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' +import LineClampText from '@/shared/components/LineClampText.vue' +import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { overseasWarehousePermissions as permission } from '../permissions' @@ -18,38 +21,94 @@ const statistics = ref(null) /** 数量事实缺失时明确提示,不用零代替未记录。 */ function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` } const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }] -const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }] +const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }] const statusLabels: Record = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } -const photoUrls = reactive>({}); const photoLoading = reactive>({}); const photoErrors = reactive>({}) +const customerError = ref(null); const listError = ref(null); const statisticsError = ref(null) +const detailError = ref(null); const reviewError = ref(null) +const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref(null) +const photoUrls = reactive>({}); const photoLoading = reactive>({}); const photoErrors = reactive>({}) const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean)) -let photoGeneration = 0 -function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) } +let photoGeneration = 0, detailGeneration = 0, loadGeneration = 0, disposed = false +function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url)); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) } async function loadPhoto(fileId: string) { const current = detail.value; const generation = photoGeneration if (!current || photoLoading[fileId]) return - photoLoading[fileId] = true; photoErrors[fileId] = false - try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) } - catch { if (generation === photoGeneration) photoErrors[fileId] = true } + if (!current.summary.customerId) { photoErrors[fileId] = { message: '照片缺少客户范围' }; return } + photoLoading[fileId] = true; photoErrors[fileId] = null + try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, current.summary.customerId); if (disposed || photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) } + catch (error) { if (!disposed && generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败') } finally { if (generation === photoGeneration) photoLoading[fileId] = false } } -watch(detailOpen, value => { if (!value) clearPhotos() }) -onBeforeUnmount(clearPhotos) +watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } }) +onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() }) function text(key: string) { return String(filters.value[key] || '').trim() || undefined } -/** 同一筛选读取分页与全量统计,失败时清除旧数据,避免陈旧合计。 */ -let loadGeneration = 0 -async function load(model: QueryModel = filters.value) { const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; try { const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }; const [result, summary] = await Promise.all([listCargoDiscards(query), getCargoDiscardStatistics(query)]); if (generation !== loadGeneration) return; rows.value = result.records; total.value = result.total; statistics.value = summary } catch (e) { if (generation !== loadGeneration) return; rows.value = []; total.value = 0; statistics.value = null; ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { if (generation === loadGeneration) loading.value = false } } -async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } } -async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } } -onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } }) +/** 分页与全筛选统计独立保留诊断,过期响应不得覆盖新的查询。 */ +async function load(model: QueryModel = filters.value) { + const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null; statisticsError.value = null; statistics.value = null + const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') } + const [list, stat] = await Promise.allSettled([listCargoDiscards(query), getCargoDiscardStatistics(query)]) + if (disposed || generation !== loadGeneration) return + if (list.status === 'fulfilled') { rows.value = list.value.records; total.value = list.value.total } + else { rows.value = []; total.value = 0; listError.value = getApiErrorDetails(list.reason, '废弃单列表加载失败') } + if (stat.status === 'fulfilled') statistics.value = stat.value + else statisticsError.value = getApiErrorDetails(stat.reason, '废弃统计加载失败') + loading.value = false +} +async function loadCustomers() { + customerError.value = null + try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); if (disposed) return; customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))] } + catch (error) { if (!disposed) customerError.value = getApiErrorDetails(error, '客户列表加载失败') } +} +async function readDetail() { + const target = detailTarget.value; if (!target) return + const generation = ++detailGeneration; detailLoading.value = true; detailError.value = null + try { const result = await getCargoDiscard(target.discardNo, target.customerId || undefined); if (disposed || generation !== detailGeneration || !detailOpen.value) return; detail.value = result; void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId))) } + catch (error) { if (!disposed && generation === detailGeneration) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') } + finally { if (generation === detailGeneration) detailLoading.value = false } +} +async function openDetail(row: CargoDiscardSummary) { if (reviewing.value) return; clearPhotos(); detail.value = null; detailTarget.value = row; detailOpen.value = true; await readDetail() } +async function audit(row: CargoDiscardSummary) { + if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return + reviewing.value = row.discardNo; reviewError.value = null + try { await ElMessageBox.confirm('确认审核废弃单 ' + row.discardNo + ' 并推送 PDA 执行废弃?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm' }); if (!access.can(permission.discardApprove)) return; await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } + catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') } + finally { reviewing.value = '' } +} +onMounted(() => { void loadCustomers(); void load() }) diff --git a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts index 0af191b4..77f18653 100644 --- a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts @@ -2,7 +2,7 @@ import ElementPlus from 'element-plus' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory' +import { getInventoryPieceProjection, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory' import InventoryDetailView from './InventoryDetailView.vue' import InventoryView from './InventoryView.vue' @@ -13,6 +13,7 @@ vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() })) vi.mock('../api/inventory', () => ({ listInventoryBalances: vi.fn(), getInventorySummary: vi.fn(), listInventoryPositions: vi.fn(), listInventoryLedger: vi.fn(), + getInventoryPieceProjection: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } @@ -45,6 +46,7 @@ describe('inventory customer-name snapshots', () => { vi.mocked(listInventoryBalances).mockReset().mockResolvedValue({ records: [balance], page: 1, size: 10, total: 1, pages: 1 }) vi.mocked(getInventorySummary).mockReset().mockResolvedValue({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) vi.mocked(listInventoryPositions).mockReset().mockResolvedValue([]) + vi.mocked(getInventoryPieceProjection).mockReset().mockResolvedValue({ availablePieceCount: null, knownAvailablePieceCount: 0, unknownCartonCount: 10, availableCartonCount: 10, availablePalletCount: 1, groups: [] }) vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }) @@ -72,6 +74,24 @@ describe('inventory customer-name snapshots', () => { expect(wrapper.text()).not.toContain(balance.id) }) + it('keeps the formal customer and carton list when piece projection fails', async () => { + vi.mocked(getInventoryPieceProjection).mockRejectedValueOnce(new Error('projection unavailable')) + const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + expect(wrapper.text()).toContain('华远贸易') + expect(wrapper.text()).toContain('可用件数及箱内SKU加载失败') + expect(wrapper.text()).not.toContain('库存数据加载失败') + }) + + it('renders every mixed-carton SKU and real piece count alongside carton totals', async () => { + vi.mocked(getInventoryPieceProjection).mockResolvedValueOnce({ availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', groups: [{ customerId, warehouseId: '200', destinationCode: null, anchorSkuCode: 'SKU-1', availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', skuContents: [{ skuCode: 'SKU-1', quantity: '5' }, { skuCode: 'SKU-SECOND', quantity: '7' }], cartons: [] }] }) + const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + expect(wrapper.text()).toContain('华远贸易') + expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件') + expect(wrapper.text()).toContain('可用 12 件 / 2 箱 / 1 托') + }) + it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => { vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 }) routing.route.params = { inventoryId: balance.id } diff --git a/src/modules/overseas-warehouse/pages/InventoryView.vue b/src/modules/overseas-warehouse/pages/InventoryView.vue index 8ec112a7..7f8537e0 100644 --- a/src/modules/overseas-warehouse/pages/InventoryView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryView.vue @@ -12,7 +12,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory' +import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceProjection, type InventoryPieceGroup } from '../api/inventory' import { ElMessage } from 'element-plus' import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets' import { toInventoryListItem } from '../inventoryPresentation' @@ -25,6 +25,9 @@ const access = useAccessPreviewStore() const warehouses = ref([]) const rows = ref([]) const summary = ref(null) +const pieces = ref(null) +const rowPieces = ref>({}) +const pieceError = ref('') const loading = ref(false) const contextLoading = ref(false) const loadError = ref(null) @@ -50,7 +53,8 @@ const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 }, - { key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, + { key: 'sku', prop: 'sku', label: '箱内SKU / 可用件数', minWidth: 220, slotName: 'skuContents', singleLine: false, className: 'inventory-sku-contents' }, + { key: 'availablePieces', label: '可用件数', minWidth: 130, slotName: 'availablePieces' }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false }, { key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' }, @@ -75,9 +79,18 @@ function query() { async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) - loading.value = true; loadError.value = null - try { const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(query()), getInventorySummary(query())]); rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult } - catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') } + loading.value = true; loadError.value = null; pieces.value = null; rowPieces.value = {}; pieceError.value = '' + try { + const currentQuery = query() + const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)]) + rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult + try { + const pieceResult = await getInventoryPieceProjection(currentQuery) + rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))])) + pieces.value = pieceResult + } catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' } + } + catch (error) { rows.value = []; total.value = 0; summary.value = null; pieces.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') } finally { loading.value = false } } @@ -95,17 +108,20 @@ onMounted(() => { void loadWarehouses(); void load() })
- - + + + +
diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index b5fda651..d574ff7c 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -367,7 +367,8 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(columnKeys('LastMileOrdersView.vue')).toEqual([ 'actions', 'selection', 'orderNo', 'businessSource', 'businessOrderNo', 'externalBookingNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse', 'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets', - 'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', + // 本轮明确拆分退货件数/载体与转运预报/实收,保留其他原型列的相对顺序。 + 'cartons', 'returnParcels', 'returnExpected', 'returnReceived', 'returnRemaining', 'cartonDifference', 'volume', 'quantity', 'declaredPieces', 'receivedPieces', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', 'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status', 'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark', ]) @@ -376,11 +377,11 @@ describe('overseas warehouse first-batch prototype contract', () => { 'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt', ]) expect(columnKeys('InventoryView.vue')).toEqual([ - 'actions', 'owner', 'warehouse', 'sku', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard', + 'actions', 'owner', 'warehouse', 'sku', 'availablePieces', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard', 'inTransit', 'updatedAt', ]) expect(columnKeys('RelabelOrdersView.vue')).toEqual([ - 'actions', 'selection', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt', + 'actions', 'selection', 'orderNo', 'sku', 'productName', 'cartons', 'labelPieces', 'skuCount', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt', ]) expect(columnKeys('PalletsView.vue')).toEqual([ 'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark', diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index 38bfc63e..23e80529 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -39,7 +39,9 @@ export interface LastMileOrderListItem { cartons: number | '—' cartonDifference: number | '—' volume: number | '—' - quantity: number | '—' + quantity: number | string + declaredPieces?: number | string + receivedPieces?: number | string volumeWeight: number | '—' portEta: string portAta: string diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 00000000..79599002 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +}