优化
This commit is contained in:
@@ -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`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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<PdaRelease[]>, ApiEnvelope<PdaRelease[]>>(`${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<PdaRelease>, ApiEnvelope<PdaRelease>>(`${basePath}/${app}`, form, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function publishPdaRelease(id: string) {
|
||||
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(
|
||||
`${basePath}/${encodeURIComponent(id)}/publish`, {},
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function withdrawPdaRelease(id: string) {
|
||||
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(
|
||||
`${basePath}/${encodeURIComponent(id)}/withdraw`, {},
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Plus, RefreshCw } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorMessage } from '@/shared/api/http'
|
||||
import {
|
||||
listPdaReleases, publishPdaRelease, uploadPdaRelease, withdrawPdaRelease,
|
||||
type PdaApp, type PdaPackageType, type PdaRelease,
|
||||
} from '../api/pdaReleases'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const canManage = computed(() => access.can('platform:pda-release:manage'))
|
||||
const app = ref<PdaApp>('domestic')
|
||||
const rows = ref<PdaRelease[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionId = ref('')
|
||||
const uploadOpen = ref(false)
|
||||
const uploading = ref(false)
|
||||
const form = reactive({ packageType: 'wgt' as PdaPackageType, versionName: '', versionCode: 1,
|
||||
minAppVersionCode: 1, mandatory: false, releaseNotes: '', file: null as File | null })
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 215, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'app', label: '应用', width: 110, slotName: 'app' },
|
||||
{ key: 'packageType', label: '包类型', width: 95, slotName: 'packageType' },
|
||||
{ key: 'versionName', prop: 'versionName', label: '版本', width: 115 },
|
||||
{ key: 'versionCode', prop: 'versionCode', label: 'APK版本码', width: 105 },
|
||||
{ key: 'minAppVersionCode', prop: 'minAppVersionCode', label: '最低APK版本码', width: 135 },
|
||||
{ key: 'status', label: '状态', width: 115, slotName: 'status' },
|
||||
{ key: 'mandatory', label: '强制更新', width: 100, slotName: 'mandatory' },
|
||||
{ key: 'releaseNotes', prop: 'releaseNotes', label: '更新说明', minWidth: 190, showOverflowTooltip: true },
|
||||
{ key: 'sizeBytes', label: '大小', width: 100, slotName: 'sizeBytes' },
|
||||
{ key: 'createdAt', label: '上传时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' },
|
||||
]
|
||||
|
||||
const currentApk = computed(() => rows.value.find((row) => row.current && row.packageType === 'apk'))
|
||||
const currentWgt = computed(() => rows.value.find((row) => row.current && row.packageType === 'wgt'))
|
||||
function asRelease(row: unknown) { return row as PdaRelease }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const requestedApp = app.value
|
||||
try {
|
||||
const result = await listPdaReleases(requestedApp)
|
||||
if (app.value === requestedApp) rows.value = result
|
||||
} catch (cause) {
|
||||
if (app.value === requestedApp) error.value = getApiErrorMessage(cause, '版本列表加载失败')
|
||||
} finally {
|
||||
if (app.value === requestedApp) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(app, () => { rows.value = []; void load() })
|
||||
onMounted(() => { void load() })
|
||||
|
||||
function openUpload() {
|
||||
Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1,
|
||||
minAppVersionCode: currentApk.value?.versionCode ?? 1, mandatory: false, releaseNotes: '', file: null })
|
||||
uploadOpen.value = true
|
||||
}
|
||||
|
||||
function selectFile(event: Event) {
|
||||
form.file = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!form.file || !/^\d+(\.\d+)+$/.test(form.versionName)
|
||||
|| form.versionCode < 1 || form.minAppVersionCode < 1
|
||||
|| (form.packageType === 'apk' && form.versionCode !== form.minAppVersionCode)) {
|
||||
ElMessage.warning('请填写有效版本、最低APK版本码并选择对应安装包')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadPdaRelease(app.value, { ...form, file: form.file })
|
||||
uploadOpen.value = false
|
||||
ElMessage.success('版本草稿已上传,请核对后发布')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, '更新包上传失败'))
|
||||
} finally { uploading.value = false }
|
||||
}
|
||||
|
||||
async function changeStatus(row: PdaRelease, action: 'publish' | 'withdraw') {
|
||||
const label = action === 'publish' ? '发布' : '停发'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${label}${row.app === 'domestic' ? '国内' : '海外'}PDA ${row.packageType.toUpperCase()} ${row.versionName}?${action === 'publish' ? '发布后设备将收到更新。' : '停发后设备不再获取这个版本。'}`,
|
||||
`${label}PDA版本`, { type: 'warning', confirmButtonText: `确认${label}`, cancelButtonText: '取消', customClass: 'admin-density-confirm' })
|
||||
} catch { return }
|
||||
actionId.value = row.id
|
||||
try {
|
||||
if (action === 'publish') await publishPdaRelease(row.id)
|
||||
else await withdrawPdaRelease(row.id)
|
||||
ElMessage.success(`版本已${label}`)
|
||||
await load()
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`))
|
||||
} finally { actionId.value = '' }
|
||||
}
|
||||
|
||||
function download(row: PdaRelease) {
|
||||
if (!row.downloadUrl) return
|
||||
const link = document.createElement('a')
|
||||
link.href = row.downloadUrl
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.download = `${row.app}-pda-${row.versionName}.${row.packageType}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density pda-releases-page" spacing="compact">
|
||||
<ListPageLayout aria-label="PDA版本">
|
||||
<template #toolbar>
|
||||
<div class="pda-release-toolbar">
|
||||
<div>
|
||||
<h1>PDA版本</h1>
|
||||
<el-radio-group v-model="app" aria-label="选择PDA应用">
|
||||
<el-radio-button value="domestic">国内 PDA</el-radio-button>
|
||||
<el-radio-button value="overseas">海外 PDA</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="pda-release-commands">
|
||||
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #metrics>
|
||||
<div class="pda-release-current">
|
||||
<span>当前 APK:{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</span>
|
||||
<span>当前 WGT:{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #state>
|
||||
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
|
||||
</template>
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
|
||||
<template #actions="{ row }">
|
||||
<div class="pda-release-actions">
|
||||
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" link @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button>
|
||||
<el-button v-if="canManage && row.status === 'DRAFT'" link :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
|
||||
<el-button v-if="canManage && row.current" link type="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #app="{ row }">{{ row.app === 'domestic' ? '国内' : '海外' }}</template>
|
||||
<template #packageType="{ row }">{{ row.packageType.toUpperCase() }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.current ? 'success' : row.status === 'DRAFT' ? 'info' : 'warning'" size="small">{{ row.current ? '当前发布' : row.status === 'DRAFT' ? '草稿' : row.status === 'WITHDRAWN' ? '已停发' : '历史发布' }}</el-tag></template>
|
||||
<template #mandatory="{ row }">{{ row.mandatory ? '是' : '否' }}</template>
|
||||
<template #sizeBytes="{ row }">{{ (row.sizeBytes / 1024 / 1024).toFixed(1) }} MB</template>
|
||||
<template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
|
||||
<template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
|
||||
</DataTable>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
|
||||
<el-form label-position="top" class="pda-release-form">
|
||||
<el-form-item label="包类型">
|
||||
<el-radio-group v-model="form.packageType"><el-radio-button value="apk">APK 整包</el-radio-button><el-radio-button value="wgt">WGT 热更新</el-radio-button></el-radio-group>
|
||||
</el-form-item>
|
||||
<div class="pda-release-fields">
|
||||
<el-form-item label="版本号"><el-input v-model.trim="form.versionName" placeholder="例如 0.0.4" /></el-form-item>
|
||||
<el-form-item label="APK版本码"><el-input-number v-model="form.versionCode" :min="1" :controls="false" /></el-form-item>
|
||||
<el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" /></el-form-item>
|
||||
<el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="更新说明"><el-input v-model="form.releaseNotes" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-form-item :label="`选择 ${form.packageType.toUpperCase()} 文件(最大200MB)`"><input :key="form.packageType" type="file" :accept="`.${form.packageType}`" @change="selectFile" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pda-release-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
|
||||
.pda-release-toolbar h1 { margin: 0 0 10px; font-size: var(--admin-font-title, 16px); }
|
||||
.pda-release-commands, .pda-release-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.pda-release-commands :deep(.el-button), .pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.pda-release-current { display: flex; gap: 24px; padding: 10px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.pda-release-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.pda-release-form :deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
@@ -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[]
|
||||
@@ -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({
|
||||
|
||||
@@ -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<LegacyOrderImportResult>, ApiEnvelope<LegacyOrderImportResult>>(
|
||||
'/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
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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<ApiErrorDetails | null>(null)
|
||||
const legacyImportOpen = ref(false)
|
||||
const legacyImportFile = ref<File | null>(null)
|
||||
const legacyImportIdempotencyKey = ref('')
|
||||
const legacyImportSaving = ref(false)
|
||||
const legacyImportResult = ref<LegacyOrderImportResult | null>(null)
|
||||
const legacyImportError = ref<ApiErrorDetails | null>(null)
|
||||
type PlanningDraft = {
|
||||
expectedArrivalAt: string
|
||||
estimatedVolumeCbm: number | null
|
||||
@@ -633,6 +642,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
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(() => {
|
||||
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-class-name="unifiedOrderRowClassName" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="can('platform:order:import')" :disabled="loading" @click="openLegacyImport"><FileUp :size="16" />导入旧系统订单</el-button>
|
||||
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalLoading || batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="openBatchApproval"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
|
||||
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
|
||||
@@ -2677,6 +2730,7 @@ onBeforeUnmount(() => {
|
||||
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #orderType="{ row }"><el-tag size="small" effect="light" type="info">{{ orderTypeLabel(row as UnifiedOrderResource) }}</el-tag></template>
|
||||
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
||||
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
|
||||
<template #channel="{ row }"><div class="channel-cell"><LineClampText :content="(row as UnifiedOrderResource).channelName" /><el-tag v-if="(row as UnifiedOrderResource).channelChanged" size="small" type="warning">曾变更渠道</el-tag></div></template>
|
||||
@@ -2731,7 +2785,29 @@ onBeforeUnmount(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog" v-model="legacyImportOpen" title="导入旧系统订单" width="min(760px, calc(100vw - 28px))" confirm-text="开始导入" :confirm-loading="legacyImportSaving" :confirm-disabled="!legacyImportFile || legacyImportSaving || !!legacyImportResult" destroy-on-close @confirm="submitLegacyImport">
|
||||
<div class="legacy-import-content">
|
||||
<dl class="legacy-import-facts">
|
||||
<div><dt>固定归属客户</dt><dd>IT测试公司</dd></div>
|
||||
<div><dt>导入结果状态</dt><dd>待审核</dd></div>
|
||||
</dl>
|
||||
<el-upload :auto-upload="false" :limit="1" accept=".xlsx" :on-change="selectLegacyImportFile">
|
||||
<el-button><FileUp :size="16" />选择 Excel 文件</el-button>
|
||||
<template #tip><div class="el-upload__tip">仅支持旧系统“订单统计导出” .xlsx 文件,最多 200 条、5MB;入仓码保持不变。</div></template>
|
||||
</el-upload>
|
||||
<LoadErrorState v-if="legacyImportError" compact title="旧系统订单导入失败" :message="legacyImportError.message" :show-retry="false"><template #details><ApiErrorMeta :details="legacyImportError" /></template></LoadErrorState>
|
||||
<div v-if="legacyImportResult" class="legacy-import-result">
|
||||
<p>共 {{ legacyImportResult.total }} 条,成功 {{ legacyImportResult.succeeded }} 条,失败 {{ legacyImportResult.failed }} 条。</p>
|
||||
<el-table v-if="legacyImportResult.failed" :data="legacyImportResult.items.filter((item) => !item.success)" border stripe max-height="280">
|
||||
<el-table-column prop="excelRow" label="Excel 行" width="90" />
|
||||
<el-table-column prop="legacyInboundCode" label="入仓码" min-width="160" />
|
||||
<el-table-column prop="errorMessage" label="失败原因" min-width="300" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<SmartLoadingDialog v-model="smartLoadingOpen" />
|
||||
<AppDialog class="admin-density-dialog" v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
|
||||
<el-alert title="请逐票核对入仓码、客户、中文品名、渠道和目的仓;审核通过后订单进入待测量。" type="info" :closable="false" show-icon />
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user