From 60a41af3ac991c899ec7867bce037c36dc2b9726 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 14:22:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- src/app/router/index.ts | 2 + src/core/navigation/adminNavigation.ts | 1 + src/modules/pda-releases/api/pdaReleases.ts | 66 ++++++ .../pda-releases/pages/PdaReleasesView.vue | 192 ++++++++++++++++++ src/modules/pda-releases/routes.ts | 10 + .../unified-orders/api/unifiedOrders.test.ts | 14 ++ .../unified-orders/api/unifiedOrders.ts | 31 +++ .../pages/UnifiedOrdersView.test.ts | 17 +- .../pages/UnifiedOrdersView.vue | 82 ++++++++ 10 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 src/modules/pda-releases/api/pdaReleases.ts create mode 100644 src/modules/pda-releases/pages/PdaReleasesView.vue create mode 100644 src/modules/pda-releases/routes.ts diff --git a/AGENTS.md b/AGENTS.md index 8bb0adf6..f0c1959a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ | 平台用户、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/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.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.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..f23927c3 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' }, @@ -1811,6 +1821,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 +2706,7 @@ onBeforeUnmount(() => { + @@ -2731,7 +2785,29 @@ onBeforeUnmount(() => { + + +
+
+
固定归属客户
IT测试公司
+
导入结果状态
待审核
+
+ + 选择 Excel 文件 + + + +
+

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

+ + + + + +
+
+
@@ -3016,6 +3092,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); }