diff --git a/AGENTS.md b/AGENTS.md index 26e35755..aba261cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | | 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 | | 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | -| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,列表下方展开只读明细并提供打印;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | +| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 51914352..878b744b 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -21,7 +21,7 @@ describe('adminNavigation', () => { { title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] }, { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] }, - { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, + { title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] }, { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, @@ -30,7 +30,7 @@ describe('adminNavigation', () => { { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) expect(visible).toHaveLength(11) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(48) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index b7e114d3..93a6a991 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -107,14 +107,15 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, + { key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' }, { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' }, { key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, { key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' }, { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, { key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, - { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list', hidden: true }, - { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list', hidden: true }, + { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' }, + { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' }, ], }, { diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 650f0e5a..4d416f86 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -17,7 +17,17 @@ const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: { const routerPush = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) })) vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) })) -vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '
{{ detail?.planNo }}
' }, PalletLabelPrintDialog: { template: '
' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) +vi.mock('@/modules/loading-lists', async (importOriginal) => { + const actual = await importOriginal() + const api = { getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() } + return { + ...api, + useLoadingListDetail: () => actual.useLoadingListDetail(api), + ContainerFeesPanel: { template: '
' }, + LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded', 'dense', 'backLabel'], template: '
{{ detail?.planNo }}
' }, + PalletLabelPrintDialog: { template: '
' }, + } +}) vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() })) vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() })) vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '
' } })) @@ -61,7 +71,7 @@ describe('ContainerDetailView empty return', () => { vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never) vi.mocked(getContainer).mockReset().mockResolvedValue(base as never) vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) - vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', items: [] } as never) + vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) }) @@ -133,6 +143,7 @@ describe('ContainerDetailView empty return', () => { vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never) vi.mocked(getConfirmedLoadingList).mockResolvedValue({ planNo: 'LP-1', + summary: { status: 'COMPLETED' }, items: [{ destinationWarehouse: 'ABE4' }, { destinationWarehouse: 'ABE4' }], } as never) @@ -158,7 +169,7 @@ describe('ContainerDetailView empty return', () => { describe('domestic container loading list and booking link', () => { const domestic = { ...base, containerNo: null, warehouseId: '8', channelCode: 'ZBYHK', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null } - const loadingDetail = { planNo: 'LP-1', items: [] } + const loadingDetail = { planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } beforeEach(() => { routeState.query = {} @@ -212,7 +223,8 @@ describe('domestic container loading list and booking link', () => { if (permitted) { const drawer = wrapper.findComponent({ name: 'LoadingListDetailDrawer' }) expect(drawer.props('mode')).toBe(planStatus === 'COMPLETED' ? 'confirmed' : 'preload') - expect(drawer.props('embedded')).not.toBeUndefined() + expect(drawer.props('embedded')).toBeUndefined() + expect(drawer.props('backLabel')).toBe('返回货柜概览') } wrapper.unmount() } diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 9d3c153f..b692f379 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -30,12 +30,12 @@ describe('ContainerDetailView customs integration', () => { expect(source).toContain(':sections="[\'documents\']"') expect(source).toContain(':sections="[\'pda\']"') expect(source).toContain('(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')") - expect(source).toContain(' { expect(navigationSource).toContain("title: '装柜清单'") expect(source).toContain('(null) const outboundError = ref(null) const showPassedGates = ref(false) const carrierRefreshKey = ref(0) -const loadingDetail = ref(null) -const loadingDetailLoading = ref(false) -const loadingDetailError = ref(null) +const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail() const emptyReturnOpen = ref(false) const emptyReturnAt = ref('') const emptyReturnReason = ref('') @@ -295,6 +293,7 @@ async function confirmEmptyReturn() { async function load() { if (!containerId.value) return + resetLoadingDetail() loading.value = true; error.value = null; overviewError.value = null try { const [resource, aggregate, arrivals] = await Promise.all([ @@ -331,23 +330,12 @@ async function loadOutboundDispatch() { } async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) { - if (loadingDetail.value || loadingDetailLoading.value) return - loadingDetail.value = null; loadingDetailError.value = null - if (!aggregate.loadingPlanId || !resource.warehouseId) return - loadingDetailLoading.value = true - try { - if (resource.planStatus === 'COMPLETED') { - if (!canViewLoadingList.value) return - loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId) - return - } - if (!canViewPreload.value) return - loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId) - } catch (cause) { - loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败') - } finally { - loadingDetailLoading.value = false + const mode = resource.planStatus === 'COMPLETED' ? 'confirmed' : 'preload' + if (!(mode === 'confirmed' ? canViewLoadingList.value : canViewPreload.value)) { + resetLoadingDetail() + return } + await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '') } async function refreshAfterCustoms() { if (canViewOutboundDispatch.value) await loadOutboundDispatch() @@ -518,7 +506,6 @@ async function completeClearance() { } function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) } -function printLoadingList() { window.print() } onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') }) @@ -588,10 +575,10 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
-

{{ loadingListHeading }}

{{ loadingListDescription }}

打印装柜清单
+

{{ loadingListHeading }}

{{ loadingListDescription }}

- +
diff --git a/src/modules/downloads/api/platformExportTasks.ts b/src/modules/downloads/api/platformExportTasks.ts index c76fa186..101305ec 100644 --- a/src/modules/downloads/api/platformExportTasks.ts +++ b/src/modules/downloads/api/platformExportTasks.ts @@ -3,6 +3,14 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED' export type KnownExportTaskType = + | 'LAST_MILE_ORDER_LIST' + | 'OVERSEAS_INVENTORY_BALANCE' + | 'OVERSEAS_INVENTORY_LEDGER' + | 'OVERSEAS_QUOTE_LIST' + | 'MONTHLY_EXCHANGE_RATE_LIST' + | 'VEHICLE_LIST' + | 'FLEET_QUOTE_LIST' + | 'TRIP_OPERATIONS_LIST' | 'CUSTOMER_LIST' | 'DOMESTIC_WAREHOUSE_ORDER_LIST' | 'FINANCE_CUSTOMER_ACCOUNT_LIST' diff --git a/src/modules/downloads/index.ts b/src/modules/downloads/index.ts index f62f5ff1..7b2ee337 100644 --- a/src/modules/downloads/index.ts +++ b/src/modules/downloads/index.ts @@ -1 +1,2 @@ export * from './api/platformExportTasks' +export { useExportTask } from './useExportTask' diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue index a2a74adc..888d5541 100644 --- a/src/modules/downloads/pages/DownloadCenterView.vue +++ b/src/modules/downloads/pages/DownloadCenterView.vue @@ -26,6 +26,16 @@ type DownloadFilters = QueryModel & { } const exportTypeMeta: Record = { + LAST_MILE_ORDER_LIST: { label: '尾程订单', retryPermission: 'platform:overseas-warehouse:last-mile-order:export' }, + OVERSEAS_INVENTORY_BALANCE: { label: '库存余额', retryPermission: 'platform:overseas-warehouse:inventory:export' }, + OVERSEAS_INVENTORY_LEDGER: { label: '进销存记录', retryPermission: 'platform:overseas-warehouse:inventory:export' }, + OVERSEAS_QUOTE_LIST: { label: '海外报价', retryPermission: 'platform:overseas-master-data:quote:export' }, + MONTHLY_EXCHANGE_RATE_LIST: { label: '月度汇率', retryPermission: 'platform:overseas-master-data:exchange-rate:export' }, + VEHICLE_LIST: { label: '车辆', retryPermission: 'platform:overseas-master-data:vehicle:export' }, + FLEET_QUOTE_LIST: { label: '车队报价', retryPermission: 'platform:fleet:quote:export' }, + TRIP_OPERATIONS_LIST: { label: '出车记录', retryPermission: 'platform:fleet:operations:export' }, + FINANCE_RECEIVABLE_STATISTICS: { label: '应收统计', retryPermission: 'platform:finance:receivable-statistics:export' }, + LOADING_LIST_BUY_DECLARATION_ITEMS: { label: '买单报关明细', retryPermission: 'platform:firstleg:customs:manage' }, CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' }, DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' }, FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' }, diff --git a/src/modules/downloads/useExportTask.test.ts b/src/modules/downloads/useExportTask.test.ts new file mode 100644 index 00000000..1cf0a9ae --- /dev/null +++ b/src/modules/downloads/useExportTask.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { createPlatformExportTask } from './api/platformExportTasks' +import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' +import { useExportTask } from './useExportTask' + +const { push } = vi.hoisted(() => ({ push: vi.fn() })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push }) })) +vi.mock('./api/platformExportTasks', () => ({ createPlatformExportTask: vi.fn() })) +vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(createPlatformExportTask).mockResolvedValue({ taskNo: 'EXP-1' } as Awaited>) + vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false) +}) + +describe('download center export submission', () => { + it('exports all matching rows and preserves false, zero, stage and business filters', async () => { + const filters = { page: 3, size: 20, detained: false, count: 0, orderNo: '', warehouse: undefined, sailingStage: 'BEFORE_DEPARTURE', relabelNo: 'RL-1' } + await useExportTask()('LAST_MILE_ORDER_LIST', '尾程订单', filters) + expect(createPlatformExportTask).toHaveBeenCalledWith({ exportType: 'LAST_MILE_ORDER_LIST', taskName: '尾程订单', format: 'XLSX', + parameters: { detained: false, count: 0, sailingStage: 'BEFORE_DEPARTURE', relabelNo: 'RL-1' } }) + expect(filters.page).toBe(3) + expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '尾程订单', taskNo: 'EXP-1', dense: true }) + expect(push).not.toHaveBeenCalled() + }) + it('opens the download center only after the user chooses it', async () => { + vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(true) + await useExportTask()('VEHICLE_LIST', '车辆', {}) + expect(push).toHaveBeenCalledWith({ name: 'download-center' }) + }) + it('preserves submission failure diagnostics without a success prompt or navigation', async () => { + const error = { message: '无导出权限', requestId: 'export-request' } + vi.mocked(createPlatformExportTask).mockRejectedValue(error) + await expect(useExportTask()('VEHICLE_LIST', '车辆', {})).rejects.toBe(error) + expect(showExportTaskCreatedDialog).not.toHaveBeenCalled() + expect(push).not.toHaveBeenCalled() + }) + it.each([["overseas-warehouse/pages/LastMileOrdersView.vue","LAST_MILE_ORDER_LIST","platform:overseas-warehouse:last-mile-order:export"],["overseas-warehouse/pages/InventoryView.vue","OVERSEAS_INVENTORY_BALANCE","platform:overseas-warehouse:inventory:export"],["overseas-warehouse/pages/InventoryTransactionsView.vue","OVERSEAS_INVENTORY_LEDGER","platform:overseas-warehouse:inventory:export"],["overseas-master-data/pages/OverseasQuotesView.vue","OVERSEAS_QUOTE_LIST","platform:overseas-master-data:quote:export"],["overseas-master-data/pages/ExchangeRatesView.vue","MONTHLY_EXCHANGE_RATE_LIST","platform:overseas-master-data:exchange-rate:export"],["overseas-master-data/pages/VehiclesView.vue","VEHICLE_LIST","platform:overseas-master-data:vehicle:export"],["overseas-master-data/pages/FleetQuotesView.vue","FLEET_QUOTE_LIST","platform:fleet:quote:export"],["overseas-master-data/pages/TripOperationsView.vue","TRIP_OPERATIONS_LIST","platform:fleet:operations:export"]])('connects %s with the correct type and download-center retry permission', (path, type, permission) => { + const page = readFileSync(new URL('../' + path, import.meta.url), 'utf8') + expect(page).toContain("await submitExport('" + type + "'") + expect(page).not.toMatch(/saveCsv\(|saveFleetCsv\(/) + const center = readFileSync('src/modules/downloads/pages/DownloadCenterView.vue', 'utf8') + expect(center).toContain(type + ": { label:") + expect(center).toContain("retryPermission: '" + permission + "'") + }) +}) + diff --git a/src/modules/downloads/useExportTask.ts b/src/modules/downloads/useExportTask.ts new file mode 100644 index 00000000..01911168 --- /dev/null +++ b/src/modules/downloads/useExportTask.ts @@ -0,0 +1,16 @@ +import { useRouter } from 'vue-router' +import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' +import { createPlatformExportTask, type CreatePlatformExportTaskRequest, type KnownExportTaskType } from './api/platformExportTasks' + +export function useExportTask() { + const router = useRouter() + return async (exportType: KnownExportTaskType, taskName: string, query: object) => { + const parameters = Object.fromEntries(Object.entries(query).filter(([key, value]) => + key !== 'page' && key !== 'size' && value !== undefined && value !== null && value !== '', + )) as CreatePlatformExportTaskRequest['parameters'] + const task = await createPlatformExportTask({ exportType, taskName, format: 'XLSX', parameters }) + if (await showExportTaskCreatedDialog({ taskName, taskNo: task.taskNo, dense: true })) { + await router.push({ name: 'download-center' }) + } + } +} diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue index ef30633b..26804546 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue @@ -104,7 +104,7 @@ function handleSubmitted() {
diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index b87513a5..d6fe2e4e 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -48,3 +48,5 @@ - 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。 - 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。 - 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。 + +- 尾程订单、库存余额与进销存记录导出统一使用 `../downloads/` 的 `useExportTask` 创建 XLSX 任务;保留当前筛选和业务导出权限,并要求 `platform:download:list`。Lastleg 内部 Provider 契约为 `lastleg-exports-v1.yaml`,业务页面不再请求同步 CSV。 diff --git a/src/modules/overseas-warehouse/api/cargoDiscards.ts b/src/modules/overseas-warehouse/api/cargoDiscards.ts new file mode 100644 index 00000000..1883512d --- /dev/null +++ b/src/modules/overseas-warehouse/api/cargoDiscards.ts @@ -0,0 +1,26 @@ +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; 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 { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } +export interface CargoDiscardDetail { summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] } +export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] } + +export async function listCargoDiscards(query: CargoDiscardQuery) { + const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/cargo-discards', { params: query, headers: query.customerId ? { 'X-Customer-Id': query.customerId } : undefined }) + return normalizeOverseasPage(unwrapApiResponse(response)) +} +export async function getCargoDiscard(discardNo: string, customerId?: string) { + const response = await http.get, ApiEnvelope>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}`, { params: customerId ? { customerId } : undefined }) + return unwrapApiResponse(response) +} +export async function reviewCargoDiscard(discardNo: string, decision: 'APPROVE' | 'REJECT', remark?: string, customerId?: string) { + const response = await http.post, ApiEnvelope>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`, { decision, remark }, { params: customerId ? { customerId } : undefined }) + return unwrapApiResponse(response) +} +export async function completeCargoDiscard(discardNo: string, customerId?: string) { + const response = await http.post, ApiEnvelope>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/complete`, undefined, { params: customerId ? { customerId } : undefined }) + return unwrapApiResponse(response) +} + diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index 9c272b87..bc23da14 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -9,8 +9,12 @@ export interface DispatchQuery { vehiclePlate?: string status?: string fbaAppointmentNo?: string + stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED' } +export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null } +export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview } + export interface DispatchPalletCandidateDto { sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT' returnReceiptId?: string | null @@ -86,6 +90,7 @@ export interface PreArrangeDispatchDraftResult { } export interface DispatchSummaryDto { + workflow?: DispatchWorkflow | null id: string dispatchNo: string vehiclePlate?: string | null diff --git a/src/modules/overseas-warehouse/api/inventory.test.ts b/src/modules/overseas-warehouse/api/inventory.test.ts index c8590a11..3b621aea 100644 --- a/src/modules/overseas-warehouse/api/inventory.test.ts +++ b/src/modules/overseas-warehouse/api/inventory.test.ts @@ -1,27 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory' +import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory' vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn() } })) describe('overseas inventory API', () => { beforeEach(() => { vi.clearAllMocks() }) - it('returns the binary response directly for inventory exports', async () => { - const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' }) - const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' }) - vi.mocked(http.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger) - - await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances) - await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger) - - expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', { - params: { skuCode: 'SKU-1' }, responseType: 'blob', - }) - expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', { - params: { inboundCode: 'IN-1' }, responseType: 'blob', - }) - }) it('keeps skuCode and nullable location filters on real balance queries', async () => { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } }) diff --git a/src/modules/overseas-warehouse/api/inventory.ts b/src/modules/overseas-warehouse/api/inventory.ts index e8b882d1..094ee2af 100644 --- a/src/modules/overseas-warehouse/api/inventory.ts +++ b/src/modules/overseas-warehouse/api/inventory.ts @@ -117,9 +117,6 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) { return normalizeOverseasPage(unwrapApiResponse(response)) } -export async function downloadInventoryBalances(query: Partial) { - return http.get('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' }) -} export async function getInventorySummary(query: InventoryBalanceQuery) { const response = await http.get, ApiEnvelope>( @@ -165,7 +162,3 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) { ) return normalizeOverseasPage(unwrapApiResponse(response)) } - -export async function downloadInventoryLedger(query: Partial) { - return http.get('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' }) -} diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 546b6717..25cf9cd9 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' import { reviewCargoDiscard } from './lastMileOrders' -import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders' +import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders' vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) @@ -60,37 +60,8 @@ describe('last-mile order overseas API', () => { expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] })) }) - it('returns the exported CSV blob without reading data twice', async () => { - const blob = new Blob(['orders'], { type: 'text/csv' }) - vi.mocked(http.get).mockResolvedValue(blob) - await expect(downloadLastMileOrders({ orderNo: 'LM-1' })).resolves.toBe(blob) - expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { - params: { orderNo: 'LM-1' }, responseType: 'blob', - }) - }) - it('exports all authorized orders without pagination or empty filters', async () => { - const blob = new Blob(['orders'], { type: 'text/csv' }) - vi.mocked(http.get).mockResolvedValue(blob) - - await downloadLastMileOrders({ page: 1, size: 10, orderNo: '', createdFrom: undefined }) - - expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { - params: {}, responseType: 'blob', - }) - }) - - it('rejects a JSON business error instead of saving it as CSV', async () => { - const blob = new Blob([JSON.stringify({ - code: 'S0500', message: '系统内部错误', data: null, requestId: 'req-1', traceId: 'trace-1', - })], { type: 'application/json' }) - vi.mocked(http.get).mockResolvedValue(blob) - - await expect(downloadLastMileOrders({})).rejects.toMatchObject({ - code: 'S0500', message: '系统内部错误', requestId: 'req-1', traceId: 'trace-1', - }) - }) it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: {} }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index f43cde9a..eed2e084 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -1,4 +1,4 @@ -import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { InboundPlanDetailDto } from './inboundPlans' @@ -27,6 +27,7 @@ export interface LastMileOrderQuery { orderNo?: string relabelNo?: string orderType?: string + sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE' inboundCode?: string lxCode?: string containerNo?: string @@ -393,28 +394,6 @@ export async function reviewDirectRelabelOrder(orderId: string, customerId: stri return unwrapApiResponse(response) } -export async function downloadLastMileOrders(query: Partial) { - const { page: _page, size: _size, ...filters } = query - const params = Object.fromEntries( - Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''), - ) - const blob = await http.get('/platform/v1/last-leg/orders/export', { params, responseType: 'blob' }) - if (!blob.type.toLowerCase().includes('json')) return blob - let error: Partial> - try { - error = JSON.parse(await blob.text()) as Partial> - } catch { - throw new ApiResponseError('导出接口返回了非 CSV 内容', 'INVALID_EXPORT_RESPONSE') - } - throw new ApiResponseError( - error.message || '尾程订单导出失败', - error.code || 'INVALID_EXPORT_RESPONSE', - undefined, - error.data, - error.requestId, - error.traceId, - ) -} export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) { const response = await http.post, ApiEnvelope>( diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index 33942dc3..0b7b24ab 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -98,7 +98,7 @@ export interface RelabelItemDto { relabelFileUrl?: string | null shipmentNo?: string | null trackingCode?: string | null - remark?: string | null + remark?: string cartonMark?: string | null fbaCartonMark?: string | null destinationWarehouseId?: string | null @@ -109,7 +109,7 @@ export interface RelabelHistoryDto { fromStatus?: string | null toStatus?: string | null operatorId?: string | null - remark?: string | null + remark?: string createdAt?: string | null } @@ -137,16 +137,24 @@ export interface ReturnPackingPlanBox { export interface ReturnPackingPlanPayload { returnNos: string[] - mode: 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION' + mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION' boxes: ReturnPackingPlanBox[] remark?: string idempotencyKey: string + sourceCartonMarks?: string[] } -export interface ReturnPackingPlanDto extends ReturnPackingPlanPayload { +export interface ReturnPackingPlanDto extends Omit { planNo: string + customerId?: string + overseasWarehouseId?: string status: string + boxes: ReturnPackingPlanBox[] + remark?: string | null + createdAt?: string | null pushedAt?: string | null + completedAt?: string | null + resultCartons?: import('./returnInventory').ReturnCartonDetail[] } export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) { @@ -156,6 +164,21 @@ export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) return unwrapApiResponse(response) as ReturnPackingPlanDto } +export interface ReturnPackingPlanQuery { page: number; size: number; status?: string; planNo?: string; customerId?: string; overseasWarehouseId?: string; skuCode?: string } +export async function listReturnPackingPlans(query: ReturnPackingPlanQuery) { + const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-packing-plans', { params: query }) + return normalizeOverseasPage(unwrapApiResponse(response)) +} +export async function reviewReturnPackingPlan(planNo: string) { + const response = await http.post, ApiEnvelope>(`/platform/v1/last-leg/return-packing-plans/${encodeURIComponent(planNo)}/review`) + return unwrapApiResponse(response) as ReturnPackingPlanDto +} + +export async function cancelReturnPackingPlan(planNo: string) { + const response = await http.post, ApiEnvelope>(`/platform/v1/last-leg/return-packing-plans/${encodeURIComponent(planNo)}/cancel`) + return unwrapApiResponse(response) as ReturnPackingPlanDto +} + export async function listRelabelOrders(query: RelabelOrderQuery) { const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/relabel-applications', { params: query }, diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index da468685..ffcdeb35 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -2,14 +2,17 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' -export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; cartonQuantity: number; updatedAt: string } -export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } -export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> } +export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string } +export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } +export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } +export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] } export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string } +export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] } export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string } export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string } export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } +export async function listReturnPackingSources(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/packing-sources', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/cartons', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } export async function listReturnInventoryMovements(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/movements', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getReturnCarton(cartonMark: string) { const response = await http.get, ApiEnvelope>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue new file mode 100644 index 00000000..fbe8d43c --- /dev/null +++ b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts index f1ae6925..cfa8371a 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts @@ -10,6 +10,8 @@ import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispa import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue' import DispatchesView from './DispatchesView.vue' +const push = vi.hoisted(() => vi.fn()) +vi.mock('vue-router', () => ({ useRouter: () => ({ push }) })) const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() })) @@ -60,6 +62,50 @@ describe('DispatchesView', () => { }) + it('queries stages on the server and discards an older response after switching', async () => { + let resolveOld!: (value: Awaited>) => void + vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) + const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } }) + await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click') + await flushPromises() + expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined })) + expect(wrapper.text()).toContain('PC-1') + resolveOld({ records: [], total: 0, page: 1, size: 10, pages: 0 }) + await flushPromises() + expect(wrapper.text()).toContain('PC-1') + await wrapper.get('[role="tablist"]').findAll('button')[3]!.trigger('click') + await flushPromises() + expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 })) + }) + + it('shows review facts and opens the matching trip section with read permission', async () => { + const result = await listDispatches({ page: 1, size: 10 }) + result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } + vi.mocked(listDispatches).mockClear().mockResolvedValue(result) + canPermission.mockImplementation(code => code === 'platform:fleet:trip:list') + const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + const sections = wrapper.get('[aria-label="排车业务分区"]') + expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核']) + expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2) + expect(wrapper.text()).not.toContain('请核对货量') + await sections.findAll('button')[1]!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('请核对货量') + expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false) + await wrapper.findAll('button').find(button => button.text() === '查看审核')!.trigger('click') + expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } }) + await sections.findAll('button')[2]!.trigger('click') + await flushPromises() + expect(wrapper.text()).not.toContain('请核对货量') + expect(wrapper.text()).toContain('待审核') + const links = wrapper.findAll('button').filter(button => button.text() === '查看审核') + await links[0]!.trigger('click') + expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } }) + expect(wrapper.text()).not.toContain('前往审核') + expect(listDispatches).toHaveBeenCalledTimes(1) + }) + it.each([ [[], ['查看', '打印BOL'], false], [['edit'], ['查看', '打印BOL', '编辑'], false], diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue index 040326c4..f9c57702 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue @@ -31,6 +31,31 @@ const loadError = ref(null) const page = ref(1) const pageSize = ref(10) const total = ref(0) +const stage = ref<'' | 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'>('') +const stages = [ + { value: '', label: '全部' }, { value: 'BEFORE_LOADING', label: '装车前' }, + { value: 'LOADING', label: '装车中' }, { value: 'AFTER_LOADING', label: '装车后' }, + { value: 'CANCELLED', label: '已取消' }, +] as const +const section = ref<'vehicle' | 'dispatchReview' | 'loadingReview'>('vehicle') +const sections = [ + { value: 'vehicle', label: '车辆信息' }, + { value: 'dispatchReview', label: '调度审核' }, + { value: 'loadingReview', label: '理货员审核' }, +] as const +const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' } +let loadSequence = 0 +function switchStage(value: typeof stage.value) { + if (stage.value === value) return + stage.value = value + filters.value.status = '' + page.value = 1 + void load() +} +function openTrip(row: DispatchListItem, tab: 'overview' | 'loading') { + if (!row.workflow || !access.can('platform:fleet:trip:list')) return + void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab } }) +} const filters = ref({ dispatchNo: '', vehiclePlate: '', @@ -168,6 +193,9 @@ const fields = computed(() => [ const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' }, { key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' }, + { key: 'workflow', label: '关联车次', minWidth: 210, slotName: 'workflow' }, + { key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false }, + { key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false }, { key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 }, { key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 }, { key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 }, @@ -228,6 +256,13 @@ const columns: DataTableColumn[] = [ { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 }, ] +const visibleColumns = computed(() => { + if (section.value === 'vehicle') return columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key))) + const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status'] + return keys.map(key => columns.find(column => column.key === key)!) +}) +const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`) + function text(value: unknown) { const normalized = String(value ?? '').trim() return normalized || '—' @@ -263,6 +298,7 @@ function appointmentLabel(numberValue: unknown, statusValue: unknown) { function toListItem(dto: DispatchSummaryDto): DispatchListItem { return { + workflow: dto.workflow, id: String(dto.id), dispatchNo: text(dto.dispatchNo), vehiclePlate: text(dto.vehiclePlate), @@ -295,6 +331,7 @@ function dispatchError(error: unknown) { } async function load(model: QueryModel = filters.value) { + const sequence = ++loadSequence Object.assign(filters.value, model) loading.value = true loadError.value = null @@ -302,21 +339,24 @@ async function load(model: QueryModel = filters.value) { const result = await listDispatches({ page: page.value, size: pageSize.value, + stage: stage.value || undefined, dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined, }) + if (sequence !== loadSequence) return dispatchSnapshots.clear() result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item)) rows.value = result.records.map(toListItem) total.value = result.total } catch (error) { + if (sequence !== loadSequence) return rows.value = [] total.value = 0 loadError.value = dispatchError(error) } finally { - loading.value = false + if (sequence === loadSequence) loading.value = false } } @@ -501,6 +541,13 @@ async function cancel(row: DispatchListItem) { } } function rowActions(row: DispatchListItem): RowActionItem[] { + if (section.value !== 'vehicle') { + const reviewPermission = section.value === 'dispatchReview' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review' + return [ + { key: 'review', label: access.can(reviewPermission) ? '前往审核' : '查看审核', priority: 400, visible: !!row.workflow && access.can('platform:fleet:trip:list') }, + { key: 'detail', label: '查看', priority: 300 }, + ] + } return [ { key: 'detail', label: '查看', priority: 300 }, { key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo }, @@ -510,7 +557,8 @@ function rowActions(row: DispatchListItem): RowActionItem[] { ] } function handleRowAction(command: string, row: DispatchListItem) { - if (command === 'detail') openDetail(row) + if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading') + else if (command === 'detail') openDetail(row) else if (command === 'bol') void openBol(row) else if (command === 'edit') openEdit(row) else if (command === 'transfer') openTransfer(row) @@ -589,11 +637,30 @@ onMounted(load)