diff --git a/AGENTS.md b/AGENTS.md index 8bb0adf6..a679be38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,13 +41,14 @@ | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | | 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 | | 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 | | 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 | | 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 | -| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 | +| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | | 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` | | 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 53cfc762..e64799da 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -31,6 +31,7 @@ import { supplierRoutes } from '@/modules/suppliers/routes' import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes' import { overseasBusinessRoutes } from '@/modules/overseas-business/routes' import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes' +import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' const router = createRouter({ history: createWebHistory(), @@ -75,6 +76,7 @@ const router = createRouter({ ...masterDataRoutes, ...downloadRoutes, ...iamRoutes, + ...pdaReleaseRoutes, { path: '403', name: 'forbidden', diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index e5574aef..34c738c8 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -182,6 +182,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true }, { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true }, { key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' }, + { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list' }, ], }, ] diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index b16b216e..dc8cf66e 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -17,6 +17,8 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| +| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 | +| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 | | 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` | | 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 | | 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 3b75310e..c13e50c0 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -364,6 +364,15 @@ export async function getLastMileOrder(orderId: string) { return unwrapApiResponse(response) } +export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { + const response = await overseasHttp.post, ApiEnvelope>( + `/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`, + { decision, remark: remark?.trim() || undefined }, + { headers: { 'X-Customer-Id': customerId } }, + ) + return unwrapApiResponse(response) +} + export async function downloadLastMileOrders(query: Partial) { const { page: _page, size: _size, ...filters } = query const params = Object.fromEntries( diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index a55bd281..1a5b747b 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -98,6 +98,7 @@ export interface RelabelItemDto { cartonMark?: string | null fbaCartonMark?: string | null destinationWarehouseId?: string | null + destinationFbaWarehouseCode?: string | null } export interface RelabelHistoryDto { diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 3911ec2f..5efd50d8 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -15,10 +15,12 @@ const orderTypeLabels: Record = { const statusLabels: Record = { PENDING_REVIEW: '待审核', + PENDING_RECEIPT: '待签收入库', + PARTIALLY_RECEIVED: '部分入库', + INBOUND_COMPLETED: '全部入库', WAIT_PDA_RECEIVING: '入库测量中', INBOUND_MEASURING: '入库测量中', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', - INBOUND_COMPLETED: '入库完成', APPROVED: '审核通过', PENDING_ASSIGNMENT: '待分配', WAIT_RELABEL: '待换标', @@ -27,7 +29,6 @@ const statusLabels: Record = { WAIT_OUTBOUND: '待出库', ALLOCATED: '已分配库存', OUTBOUNDED: '已出库', - PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', IN_TRANSIT: '运输中', DESTINATION_ASSIGNED: '已分配目的仓', @@ -35,6 +36,7 @@ const statusLabels: Record = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', + REJECTED: '已驳回', CANCELLED: '已取消', } @@ -145,6 +147,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO customerId: String(dto.customerId), sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(), orderNo: lastMileOrderReference(dto.orderNo), + sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''), orderTypeCode: String(dto.orderType ?? '').trim(), currentStatusCode: String(dto.currentStatus ?? '').trim(), warning: dto.abnormal ? '异常' : '—', diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts index 81b0b92f..986296b7 100644 --- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts +++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts @@ -61,6 +61,8 @@ describe('overseas warehouse 1920px layout acceptance', () => { expect(page('DispatchDetailView.vue')).toContain('density-fields') expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields') expect(page('InventoryDetailView.vue')).toContain('density-fields') + expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(2) + expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }') const outbounds = page('OutboundsView.vue') expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }') expect(outbounds).not.toContain('.create-outbound-form :deep(.el-select)') @@ -78,4 +80,18 @@ describe('overseas warehouse 1920px layout acceptance', () => { expect(lastMileDetail).toContain(':title="isSingleLineBusinessField(field) ? String(field.value) : undefined"') expect(lastMileDetail).toContain('.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }') }) + + it('keeps the inbound detail compact and puts pallet facts before appointment metadata', () => { + const inbound = page('InboundOrderDetailView.vue') + expect(inbound).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))') + expect(inbound).toContain('class="section-heading"') + expect(inbound).toContain('.detail-section { min-width: 0; padding: 0;') + expect(inbound).toContain(':deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }') + expect(inbound).toContain('.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }') + expect(inbound).toContain('class="compact-section-heading"') + expect(inbound.match(/class="detail-table-wrap"/g)).toHaveLength(2) + expect(inbound).toContain('.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }') + expect(inbound.indexOf('class="detail-section pallet-section"')).toBeLessThan(inbound.indexOf('class="detail-section appointment-section"')) + expect(inbound).not.toContain("fields: ['状态', '预约状态'") + }) }) diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index a041e8dc..84257703 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -28,14 +28,14 @@ const fieldValues = computed>(() => ({ 海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0, '货物毛重(kg)': Number(detail.value?.totalGrossWeightKg || 0), '货物净重(kg)': Number(detail.value?.totalNetWeightKg || 0), - '计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status), + '计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: text(detail.value?.remark), })) -const groups = [ +const summaryGroups = [ { title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] }, { title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] }, - { title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] }, ] +const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }] const palletColumns: DataTableColumn[] = [ { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 }, @@ -60,18 +60,33 @@ onMounted(load) diff --git a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue index a7441dbb..bc79a2f9 100644 --- a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue @@ -31,11 +31,12 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted( diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index c07872ad..3e985e36 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -15,7 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' -import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders' +import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue' import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders' @@ -440,6 +440,11 @@ async function submitReceivingWarehouseChange() { } } function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] } +function isRejectedReturn(source: object) { + const row = source as LastMileOrderListItem + return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED' +} +function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) } async function loadTransferWarehouseOptions() { transferWarehouseLoading.value = true transferWarehouseError.value = null @@ -461,6 +466,7 @@ function selectTransferWarehouse(warehouseId: string) { } function openTransfer() { if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return } + if (hasSelectedRejectedReturn()) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' }) transferWarehouseOptions.value = [] transferWarehouseError.value = null @@ -493,6 +499,8 @@ function idempotencyKey(prefix: string) { function openInboundPlan(source?: object) { const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null) if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } + if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } + if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return } actionTarget.value = target const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1 inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum, @@ -776,11 +784,37 @@ watch(containerConvertOpen, (open) => { containerConversionIdempotencyKey = '' }) +async function reviewReturn(source: object) { + const row = source as LastMileOrderListItem + const returnNo = row.sourceOrderNo + if (!returnNo || !row.customerId) return + try { + await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true }) + await reviewReturnOrder(returnNo, row.customerId, 'APPROVE') + ElMessage.success('退货预报已审核通过') + await load() + } catch (error) { + if (error === 'cancel') { + try { + const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回退货预报', { inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' }) + await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value) + ElMessage.success('退货预报已驳回') + await load() + } catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '退货预报审核失败').message) } + } else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message) + } +} function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' } function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') } +function hasRelabelActions(row: LastMileOrderListItem) { + return access.can(permission.relabelOrderStatus) + && Boolean(row.relabelOrderNo) + && (canConfirmRelabel(row) || canReworkRelabel(row)) +} function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { return [ { key: 'detail', label: '查看', priority: 500 }, + { key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'inbound-plan', label: '入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) }, { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' }, @@ -791,6 +825,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { } function handleLastMileRowAction(command: string, row: LastMileOrderListItem) { if (command === 'detail') openDetail(row) + else if (command === 'review-return') void reviewReturn(row) else if (command === 'review') openTransferReview(row) else if (command === 'inbound-plan') openInboundPlan(row) else if (command === 'hold') openHold(row) @@ -890,7 +925,7 @@ onMounted(() => { void load() })
- + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts index f6c0b348..595a3caa 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts @@ -13,4 +13,10 @@ describe('RelabelOrderDetailView', () => { expect(source).toContain('text(item.fbaCartonMark)') expect(source).not.toContain("{ title: '箱唛换标信息', fields:") }) + + it('shows the review state and customer-facing return order and FBA warehouse codes', () => { + expect(source).toContain("PENDING_REVIEW: '待审核'") + expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo') + expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId') + }) }) diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue index f6ca525c..7ff83847 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue @@ -46,9 +46,16 @@ const groups = [ ] function text(value: unknown) { return String(value ?? '').trim() || '—' } function status(value: unknown) { - if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认' - if (value === 'PROCESSING') return '处理中' - return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(String(value || '')) ? '换标完成' : '待处理' + return ({ + PENDING_REVIEW: '待审核', + PENDING_PROCESSING: '待处理', + PROCESSING: '处理中', + CUSTOMER_CONFIRM_PENDING: '待客户确认', + READY_FOR_OUTBOUND: '换标完成', + COMPLETED: '换标完成', + REJECTED: '已驳回', + CANCELLED: '已取消', + } as Record)[String(value || '')] || text(value) } function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record)[String(value || '')] || text(value) } const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充')) @@ -57,7 +64,7 @@ const fieldValues = computed>(() => { const items = value?.items || [] return { 换标单号: text(value?.application.relabelNo), - 关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo), + 关联订单: text(value?.application.customerOrderNo || value?.application.lastlegOrderNo), 状态: status(value?.application.status), 资金状态: walletStatus(value?.application.walletStatus), 创建时间: formatOverseasDateTime(value?.application.createdAt), @@ -117,7 +124,7 @@ async function previewCompletionPhoto(fileId: string) { - +
我方箱唛FBA 箱唛商品 SKU中文品名FBA 货件编号换标新 SKU目的仓明细PDF
{{ text(item.cartonMark) }}{{ text(item.fbaCartonMark) }}{{ text(item.skuCode) }}{{ text(item.productNameZh) }}{{ text(item.shipmentNo) }}{{ text(item.newSkuCode) }}{{ text(item.destinationWarehouseId) }}{{ item.relabelFileName }}预览
{{ text(item.cartonMark) }}{{ text(item.fbaCartonMark) }}{{ text(item.skuCode) }}{{ text(item.productNameZh) }}{{ text(item.shipmentNo) }}{{ text(item.newSkuCode) }}{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}{{ item.relabelFileName }}预览
暂无箱唛换标信息
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts index 4b5cfcd3..4bdb1c06 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts @@ -109,19 +109,27 @@ describe('RelabelOrdersView', () => { })) }) - it('offers only backend-allowed audit transitions', async () => { + it('hides the audit action after approval', async () => { vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1, }) const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text().includes('审核'))!.trigger('click') - await flushPromises() + expect(wrapper.findAll('button').some((button) => button.text().trim() === '审核')).toBe(false) + }) - const labels = formItem(wrapper, '目标状态').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label')) - expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消'])) - expect(labels).not.toContain('已完成') + it('shows the audit action only while pending review', async () => { + vi.mocked(listRelabelOrders).mockResolvedValueOnce({ + records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_REVIEW' }], + page: 1, size: 10, total: 1, pages: 1, + }) + const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) + await flushPromises() + await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click') + await flushPromises() + const labels = formItem(wrapper, '审核动作').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label')) + expect(labels).toEqual(['审核通过', '已驳回']) }) it('passes the relabel order number search to the backend', async () => { diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue index a2ac6a4a..1ac13a66 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue @@ -35,7 +35,7 @@ const filters = ref({ relabelNo: '', sku: '', status: '' }) const fields = computed(() => [ { key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 }, { key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 }, - { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] }, + { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] }, ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, @@ -58,6 +58,7 @@ const editSourceStatus = ref('') const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' }) const createIdempotencyKey = ref('') const allowedStatusOptions = computed(() => { + if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED'] if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED'] if (editSourceStatus.value === 'PROCESSING') return ['COMPLETED', 'REJECTED', 'CANCELLED'] return [] @@ -127,6 +128,7 @@ async function submitForm() { ElMessage.success('换标单已创建') } else { if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return } + if (form.status === 'REJECTED' && !form.remark.trim()) { ElMessage.warning('审核驳回请填写原因'); return } submitting.value = true await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined) ElMessage.success('状态已更新') @@ -148,6 +150,7 @@ function relabelStatus(value: unknown) { } function relabelAuditStatusLabel(value: string) { return ({ + PENDING_PROCESSING: '审核通过', PROCESSING: '处理中', REJECTED: '已驳回', CANCELLED: '已取消', @@ -155,13 +158,13 @@ function relabelAuditStatusLabel(value: string) { } as Record)[value] || relabelStatus(value) } function relabelStatusCode(value: unknown) { - const codes: Record = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' } + const codes: Record = { 待审核: 'PENDING_REVIEW', 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' } return codes[String(value || '')] || String(value || 'PENDING_PROCESSING') } function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record)[String(value || '')] || String(value || '—') } function canAudit(source: object) { const row = source as RelabelOrderListItem - return ['PENDING_PROCESSING', 'PROCESSING'].includes(row.rawStatus || relabelStatusCode(row.status)) + return (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW' } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) @@ -205,7 +208,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' }) - +

作业要求

diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index 14de1e43..4879b9e0 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -28,6 +28,7 @@ export const overseasWarehousePermissions = { inventoryEdit: 'platform:overseas-warehouse:inventory:edit', inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust', returnInventoryList: 'platform:overseas-warehouse:return-inventory:list', + returnOrderReview: 'platform:overseas-warehouse:return-inventory:list', returnCartonsList: 'platform:overseas-warehouse:return-cartons:list', relabelOrderList: 'platform:overseas-warehouse:relabel-order:list', relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create', diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index 6af1448f..c6a0cc22 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -8,6 +8,7 @@ export interface LastMileOrderListItem { customerId: string sourceContainerBookingId: string orderNo: string + sourceOrderNo: string orderTypeCode: string currentStatusCode: string warning: string diff --git a/src/modules/pda-releases/api/pdaReleases.test.ts b/src/modules/pda-releases/api/pdaReleases.test.ts new file mode 100644 index 00000000..e0c3f186 --- /dev/null +++ b/src/modules/pda-releases/api/pdaReleases.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listPdaReleases, publishPdaRelease, uploadPdaRelease, withdrawPdaRelease } from './pdaReleases' + +vi.mock('@/shared/api/http', async () => { + const actual = await vi.importActual('@/shared/api/http') + return { ...actual, http: { get: vi.fn(), post: vi.fn() } } +}) + +describe('PDA release API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('keeps domestic and overseas lists isolated and uses only File service admin routes', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: [], message: 'success' }) + await listPdaReleases('domestic') + await listPdaReleases('overseas') + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/files/pda-releases/domestic') + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/pda-releases/overseas') + }) + + it('uploads a draft with compatibility fields and publishes or withdraws by release identity', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: 'release-1' }, message: 'success' }) + const file = new File(['PK\u0003\u0004'], 'app.wgt', { type: 'application/octet-stream' }) + await uploadPdaRelease('overseas', { packageType: 'wgt', versionName: '0.0.4', versionCode: 3, + minAppVersionCode: 3, mandatory: true, releaseNotes: '修复扫码', file }) + const [url, form, options] = vi.mocked(http.post).mock.calls[0]! + expect(url).toBe('/platform/v1/files/pda-releases/overseas') + expect(form).toBeInstanceOf(FormData) + expect((form as FormData).get('file')).toBe(file) + expect((form as FormData).get('minAppVersionCode')).toBe('3') + expect(options).toMatchObject({ timeout: 600_000 }) + await publishPdaRelease('release-1') + await withdrawPdaRelease('release-1') + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/files/pda-releases/release-1/publish', {}) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/files/pda-releases/release-1/withdraw', {}) + }) +}) diff --git a/src/modules/pda-releases/api/pdaReleases.ts b/src/modules/pda-releases/api/pdaReleases.ts new file mode 100644 index 00000000..40ed05db --- /dev/null +++ b/src/modules/pda-releases/api/pdaReleases.ts @@ -0,0 +1,66 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type PdaApp = 'domestic' | 'overseas' +export type PdaPackageType = 'apk' | 'wgt' +export interface PdaRelease { + id: string + app: PdaApp + appid: string + packageType: PdaPackageType + versionName: string + versionCode: number + minAppVersionCode: number + mandatory: boolean + releaseNotes: string + status: 'DRAFT' | 'PUBLISHED' | 'WITHDRAWN' + current: boolean + sha256: string + sizeBytes: number + downloadUrl: string | null + createdAt: string + publishedAt: string | null +} + +const basePath = '/platform/v1/files/pda-releases' + +export async function listPdaReleases(app: PdaApp) { + const response = await http.get, ApiEnvelope>(`${basePath}/${app}`) + return unwrapApiResponse(response) +} + +export async function uploadPdaRelease(app: PdaApp, data: { + packageType: PdaPackageType + versionName: string + versionCode: number + minAppVersionCode: number + mandatory: boolean + releaseNotes: string + file: File +}) { + const form = new FormData() + form.append('packageType', data.packageType) + form.append('versionName', data.versionName) + form.append('versionCode', String(data.versionCode)) + form.append('minAppVersionCode', String(data.minAppVersionCode)) + form.append('mandatory', String(data.mandatory)) + form.append('releaseNotes', data.releaseNotes) + form.append('file', data.file) + const response = await http.post, ApiEnvelope>(`${basePath}/${app}`, form, { + timeout: 10 * 60 * 1000, + }) + return unwrapApiResponse(response) +} + +export async function publishPdaRelease(id: string) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(id)}/publish`, {}, + ) + return unwrapApiResponse(response) +} + +export async function withdrawPdaRelease(id: string) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(id)}/withdraw`, {}, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/pda-releases/pages/PdaReleasesView.test.ts b/src/modules/pda-releases/pages/PdaReleasesView.test.ts new file mode 100644 index 00000000..25766476 --- /dev/null +++ b/src/modules/pda-releases/pages/PdaReleasesView.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { pdaReleaseRoutes } from '../routes' +import { adminNavigation } from '@/core/navigation/adminNavigation' + +const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8') + +describe('PDA release workbench', () => { + it('registers one permission-gated route and a system menu entry', () => { + expect(pdaReleaseRoutes[0]?.meta?.permission).toBe('platform:pda-release:list') + const system = adminNavigation.find((item) => item.key === 'system') + expect(system?.children?.find((item) => item.key === 'pda-releases')).toMatchObject({ + path: '/system/pda-releases', permission: 'platform:pda-release:list', + }) + }) + + it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => { + expect(page).toContain('value="domestic"') + expect(page).toContain('value="overseas"') + expect(page).toContain('row.packageType === \'apk\' && row.downloadUrl') + expect(page).toContain('v-if="canManage"') + expect(page).toContain('row.status === \'DRAFT\'') + expect(page).toContain('row.current') + expect(page).toContain('ElMessageBox.confirm') + }) +}) diff --git a/src/modules/pda-releases/pages/PdaReleasesView.vue b/src/modules/pda-releases/pages/PdaReleasesView.vue new file mode 100644 index 00000000..1ccdc69c --- /dev/null +++ b/src/modules/pda-releases/pages/PdaReleasesView.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/modules/pda-releases/routes.ts b/src/modules/pda-releases/routes.ts new file mode 100644 index 00000000..53b86464 --- /dev/null +++ b/src/modules/pda-releases/routes.ts @@ -0,0 +1,10 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const pdaReleaseRoutes = [ + { + path: 'system/pda-releases', + name: 'pda-releases', + component: () => import('@/modules/pda-releases/pages/PdaReleasesView.vue'), + meta: { title: 'PDA版本', description: '国内与海外PDA安装包下载和热更新发布', permission: 'platform:pda-release:list' }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/unified-orders/api/unifiedOrders.test.ts b/src/modules/unified-orders/api/unifiedOrders.test.ts index a22c8d7d..c1aa7174 100644 --- a/src/modules/unified-orders/api/unifiedOrders.test.ts +++ b/src/modules/unified-orders/api/unifiedOrders.test.ts @@ -11,6 +11,7 @@ import { listPlatformUnifiedOrderFeeDetails, updatePlatformUnifiedOrderDeclaration, updatePlatformUnifiedOrderPlanning, + importPlatformLegacyOrders, } from './unifiedOrders' vi.mock('@/shared/api/http', () => ({ @@ -25,6 +26,19 @@ describe('unified orders API contract', () => { vi.mocked(http.put).mockReset() }) + it('uploads the old-system workbook with a retry-safe key', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { total: 1, succeeded: 1, failed: 0, items: [] } }) + const file = new File(['xlsx'], 'legacy.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + + await expect(importPlatformLegacyOrders(file, 'legacy-key')).resolves.toMatchObject({ succeeded: 1 }) + + const [path, body, config] = vi.mocked(http.post).mock.calls[0] + expect(path).toBe('/platform/v1/orders/first-leg/legacy-import') + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('file')).toBe(file) + expect(config).toEqual({ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': 'legacy-key' } }) + }) + it('uses the C02 platform orders endpoint and contract filters', async () => { vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } }) await expect(listPlatformUnifiedOrders({ diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts index f24bcca3..69148e5e 100644 --- a/src/modules/unified-orders/api/unifiedOrders.ts +++ b/src/modules/unified-orders/api/unifiedOrders.ts @@ -611,6 +611,37 @@ export interface UnifiedOrderRemarksUpdateRequest { customerServiceRemark: string | null } +export interface LegacyOrderImportItem { + excelRow: number + success: boolean + legacyInboundCode: string | null + orderId: number | string | null + orderNo: string | null + errorMessage: string | null +} + +export interface LegacyOrderImportResult { + total: number + succeeded: number + failed: number + items: LegacyOrderImportItem[] +} + +export function importPlatformLegacyOrders(file: File, idempotencyKey: string) { + const form = new FormData() + form.append('file', file) + return http.post, ApiEnvelope>( + '/platform/v1/orders/first-leg/legacy-import', + form, + { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': idempotencyKey } }, + ).then(unwrapApiResponse) +} + +export function createLegacyOrderImportIdempotencyKey() { + const uuid = globalThis.crypto?.randomUUID?.() + return uuid ? `legacy-order-import-${uuid}` : `legacy-order-import-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + export interface UnifiedOrderRemarksUpdateResponse { version: number customerRemark: string | null diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts index f2551cba..d4a21393 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts @@ -104,6 +104,29 @@ describe('UnifiedOrdersView co-load group behavior', () => { expect(wrapper.text()).not.toContain('7000') }) + it.each(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT', 'LOADING'] as const)( + 'allows creating a co-load group when both orders are %s', async (mainStatus) => { + const selectedRows = rows.map(row => ({ ...row, mainStatus })) + vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: selectedRows, page: 1, size: 20, total: 2, pages: 1 }) + const wrapper = mountView() + await flushPromises() + await wrapper.get('[data-testid="select"]').trigger('click') + expect(wrapper.findAll('button').find((button) => button.text().includes('合柜报关'))!.attributes('disabled')).toBeUndefined() + }, + ) + + it.each(['LOADED', 'DELIVERED', 'COMPLETED'] as const)( + 'blocks creating a co-load group when selected orders are %s', async (mainStatus) => { + const selectedRows = rows.map(row => ({ ...row, mainStatus })) + vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: selectedRows, page: 1, size: 20, total: 2, pages: 1 }) + const wrapper = mountView() + await flushPromises() + await wrapper.get('[data-testid="select"]').trigger('click') + expect(wrapper.findAll('button').find((button) => button.text().includes('合柜报关'))!.attributes('disabled')).toBeDefined() + expect(createPlatformCoLoadGroup).not.toHaveBeenCalled() + }, + ) + it.each([ ['平台收货仓', [{ ...rows[0], receivingWarehouseId: null, receivingWarehouseName: null }, rows[1]], '以下入仓码缺少平台收货仓:IN-1(仓库:未配置仓库;渠道:纽约渠道),请刷新后重试'], ['渠道', [{ ...rows[0], channelCode: null, channelName: null }, rows[1]], '以下入仓码缺少渠道:IN-1(仓库:义乌仓;渠道:未配置渠道),请刷新后重试'], diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 7249a96e..3c8db07c 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -9,6 +9,15 @@ const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-ord const densitySource = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8') describe('UnifiedOrdersView contract', () => { + it('imports old-system workbooks only for the fixed test customer', () => { + expect(source).toContain("can('platform:order:import')") + expect(source).toContain('导入旧系统订单') + expect(source).toContain('固定归属客户') + expect(source).toContain('IT测试公司') + expect(source).toContain('accept=".xlsx"') + expect(source).toContain('legacyImportResult.items.filter((item) => !item.success)') + }) + it('exposes desktop pallet printing only for one-click dropship orders', () => { expect(source).toContain("detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP'") expect(source).toContain("{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }") @@ -48,7 +57,7 @@ describe('UnifiedOrdersView contract', () => { it('exposes the product main statuses including dropship palletizing', () => { expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([ - '待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成', + '待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成', ]) for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) { expect(source).not.toContain(legacyLabel) @@ -151,7 +160,7 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('v-model:page-size="pageSize"') const columnSource = source.slice(source.indexOf('const columns ='), source.indexOf('function emptyFilters')) const expectedColumnKeys = [ - 'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName', + 'actions', 'inboundCode', 'orderType', 'mainStatus', 'customerName', 'receivingWarehouseName', 'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg', 'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate', 'estimatedVolumeCbm', 'expectedVolumeCbm', 'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo', @@ -171,6 +180,10 @@ describe('UnifiedOrdersView contract', () => { expect(columnSource).not.toContain("key: 'coLoadGroup'") expect(columnSource).not.toContain("key: 'orderNo'") expect(columnSource).toContain("key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode'") + expect(columnSource).toContain("key: 'orderType', label: '订单类型', minWidth: 120, slotName: 'orderType'") + expect(source).toContain("if (row.fulfillmentType === 'ONE_CLICK_DROPSHIP') return '一件代发'") + expect(source).toContain("if (row.businessType === 'FBA' || row.businessType === 'FBA_SEA') return 'FBA 头程'") + expect(source).toContain("if (row.businessType === 'OVERSEAS_SEA') return '海外中转'") expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'") expect(columnSource).not.toContain("key: 'loadingStatus'") expect(columnSource).not.toContain("key: 'businessDate'") diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 86f0e4ff..e6fffb40 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -19,6 +19,9 @@ import { type UnifiedOrderResource, type UnifiedOrderBatchApprovalPreviewItem, type UnifiedOrderBatchReviewItem, + importPlatformLegacyOrders, + createLegacyOrderImportIdempotencyKey, + type LegacyOrderImportResult, } from '@/modules/unified-orders/api/unifiedOrders' import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups' import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue' @@ -117,6 +120,12 @@ const pageSize = ref(20) const total = ref(0) const loading = ref(false) const loadError = ref(null) +const legacyImportOpen = ref(false) +const legacyImportFile = ref(null) +const legacyImportIdempotencyKey = ref('') +const legacyImportSaving = ref(false) +const legacyImportResult = ref(null) +const legacyImportError = ref(null) type PlanningDraft = { expectedArrivalAt: string estimatedVolumeCbm: number | null @@ -633,6 +642,7 @@ const columns = computed(() => [ selectable: () => !batchApprovalSaving.value && !coLoadSaving.value && !exportSubmitting.value, } satisfies DataTableColumn] : []), { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode' }, + { key: 'orderType', label: '订单类型', minWidth: 120, slotName: 'orderType' }, { key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 120, slotName: 'mainStatus' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'field' }, { key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 100, slotName: 'field' }, @@ -1793,6 +1803,9 @@ function coLoadSelectionMembersLabel(rows: UnifiedOrderResource[]) { const coLoadSelectionProblem = computed(() => { if (selectedOrders.value.length < 2) return null + const allowedStatuses = new Set(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT', 'LOADING']) + const invalidStatusRows = selectedOrders.value.filter((row) => !allowedStatuses.has(row.mainStatus)) + if (invalidStatusRows.length) return `以下入仓码当前状态不能合柜:${coLoadSelectionMembersLabel(invalidStatusRows)}` const groupedRows = selectedOrders.value.filter((row) => row.coLoadGroup && row.coLoadGroup.status !== 'CANCELLED') if (groupedRows.length) return `以下入仓码已有同柜组:${coLoadSelectionMembersLabel(groupedRows)},请先解除原同柜组` const missingWarehouseRows = selectedOrders.value.filter((row) => !String(row.receivingWarehouseId || '').trim()) @@ -1811,6 +1824,48 @@ function coLoadGroup(row: UnifiedOrderResource) { return row.coLoadGroup || null } +function openLegacyImport() { + legacyImportFile.value = null + legacyImportResult.value = null + legacyImportError.value = null + legacyImportIdempotencyKey.value = '' + legacyImportOpen.value = true +} + +function selectLegacyImportFile(uploadFile: UploadFile) { + legacyImportFile.value = uploadFile.raw || null + legacyImportResult.value = null + legacyImportError.value = null + legacyImportIdempotencyKey.value = createLegacyOrderImportIdempotencyKey() +} + +async function submitLegacyImport() { + if (!legacyImportFile.value || legacyImportSaving.value) return + legacyImportSaving.value = true + legacyImportError.value = null + try { + legacyImportResult.value = await importPlatformLegacyOrders( + legacyImportFile.value, + legacyImportIdempotencyKey.value, + ) + const result = legacyImportResult.value + if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`) + else ElMessage.success(`已导入 ${result.succeeded} 条旧系统订单`) + await loadOrders() + } catch (error) { + legacyImportError.value = getApiErrorDetails(error, '旧系统订单导入失败,请核对文件后重试') + } finally { + legacyImportSaving.value = false + } +} + +function orderTypeLabel(row: UnifiedOrderResource) { + if (row.fulfillmentType === 'ONE_CLICK_DROPSHIP') return '一件代发' + if (row.businessType === 'FBA' || row.businessType === 'FBA_SEA') return 'FBA 头程' + if (row.businessType === 'OVERSEAS_SEA') return '海外中转' + return firstLegBusinessTypeLabel(row.businessType) +} + function unifiedOrderRowClassName({ row }: { row: UnifiedOrderResource }) { return row.coLoadGroup && row.coLoadGroup.status !== 'CANCELLED' ? 'is-coload-group-row' : '' } @@ -2654,6 +2709,7 @@ onBeforeUnmount(() => { + @@ -2731,7 +2788,29 @@ onBeforeUnmount(() => { + + +
+
+
固定归属客户
IT测试公司
+
导入结果状态
待审核
+
+ + 选择 Excel 文件 + + + +
+

共 {{ legacyImportResult.total }} 条,成功 {{ legacyImportResult.succeeded }} 条,失败 {{ legacyImportResult.failed }} 条。

+ + + + + +
+
+
@@ -3016,6 +3095,12 @@ onBeforeUnmount(() => { .hold-summary dt { flex: 0 0 auto; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); } .hold-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); overflow-wrap: anywhere; } .hold-actions { display: flex; flex: 0 0 auto; gap: var(--admin-space-3, 8px); } +.legacy-import-content { display: grid; gap: var(--admin-space-5, 12px); } +.legacy-import-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); } +.legacy-import-facts > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); align-items: center; padding-block: var(--admin-space-2, 6px); } +.legacy-import-facts dt { padding: 8px 10px; color: var(--cc-text-muted); } +.legacy-import-facts dd { margin: 0; padding: 8px 10px; font-weight: 600; } +.legacy-import-result > p { margin: 0 0 var(--admin-space-3, 8px); } .hold-actions :deep(.el-button) { margin-left: 0; } .hold-block-reason { margin: var(--admin-space-5, 12px) 0 0; padding: 9px var(--admin-space-5, 12px); border: 1px solid var(--el-color-warning-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); } .hold-history-title { margin: var(--admin-space-6, 16px) 0 var(--admin-space-3, 8px); color: #344054; font-size: var(--admin-font-section, 14px); }