Release/test #7

Merged
admin merged 11 commits from release/test into main 2026-09-20 19:49:09 +08:00
27 changed files with 644 additions and 34 deletions
+2 -1
View File
@@ -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`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
+2
View File
@@ -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',
+1
View File
@@ -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' },
],
},
]
+2
View File
@@ -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/` |
@@ -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<unknown>, ApiEnvelope<unknown>>(
`/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<LastMileOrderQuery>) {
const { page: _page, size: _size, ...filters } = query
const params = Object.fromEntries(
@@ -98,6 +98,7 @@ export interface RelabelItemDto {
cartonMark?: string | null
fbaCartonMark?: string | null
destinationWarehouseId?: string | null
destinationFbaWarehouseCode?: string | null
}
export interface RelabelHistoryDto {
@@ -15,10 +15,12 @@ const orderTypeLabels: Record<string, string> = {
const statusLabels: Record<string, string> = {
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<string, string> = {
WAIT_OUTBOUND: '待出库',
ALLOCATED: '已分配库存',
OUTBOUNDED: '已出库',
PARTIALLY_RECEIVED: '部分收货',
RECEIVED: '已收货',
IN_TRANSIT: '运输中',
DESTINATION_ASSIGNED: '已分配目的仓',
@@ -35,6 +36,7 @@ const statusLabels: Record<string, string> = {
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 ? '异常' : '—',
@@ -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: ['状态', '预约状态'")
})
})
@@ -28,14 +28,14 @@ const fieldValues = computed<Record<string, string | number>>(() => ({
海外仓: 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)
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数。</p><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细" /></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细" /></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell></template>
<style scoped>
.detail-group:nth-child(2) { border-right: 0; }.detail-group:last-child { grid-column: 1 / -1; border-right: 0; border-bottom: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }.fee-section { overflow:hidden; padding-top:var(--admin-space-5); }.fee-section h2 { margin-right:var(--admin-space-5); margin-left:var(--admin-space-5); }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:var(--admin-space-4) var(--admin-space-5); border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.pallet-section { overflow: hidden; padding-top: var(--admin-space-5); }.pallet-section h2 { margin: 0 var(--admin-space-5) var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0 var(--admin-space-5) var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media(max-width:1200px){.detail-groups,.detail-fields,.detail-group:last-child .detail-fields{grid-template-columns:1fr}.detail-group:last-child{grid-column:auto}.detail-group{border-right:0}}
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
.detail-group h2 { margin: 0 0 var(--admin-space-3); color: var(--cc-text); font-size: var(--admin-font-section); }
.compact-section-heading { display:flex; min-height:42px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-2) var(--admin-space-5); border-bottom:1px solid var(--cc-border); }
.compact-section-heading h2 { margin:0; color:var(--cc-text); font-size:var(--admin-font-section); }
.compact-section-heading span { color:var(--cc-muted); font-size:var(--admin-font-caption); }
.fee-section { overflow:hidden; }.fee-total { display:flex; min-height:44px; align-items:center; justify-content:flex-end; gap:40px; margin:0 var(--admin-space-5) var(--admin-space-4); padding:var(--admin-space-3) var(--admin-space-5); border:1px solid var(--cc-border); border-top:0; }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.pallet-section { overflow: hidden; }.section-heading { display:flex; min-height:50px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-3) var(--admin-space-5); border-bottom:1px solid var(--cc-border); }.pallet-section h2 { margin: 0 0 var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }.section-count { flex:0 0 auto; color:var(--cc-muted); font-size:var(--admin-font-caption); font-variant-numeric:tabular-nums; }
.appointment-section,.fee-section { border-top:1px solid var(--cc-border); }
.appointment-section { padding:0 var(--admin-space-5) var(--admin-space-4); }
.appointment-section .compact-section-heading { margin:0 calc(0px - var(--admin-space-5)) var(--admin-space-3); }
.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }
.appointment-fields > .is-wide { grid-column:span 2; }
@media(max-width:1400px){.detail-fields{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(max-width:1200px){.detail-fields,.appointment-fields{grid-template-columns:repeat(2,minmax(0,1fr))}.appointment-fields > .is-wide{grid-column:span 2}}
.detail-section { min-width: 0; border: 0; background: #fff; }
.detail-groups { display: grid; grid-template-columns: minmax(0, 1fr); }
.detail-group { min-width: 0; padding: var(--admin-space-5) var(--admin-space-5) var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-group { min-width: 0; padding: var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
.detail-fields { --admin-field-label-width: 108px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
:deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }
.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }
.fee-section .detail-table-wrap { padding-bottom:0; }
</style>
@@ -31,11 +31,12 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(
<template><OverseasDetailShell class="admin-density" dense title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields density-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields density-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><div class="inventory-table-wrap"><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><div class="inventory-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
</OverseasDetailShell></template>
<style scoped>.overview-panel>section{min-width:0;padding:var(--admin-space-5) var(--admin-space-5) var(--admin-space-5);border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 var(--admin-space-5);color:var(--cc-text);font-size:var(--admin-font-section)}.ledger-section h2,.ledger-note{margin-right:var(--admin-space-5);margin-left:var(--admin-space-5)}.ledger-note{color:var(--cc-muted);font-size:var(--admin-font-caption)}@media(max-width:1100px){.overview-panel,.detail-fields{grid-template-columns:1fr}.overview-panel>section{border-right:0;border-bottom:1px solid var(--cc-border)}}
.overview-panel {display:grid;min-width:0; grid-template-columns: minmax(0, 1fr); border: 0;background:#fff; }
.overview-panel > section { border-right: 0; }
.ledger-section {min-width:0;overflow:hidden;padding-top:var(--admin-space-5); border: 0;background:#fff; border-top: 1px solid var(--cc-border); }
.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }
</style>
@@ -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() })
</div>
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
@@ -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')
})
})
@@ -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, string>)[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,string>)[String(value || '')] || text(value) }
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
@@ -57,7 +64,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
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) {
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -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 () => {
@@ -35,7 +35,7 @@ const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ 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<string, string>)[value] || relabelStatus(value)
}
function relabelStatusCode(value: unknown) {
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' }
const codes: Record<string, string> = { 待审核: '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,string>)[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 = '' })
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="目标状态"><el-select v-model="form.status" placeholder="请选择目标状态"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></el-col>
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
@@ -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',
+1
View File
@@ -8,6 +8,7 @@ export interface LastMileOrderListItem {
customerId: string
sourceContainerBookingId: string
orderNo: string
sourceOrderNo: string
orderTypeCode: string
currentStatusCode: string
warning: string
@@ -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<typeof import('@/shared/api/http')>('@/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', {})
})
})
@@ -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,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')
})
})
@@ -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>
+10
View File
@@ -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
@@ -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(仓库:义乌仓;渠道:未配置渠道),请刷新后重试'],
@@ -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' },
@@ -1793,6 +1803,9 @@ function coLoadSelectionMembersLabel(rows: UnifiedOrderResource[]) {
const coLoadSelectionProblem = computed(() => {
if (selectedOrders.value.length < 2) return null
const allowedStatuses = new Set<UnifiedOrderMainStatus>(['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(() => {
<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 +2733,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 +2788,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 +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); }