This commit is contained in:
2026-09-22 13:52:10 +08:00
32 changed files with 885 additions and 122 deletions
+2
View File
@@ -112,6 +112,7 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
@@ -170,6 +171,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'mojia-sku-sync', title: '一件代发 SKU 同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
],
},
{
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
test('production frontend starts and validates the candidate before switching nginx', () => {
const start = script.indexOf('compose run -d --no-deps --name "$candidate"')
const health = script.indexOf('docker exec "$candidate" wget')
const nginx = script.indexOf('nginx -t || ! nginx -s reload')
const stop = script.indexOf('docker stop --time 30 "$active_container"')
const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"')
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state)
})
test('production frontend supports both slots and restores nginx on failure', () => {
assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/)
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
assert.match(script, /00-blue-green-admin-web\.conf/)
assert.match(script, /00-blue-green-client-web\.conf/)
assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/)
assert.match(script, /cp -f "\$nginx_config" "\$backup"/)
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
assert.match(script, /docker rm -f "\$candidate"/)
assert.match(script, /gateway-active:firstleg-production-gateway/)
})
+3 -1
View File
@@ -60,7 +60,9 @@ describe('admin nginx deployment config', () => {
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
expect(jenkinsfile).toContain('git rev-parse origin/main')
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway:9000"')
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"')
expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"')
expect(productionCompose).toContain('name: firstleg-production-routing')
expect(productionCompose).toContain('name: firstleg-prod-infra-network')
})
+1
View File
@@ -104,6 +104,7 @@ describe('RolesView', () => {
it('restores saved permission ids after the dialog tree has mounted', () => {
expect(rolesSource).toContain('@opened="initializePermissionSelection"')
expect(rolesSource).toContain('treeRef.value?.setCheckedKeys([...form.permissionIds])')
expect(rolesSource).not.toContain('<code v-if="data.code">{{ data.code }}</code>')
expect(rolesSource).not.toContain('nextTick(() =>')
})
+1 -2
View File
@@ -266,7 +266,7 @@ onMounted(loadRoles)
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
</FormSection>
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions density-form-section"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span><code v-if="data.code">{{ data.code }}</code></div></template></el-tree></FormSection>
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions density-form-section"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span></div></template></el-tree></FormSection>
</div>
</el-form>
</AppDialog>
@@ -279,7 +279,6 @@ onMounted(loadRoles)
.role-editor-layout > .role-editor-permissions.density-form-section { min-width: 0; padding: 0 0 0 var(--admin-space-5); border-left: 1px solid var(--cc-border); }
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
.permission-node span { min-width: 0; }
.permission-node code { max-width: 58%; white-space: normal; text-align: right; }
.permission-node.is-navigation-group span { font-weight: 650; }
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
@@ -381,4 +381,5 @@ describe('InboundFeePanel', () => {
expect(document.body.textContent).toContain('系统已刷新最新快照')
expect(document.body.textContent).toContain('当前输入仍保留')
})
})
+29 -6
View File
@@ -47,6 +47,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const adjustmentError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const snapshot = ref<InboundFeeSnapshot | null>(null)
const hasLoadedOnce = ref(false)
const adjustOpen = ref(false)
const trialOpen = ref(false)
const trialLoading = ref(false)
@@ -183,12 +184,25 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
} catch (error) {
if (sequence !== loadSequence) return
snapshot.value = null
emit('loaded', null)
if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true
else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
const hadSnapshot = Boolean(snapshot.value)
if (error instanceof ApiResponseError && error.code === 'A0404') {
notFound.value = !hadSnapshot
if (!hadSnapshot) {
snapshot.value = null
emit('loaded', null)
}
} else {
loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
if (!hadSnapshot) {
snapshot.value = null
emit('loaded', null)
}
}
} finally {
if (sequence === loadSequence) loading.value = false
if (sequence === loadSequence) {
loading.value = false
hasLoadedOnce.value = true
}
}
}
@@ -353,6 +367,7 @@ watch(
() => {
adjustOpen.value = false
snapshot.value = null
hasLoadedOnce.value = false
void loadFee()
},
{ immediate: true },
@@ -435,11 +450,18 @@ defineExpose({ reload: loadFee })
<p>需要权限:firstleg:fba-pricing:view</p>
</el-alert>
<div v-else-if="loading && !snapshot" class="fee-loading" aria-live="polite">
<div v-else-if="loading && !snapshot && !hasLoadedOnce" class="fee-loading" aria-live="polite">
<el-skeleton :rows="6" animated />
<span class="sr-only">正在加载费用快照</span>
</div>
<el-alert v-if="loadError && snapshot" class="fee-refresh-error" type="error" :closable="false" show-icon role="status">
<template #title>最新费用快照暂时无法刷新,当前仍显示上一版快照
</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
</el-alert>
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title>费用快照加载失败</template>
<p>{{ loadError.message }}</p>
@@ -597,6 +619,7 @@ defineExpose({ reload: loadFee })
.fee-heading-actions .el-button { margin-left: 0; }
.fee-refresh-button .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; }
.fee-loading { padding: 20px 0; }
.fee-refresh-error { margin-bottom: 10px; }
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
+12
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
adjustPublishedInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
@@ -69,6 +70,17 @@ describe('inbound fee API', () => {
})
})
it('creates a manual FBA quote through the dedicated fallback endpoint', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, rateCode: 'MANUAL_QUOTE' } })
const payload = { selectedWeightKg: 62.5, selectedVolumeCbm: 0.384, unitPrice: 12.2, reason: 'FBA价格未维护' }
await expect(createManualInboundQuote('10001', '88', payload, 'fee-manual-1'))
.resolves.toMatchObject({ rateCode: 'MANUAL_QUOTE' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/manual-quote', payload, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-manual-1' },
})
})
it('preserves backend conflict metadata for stale-version recovery', async () => {
vi.mocked(http.put).mockResolvedValue({
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
+11 -1
View File
@@ -23,6 +23,16 @@ export async function calculateInboundFee(tenantId: string, inboundId: string, p
return unwrapApiResponse(response)
}
export async function createManualInboundQuote(tenantId: string, inboundId: string, payload: {
selectedWeightKg: number; selectedVolumeCbm: number; unitPrice: number; reason: string
}, idempotencyKey: string) {
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/manual-quote`, payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
async function transitionInboundFee(
tenantId: string,
inboundId: string,
@@ -63,7 +73,7 @@ export function createFeeAdjustmentIdempotencyKey() {
return createFeeActionIdempotencyKey('adjust')
}
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') {
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'manual' | 'confirm' | 'send') {
const uuid = globalThis.crypto?.randomUUID?.()
if (uuid) return `fee-${action}-${uuid}`
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
+3 -3
View File
@@ -13,9 +13,9 @@ export interface InboundFeeSnapshot {
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
carrierRateId: string
carrierRateId: string | null
rateCode: string
carrierRateVersion: number
carrierRateVersion: number | null
customerId: string
customerName: string
originWarehouseId: string
@@ -45,7 +45,7 @@ export interface InboundFeeSnapshot {
additionalAmount: number
totalAmount: number
currency: string
rateEffectiveFrom: string
rateEffectiveFrom: string | null
rateEffectiveTo: string | null
calculatedAt: string
confirmedAt: string | null
+1
View File
@@ -8,6 +8,7 @@ export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export {
adjustPublishedInboundFee,
calculateInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
+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/` |
@@ -53,6 +53,7 @@ export interface InboundPlanDetailDto {
customerName?: string | null
orderType?: string | null
containerNo?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
status?: string | null
@@ -67,7 +68,7 @@ export interface InboundPlanDetailDto {
updatedAt?: string | null
remark?: string | null
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; grossWeightKg?: number | null; netWeightKg?: number | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseLocationCode?: string | null; warehouseLocationType?: string | null; warehouseLocationStatus?: string | null; sharedOverflow?: boolean | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number; version?: number }>
allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
}
@@ -79,3 +80,27 @@ export async function listInboundPlans(query: InboundPlanQuery) {
export async function getInboundPlan(planNo: string, customerId: string) {
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
}
export interface PlatformInventoryMoveRequest {
overseasWarehouseId: string
sourcePalletCode: string
targetLocationCode: string
expectedPalletVersion: number
reason: string
idempotencyKey: string
}
export interface PlatformInventoryMoveResult {
commandId: string
status: string
canMove?: boolean
blockReason?: string | null
sourceLocationCode?: string | null
targetLocationCode?: string | null
sharedOverflow?: boolean | null
}
export async function moveInboundPallet(payload: PlatformInventoryMoveRequest) {
const response = await overseasHttp.post<ApiEnvelope<PlatformInventoryMoveResult>, ApiEnvelope<PlatformInventoryMoveResult>>('/platform/v1/last-leg/inventory-moves', payload, { headers: { 'Idempotency-Key': payload.idempotencyKey } })
return unwrapApiResponse(response)
}
@@ -0,0 +1,64 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export type OverseasLocationType = 'VIRTUAL' | 'NORMAL' | 'SHARED_OVERFLOW'
export type OverseasLocationStatus = 'ACTIVE' | 'IDLE' | 'OCCUPIED' | 'INACTIVE' | 'DISABLED'
export interface OverseasLocationDto {
id: string
warehouseId: string
warehouseCode?: string | null
warehouseName?: string | null
zoneCode: string
zoneName?: string | null
locationCode: string
locationName?: string | null
locationType: OverseasLocationType
sharedOverflow: boolean
status: OverseasLocationStatus
maxLoadKg?: number | string | null
occupiedPalletCount?: number | null
version: number
remark?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface SaveOverseasLocationPayload {
zoneCode: string
locationCode: string
locationType: 'NORMAL'
maxLoadKg: number
status: 'ACTIVE' | 'INACTIVE'
remark?: string | null
}
export interface OverseasLocationQuery { page?: number; size?: number; zoneCode?: string; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' }
function base(warehouseId: string) { return `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations` }
export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function createOverseasLocation(warehouseId: string, payload: SaveOverseasLocationPayload) {
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(base(warehouseId), payload)
return unwrapApiResponse(response)
}
export async function updateOverseasLocation(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload)
return unwrapApiResponse(response)
}
export async function changeOverseasLocationStatus(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload)
return unwrapApiResponse(response)
}
export async function deleteOverseasLocation(warehouseId: string, locationId: string, version: number) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, { params: { version } })
return unwrapApiResponse(response)
}
@@ -0,0 +1,104 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type MojiaSkuSyncStatus = 'PENDING' | 'SUCCESS' | 'FAILED'
export interface MojiaSkuSyncRecord {
id: string
skuCode: string
lengthCm: number
widthCm: number
heightCm: number
weightKg: number
imageFileId?: string | null
imageUrl?: string | null
measuredAt?: string | null
receivedAt?: string | null
syncStatus: MojiaSkuSyncStatus
syncFailureCode?: string | null
syncFailureReason?: string | null
lingxingSkuId?: string | null
revision: number
updatedAt?: string | null
}
export interface MojiaSkuSyncPage {
records: MojiaSkuSyncRecord[]
page: number
size: number
total: number
pages: number
}
export interface MojiaSkuSyncQuery {
page: number
size: number
skuCode?: string
syncStatus?: MojiaSkuSyncStatus
}
export interface MojiaSkuSyncConfig {
autoSyncEnabled: boolean
}
export interface LingxingWarehouseTestResult {
whCode: string
reachable: boolean
matched: boolean
maintained: boolean
productSku?: string | null
barcode?: string | null
length?: number | null
width?: number | null
height?: number | null
weight?: number | null
message: string
}
export interface LingxingConnectionTestResult {
skuCode: string
warehouses: LingxingWarehouseTestResult[]
}
const basePath = '/platform/v1/firstleg/mojia-sku-sync'
export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
const response = await http.get<ApiEnvelope<MojiaSkuSyncPage>, ApiEnvelope<MojiaSkuSyncPage>>(
basePath, { params: query },
)
return unwrapApiResponse(response)
}
export async function getMojiaSkuSyncConfig() {
const response = await http.get<ApiEnvelope<MojiaSkuSyncConfig>, ApiEnvelope<MojiaSkuSyncConfig>>(
`${basePath}/config`,
)
return unwrapApiResponse(response)
}
export async function testLingxingConnection(skuCode: string) {
const response = await http.post<ApiEnvelope<LingxingConnectionTestResult>, ApiEnvelope<LingxingConnectionTestResult>>(
`${basePath}/test-connection`, { skuCode },
)
return unwrapApiResponse(response)
}
export async function updateMojiaSkuSync(id: string, data: {
skuCode: string
lengthCm: number
widthCm: number
heightCm: number
weightKg: number
revision: number
}) {
const response = await http.put<ApiEnvelope<MojiaSkuSyncRecord>, ApiEnvelope<MojiaSkuSyncRecord>>(
`${basePath}/${encodeURIComponent(id)}`, data,
)
return unwrapApiResponse(response)
}
export async function retryMojiaSkuSync(id: string, revision: number) {
const response = await http.post<ApiEnvelope<MojiaSkuSyncRecord>, ApiEnvelope<MojiaSkuSyncRecord>>(
`${basePath}/${encodeURIComponent(id)}/retry`, { revision },
)
return unwrapApiResponse(response)
}
@@ -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(3)
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: ['状态', '预约状态'")
})
})
@@ -1,12 +1,15 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
@@ -14,6 +17,7 @@ const route = useRoute(); const router = useRouter()
const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
@@ -28,31 +32,51 @@ 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: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'palletOwner', prop: 'palletOwner', label: '托盘所属计划', minWidth: 190 },
{ key: 'packageCount', prop: 'packageCount', label: '对应箱数', width: 110, align: 'right' },
{ key: 'location', label: '当前库位', minWidth: 150, slotName: 'location' },
{ key: 'locationType', label: '库位类型', width: 120, prop: 'locationType' },
{ key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus' },
]
const palletRows = computed(() => (detail.value?.allocations || []).map((allocation, index) => {
const line = detail.value?.lines.find(item => item.id === allocation.lineId)
const pallet = detail.value?.pallets.find(item => item.id === allocation.palletId || item.warehousePalletId === allocation.palletId)
const locationCode = pallet?.warehouseLocationCode || null
const locationType = pallet?.sharedOverflow ? 'SHARED_OVERFLOW' : (pallet?.warehouseLocationType || null)
return {
id: `${allocation.lineId}-${allocation.palletId}-${index}`,
inboundCode: text(line?.inboundCode),
palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? 0, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
}
}))
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
async function openMove(row: typeof palletRows.value[number]) {
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveOpen.value = true
try { const result = await listOverseasLocations(String((detail.value as InboundPlanDetailDto & { overseasWarehouseId?: string }).overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') } catch { locationOptions.value = []; ElMessage.error('可用库位加载失败,请重试') }
}
async function submitMove() {
if (!moveRow.value || !targetLocationCode.value || !moveReason.value.trim()) { ElMessage.warning('请选择目标库位并填写操作原因'); return }
moveSaving.value = true
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
}
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push({ name: 'overseas-inbound-orders' }) }
onMounted(load)
@@ -60,18 +84,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>
</OverseasDetailShell></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 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</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="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></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><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ` · ${item.zoneCode}`}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" /></el-form-item></el-form></AppDialog></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><div class="inventory-table-wrap"><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></div></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>
@@ -176,9 +176,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it.each([
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '入库计划'], false],
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true],
[['inbound-order:create'], ['查看', '生成入库计划'], false],
[['last-mile-order:edit'], ['查看', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -213,9 +213,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it.each([
['PENDING_ASSIGNMENT', true],
['INBOUND_COMPLETED', false],
])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => {
['PENDING_ASSIGNMENT', false],
['INBOUND_COMPLETED', true],
])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false,
@@ -223,8 +223,38 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await mountView()
const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('暂扣订单'))!
expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled)
.find(item => item.text().includes('暂扣订单'))
expect(Boolean(holdButton)).toBe(visible)
if (holdButton) expect((holdButton.element as HTMLButtonElement).disabled).toBe(false)
})
it('keeps release hold available for an already detained order', async () => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const releaseButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('解除暂扣'))
expect(releaseButton).toBeTruthy()
expect((releaseButton!.element as HTMLButtonElement).disabled).toBe(false)
})
it('removes generation and hold actions after an inbound plan has been submitted', async () => {
canAccess.mockReturnValue(true)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.text()).toContain('查看')
expect(actions.text()).not.toContain('生成入库计划')
expect(actions.text()).not.toContain('暂扣订单')
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
@@ -342,7 +372,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(payload).not.toHaveProperty('plannedWarehouseName')
})
it('allows a historically held order to generate its inbound plan', async () => {
it('blocks a held order from generating its inbound plan', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({
detained: true,
@@ -352,10 +382,10 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId)
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
expect(confirm).toBeUndefined()
})
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
@@ -445,6 +445,7 @@ function isRejectedReturn(source: object) {
return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED'
}
function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) }
function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) }
async function loadTransferWarehouseOptions() {
transferWarehouseLoading.value = true
transferWarehouseError.value = null
@@ -500,6 +501,7 @@ 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 (hasInboundPlan(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
@@ -806,13 +808,32 @@ async function reviewReturn(source: object) {
}
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))
}
const inboundPlanCreatedStatuses = new Set([
'INBOUND_PLANNED',
'PENDING_RECEIVING',
'WAIT_PDA_RECEIVING',
'INBOUND_MEASURING',
'WAIT_CUSTOMER_CONFIRMATION',
'PARTIALLY_RECEIVED',
'INBOUND_COMPLETED',
])
function hasInboundPlan(row: LastMileOrderListItem) {
return inboundPlanCreatedStatuses.has(row.currentStatusCode)
|| Boolean(row.inboundOrderNo && row.inboundOrderNo !== '—')
}
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
const inboundPlanCreated = hasInboundPlan(row)
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' },
{ key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) },
@@ -920,7 +941,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 || 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 #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() || hasSelectedInboundPlan() || 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>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Save } 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = useAccessPreviewStore(); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const rows = ref<OverseasLocationDto[]>([])
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<{ message: string } | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' }); const editorOpen = ref(false); const saving = ref(false); const editing = ref<OverseasLocationDto | null>(null)
const form = reactive({ warehouseId: '', zoneCode: '', zoneName: '', locationCode: '', locationName: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 260, options: warehouses.value.map(item => ({ value: String(item.id), label: `${item.warehouseName}(${item.warehouseCode})` })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
{ key: 'zoneCode', type: 'input', label: '库区编码', width: 180 }, { key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
{ key: 'locationType', type: 'select', label: '类型', width: 150, options: [{ label: '普通库位', value: 'NORMAL' }, { label: 'A0共享溢出位', value: 'SHARED_OVERFLOW' }, { label: '虚拟收货位', value: 'VIRTUAL' }] },
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'DISABLED' }] },
])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
const typeLabels: Record<OverseasLocationType, string> = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record<OverseasLocationStatus, string> = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
const selectedWarehouse = computed(() => warehouses.value.find(item => String(item.id) === String(form.warehouseId)))
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.status === 'DISABLED' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { ElMessage.error(getApiErrorDetails(error, fallback).message) }
async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const warehouseId = String(filters.value.warehouseId || ''); if (!warehouseId) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const result = await listOverseasLocations(warehouseId, { page: page.value, size: pageSize.value, zoneCode: String(filters.value.zoneCode || '').trim() || undefined, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), zoneCode: '', zoneName: '', locationCode: '', locationName: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() { resetForm(); editorOpen.value = true }
function openEdit(row: OverseasLocationDto) { if (!canEdit(row)) return; Object.assign(form, { warehouseId: row.warehouseId, zoneCode: row.zoneCode, zoneName: row.zoneName || '', locationCode: row.locationCode, locationName: row.locationName || '', locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'DISABLED' ? 'INACTIVE' : 'ACTIVE', remark: row.remark || '' }); editing.value = row; editorOpen.value = true }
async function save() { if (!form.warehouseId || !form.zoneCode.trim() || !form.locationCode.trim() || form.maxWeightKg == null) { ElMessage.warning('请填写海外仓、库区编码、库位编码和最大承重'); return }; saving.value = true; try { const payload = { zoneCode: form.zoneCode.trim(), locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }; if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version }); else await createOverseasLocation(form.warehouseId, payload); editorOpen.value = false; await load() } catch (error) { actionError(error, '保存库位失败') } finally { saving.value = false } }
async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'DISABLED' ? 'ACTIVE' : 'INACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { zoneCode: row.zoneCode, locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; try { await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning' }); await deleteOverseasLocation(row.warehouseId, row.id, row.version); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') } }
onMounted(() => { void loadWarehouses() })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><template #toolbar><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template><template #state><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /><el-alert v-else-if="!filters.warehouseId" title="请选择海外仓后查询库位" type="info" :closable="false" /></template><section v-if="!loadError && filters.warehouseId" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'DISABLED' ? 'success' : 'danger'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'DISABLED' ? '启用' : '停用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || selectedWarehouse?.warehouseName || '—' }}</template><template #zone="{ row }">{{ (row as OverseasLocationDto).zoneName || '—' }}({{ (row as OverseasLocationDto).zoneCode }})</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓"><el-select v-model="form.warehouseId" disabled><el-option v-if="selectedWarehouse" :value="selectedWarehouse.id" :label="`${selectedWarehouse.warehouseName}(${selectedWarehouse.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码"><el-input v-model="form.locationCode" maxlength="50" /></el-form-item><el-form-item label="库区编码"><el-input v-model="form.zoneCode" maxlength="50" /></el-form-item><el-form-item label="库区名称"><el-input v-model="form.zoneName" maxlength="100" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" /></el-form-item><el-form-item label="最大承重(kg)"><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template>
<style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Cable, Edit3, RefreshCw, RotateCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, testLingxingConnection, updateMojiaSkuSync,
type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
} from '../api/mojiaSkuSync'
const permission = {
list: 'platform:mojia-sku-sync:list',
edit: 'platform:mojia-sku-sync:edit',
retry: 'platform:mojia-sku-sync:retry',
} as const
const access = useAccessPreviewStore()
const canEdit = computed(() => access.can(permission.edit))
const canRetry = computed(() => access.can(permission.retry))
const rows = ref<MojiaSkuSyncRecord[]>([])
const total = ref(0)
const loading = ref(false)
const autoSyncEnabled = ref(false)
const error = ref('')
const actionId = ref('')
const testingConnection = ref(false)
const testOpen = ref(false)
const testResult = ref<LingxingConnectionTestResult | null>(null)
const page = ref(1)
const size = ref(20)
const form = reactive({ skuCode: '', syncStatus: '' as '' | MojiaSkuSyncStatus })
const editOpen = ref(false)
const editing = ref<MojiaSkuSyncRecord | null>(null)
const editForm = reactive({ skuCode: '', lengthCm: 0, widthCm: 0, heightCm: 0, weightKg: 0, revision: 0 })
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 180, showOverflowTooltip: true },
{ key: 'image', label: '测量图片', width: 110, slotName: 'image' },
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', width: 170, slotName: 'dimensions' },
{ key: 'weightKg', prop: 'weightKg', label: '重量(kg)', width: 110 },
{ key: 'syncStatus', label: '同步状态', width: 120, slotName: 'syncStatus' },
{ key: 'match', label: '领星 SKU', minWidth: 150, slotName: 'match' },
{ key: 'measuredAt', label: '测量时间', width: 170, slotName: 'measuredAt' },
{ key: 'failure', label: '失败原因', minWidth: 220, slotName: 'failure' },
]
const statusLabel: Record<MojiaSkuSyncStatus, string> = {
PENDING: '待同步', SUCCESS: '同步成功', FAILED: '同步失败',
}
const statusType: Record<MojiaSkuSyncStatus, 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning', SUCCESS: 'success', FAILED: 'danger',
}
async function load() {
loading.value = true
error.value = ''
try {
const result = await listMojiaSkuSync({ page: page.value, size: size.value, skuCode: form.skuCode || undefined, syncStatus: form.syncStatus || undefined })
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = getApiErrorMessage(cause, 'SKU同步记录加载失败')
} finally { loading.value = false }
}
async function loadConfig() {
try {
autoSyncEnabled.value = (await getMojiaSkuSyncConfig()).autoSyncEnabled
} catch (cause) {
error.value = getApiErrorMessage(cause, '自动同步配置加载失败')
}
}
function search() { page.value = 1; void load() }
function reset() { form.skuCode = ''; form.syncStatus = ''; search() }
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
function asRecord(row: unknown) { return row as MojiaSkuSyncRecord }
function syncLabel(row: unknown) { return statusLabel[asRecord(row).syncStatus] }
function syncTagType(row: unknown) { return statusType[asRecord(row).syncStatus] }
async function testConnection() {
const skuCode = form.skuCode.trim()
if (!skuCode) { ElMessage.warning('请先输入要查询的 SKU'); return }
testingConnection.value = true
try {
testResult.value = await testLingxingConnection(skuCode)
testOpen.value = true
} catch (cause) { ElMessage.error(getApiErrorMessage(cause, '领星连接测试失败')) }
finally { testingConnection.value = false }
}
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
Object.assign(editForm, { skuCode: row.skuCode, lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
editOpen.value = true
}
async function saveEdit() {
const row = editing.value
if (!row || !editForm.skuCode.trim() || [editForm.lengthCm, editForm.widthCm, editForm.heightCm, editForm.weightKg].some((value) => value <= 0)) {
ElMessage.warning('请填写有效的 SKU、尺寸和重量')
return
}
actionId.value = row.id
try {
await updateMojiaSkuSync(row.id, { ...editForm, skuCode: editForm.skuCode.trim() })
editOpen.value = false
ElMessage.success('已保存人工修正版,记录将重新进入同步队列')
await load()
} catch (cause) { ElMessage.error(getApiErrorMessage(cause, '保存修正版失败')) }
finally { actionId.value = '' }
}
async function retry(row: MojiaSkuSyncRecord) {
try {
await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
} catch { return }
actionId.value = row.id
try { await retryMojiaSkuSync(row.id, row.revision); ElMessage.success('已进入待同步队列'); await load() }
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '重新推送失败')) }
finally { actionId.value = '' }
}
onMounted(() => { void Promise.all([loadConfig(), load()]) })
</script>
<template>
<AdminPage class="admin-density mojia-sku-sync-page">
<ListPageLayout aria-label="一件代发 SKU 同步">
<section class="surface-panel table-panel" :aria-busy="loading">
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
<el-alert
:type="autoSyncEnabled ? 'warning' : 'info'"
:title="autoSyncEnabled ? '领星自动同步已开启' : '领星自动同步已关闭,仅支持人工推送'"
description="开关由 Nacos 配置 firstleg.lingxing.auto-sync-enabled 控制。"
show-icon
:closable="false"
/>
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无墨家 SKU 测量记录">
<template #toolbar-start>
<div class="sync-filters">
<el-input v-model.trim="form.skuCode" clearable placeholder="搜索 SKU" @keyup.enter="search" />
<el-select v-model="form.syncStatus" clearable placeholder="同步状态" style="width: 140px">
<el-option v-for="(label, value) in statusLabel" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button :loading="testingConnection" @click="testConnection"><Cable :size="16" />测试连接</el-button>
</div>
</template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></template>
<template #actions="{ row }">
<div class="density-actions">
<el-button v-if="canEdit" class="density-action" @click="openEdit(asRecord(row))"><Edit3 :size="15" />编辑</el-button>
<el-button v-if="canRetry && row.syncStatus !== 'SUCCESS'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))"><RotateCw :size="15" />人工推送</el-button>
</div>
</template>
<template #image="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="sync-image" preview-teleported /><span v-else>—</span></template>
<template #dimensions="{ row }">{{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}</template>
<template #syncStatus="{ row }"><el-tag size="small" :type="syncTagType(row)">{{ syncLabel(row) }}</el-tag></template>
<template #match="{ row }">{{ row.lingxingSkuId || '未匹配' }}</template>
<template #measuredAt="{ row }">{{ formatTime(row.measuredAt) }}</template>
<template #failure="{ row }">{{ row.syncFailureReason || '—' }}</template>
</DataTable>
<div class="sync-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" background layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" /></div>
</section>
</ListPageLayout>
<AppDialog v-model="editOpen" class="admin-density-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit">
<el-form label-position="top" class="sync-edit-form">
<el-form-item label="SKU"><el-input v-model.trim="editForm.skuCode" /></el-form-item>
<div class="sync-edit-grid">
<el-form-item label="长(cm)"><el-input-number v-model="editForm.lengthCm" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="宽(cm)"><el-input-number v-model="editForm.widthCm" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="高(cm)"><el-input-number v-model="editForm.heightCm" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="重量(kg)"><el-input-number v-model="editForm.weightKg" :min="0.001" :controls="false" /></el-form-item>
</div>
<el-alert type="info" :closable="false" title="原始墨家测量记录不可变,本次保存会生成新的人工修正版并进入同步队列。" />
</el-form>
</AppDialog>
<AppDialog v-model="testOpen" class="admin-density-dialog" title="领星连接测试" width="min(720px, calc(100vw - 28px))" :show-cancel="false" confirm-text="关闭" @confirm="testOpen = false">
<div v-if="testResult" class="connection-result">
<div class="connection-result__sku">查询 SKU:<strong>{{ testResult.skuCode }}</strong></div>
<el-table :data="testResult.warehouses" border>
<el-table-column prop="whCode" label="仓库" width="90" />
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'danger'">{{ row.matched ? '已命中' : '未命中' }}</el-tag></template></el-table-column>
<el-table-column label="维护状态" width="110"><template #default="{ row }">{{ row.matched ? (row.maintained ? '已维护' : '待维护') : '—' }}</template></el-table-column>
<el-table-column label="领星数据" min-width="220"><template #default="{ row }">{{ row.matched ? `${row.length ?? 0} × ${row.width ?? 0} × ${row.height ?? 0} cm / ${row.weight ?? 0} kg` : row.message }}</template></el-table-column>
</el-table>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.sync-filters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2); }
.sync-filters .el-input { width: 220px; }
.density-actions { display: flex; gap: var(--admin-space-2); }
.sync-image { width: 52px; height: 40px; border-radius: 4px; background: var(--cc-surface-muted); }
.sync-pagination { display: flex; justify-content: flex-end; padding: var(--admin-space-4); }
.sync-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.sync-edit-grid :deep(.el-input-number) { width: 100%; }
.connection-result { display: grid; gap: var(--admin-space-4); }
.connection-result__sku { color: var(--cc-muted); }
.connection-result__sku strong { color: var(--cc-text); }
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
</style>
@@ -37,6 +37,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
['PalletsView.vue', 'overseas-pallets-query'],
['CartonsView.vue', 'overseas-cartons-query'],
['ProductsView.vue', 'overseas-products-query'],
['LocationsView.vue', 'overseas-locations-query'],
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
@@ -127,6 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDrawer')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"')
else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库')
else expect(value).not.toContain('AppDialog')
}
})
@@ -342,10 +344,17 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('<RowActions :actions="rowActions(row as DispatchListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
expect(value).toContain("if (command === 'detail') openDetail(row)")
} else if (fileName === 'LastMileOrdersView.vue') {
expect(value).toContain('<RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 500 }")
expect(value).toContain("if (command === 'detail') openDetail(row)")
} else {
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
}
}
const locations = source('LocationsView.vue')
expect(locations).toContain('海外仓库位管理')
expect(locations).toContain('库位编码')
})
it('connects the dispatch prototype detail to the real overseas API', () => {
@@ -387,12 +396,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'grossWeight', 'netWeight', 'volume',
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
@@ -49,4 +49,7 @@ export const overseasWarehousePermissions = {
productList: 'platform:overseas-warehouse:product:list',
productCreate: 'platform:overseas-warehouse:product:create',
productEdit: 'platform:overseas-warehouse:product:edit',
mojiaSkuSyncList: 'platform:mojia-sku-sync:list',
mojiaSkuSyncEdit: 'platform:mojia-sku-sync:edit',
mojiaSkuSyncRetry: 'platform:mojia-sku-sync:retry',
} as const
+12
View File
@@ -130,4 +130,16 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/PalletDetailView.vue'),
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
},
{
path: 'overseas-warehouse/locations',
name: 'overseas-locations',
component: () => import('./pages/LocationsView.vue'),
meta: { title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList },
},
{
path: 'general/mojia-sku-sync',
name: 'mojia-sku-sync',
component: () => import('./pages/MojiaSkuSyncView.vue'),
meta: { title: '一件代发 SKU 同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList },
},
] satisfies RouteRecordRaw[]
@@ -24,4 +24,12 @@ describe('PDA release workbench', () => {
expect(page).toContain('row.current')
expect(page).toContain('ElMessageBox.confirm')
})
it('uses the standard dense list workspace without repeating the route title', () => {
expect(page).toContain('class="surface-panel table-panel pda-release-panel"')
expect(page).toContain('<template #toolbar-start>')
expect(page).toContain('<template #toolbar-actions>')
expect(page).toContain('class="pda-release-actions density-actions"')
expect(page).not.toContain('<h1>PDA版本</h1>')
})
})
@@ -119,38 +119,32 @@ function download(row: PdaRelease) {
</script>
<template>
<AdminPage class="admin-density pda-releases-page" spacing="compact">
<AdminPage class="admin-density pda-releases-page">
<ListPageLayout aria-label="PDA版本">
<template #toolbar>
<div class="pda-release-toolbar">
<div>
<h1>PDA版本</h1>
<el-radio-group v-model="app" aria-label="选择PDA应用">
<section class="surface-panel table-panel pda-release-panel" :aria-busy="loading">
<el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" />
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
<template #toolbar-start>
<div class="pda-release-context">
<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">
<el-radio-button value="domestic">国内 PDA</el-radio-button>
<el-radio-button value="overseas">海外 PDA</el-radio-button>
</el-radio-group>
<dl class="pda-release-current">
<div><dt>当前 APK</dt><dd>{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</dd></div>
<div><dt>当前 WGT</dt><dd>{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</dd></div>
</dl>
</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>
<template #toolbar-actions>
<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>
</template>
<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 class="pda-release-actions density-actions">
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button>
<el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
<el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
</div>
</template>
<template #app="{ row }">{{ row.app === 'domestic' ? '国内' : '海外' }}</template>
@@ -160,7 +154,8 @@ function download(row: PdaRelease) {
<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>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
@@ -170,11 +165,11 @@ function download(row: PdaRelease) {
</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="APK版本码"><el-input-number v-model="form.versionCode" :min="1" :controls="false" placeholder="请输入APK版本码" /></el-form-item>
<el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" placeholder="请输入最低兼容APK版本码" /></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="更新说明"><el-input v-model="form.releaseNotes" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入更新说明" /></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>
@@ -182,11 +177,16 @@ function download(row: PdaRelease) {
</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-panel { min-width: 0; overflow: hidden; }
.pda-release-error { border: 0; border-bottom: 1px solid var(--el-color-danger-light-7); border-radius: 0; }
.pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); }
.pda-release-current { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); margin: 0; }
.pda-release-current > div { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2); }
.pda-release-current dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.pda-release-current dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body); font-variant-numeric: tabular-nums; }
.pda-release-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
.pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
.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%; }
@media (max-width: 900px) { .pda-release-context { align-items: flex-start; flex-direction: column; gap: var(--admin-space-3); } }
</style>
@@ -34,6 +34,11 @@ describe('UnifiedOrdersView contract', () => {
expect(sendFeeSource).toContain('adjustPublishedInboundFee')
expect(source).toContain('价格表参考单价')
expect(source).toContain('本次对客单价')
expect(openDialogSource).toContain("feeConfirmPriceMissing.value = true")
expect(sendFeeSource).toContain('createManualInboundQuote')
expect(source).toContain("feeConfirmPriceMissing ? '未命中'")
expect(source).toContain("feeConfirmPriceMissing ? '人工定价原因'")
expect(source).not.toContain('<dt>固定附加费</dt>')
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
})
@@ -66,6 +66,7 @@ import {
adjustPublishedInboundFee,
downloadWeightImage,
calculateInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
@@ -258,24 +259,27 @@ const feeConfirmWeight = ref<number | null>(null)
const feeConfirmVolume = ref<number | null>(null)
const feeConfirmReferenceLoading = ref(false)
const feeConfirmReference = ref<InboundFeeSnapshot | null>(null)
const feeConfirmPriceMissing = ref(false)
const feeConfirmUnitPrice = ref<number | null>(null)
const feeConfirmPriceReason = ref('')
type FeeConfirmAttempt = {
fingerprint: string
calculateKey: string
manualKey: string
adjustKey: string
confirmKey: string
sendKey: string
}
let feeConfirmAttempt: FeeConfirmAttempt | null = null
const feeConfirmPriceChanged = computed(() => {
if (feeConfirmPriceMissing.value) return true
const reference = Number(feeConfirmReference.value?.unitPrice)
const current = Number(feeConfirmUnitPrice.value)
return reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
})
const feeConfirmPriceUnit = computed(() => {
const snapshot = feeConfirmReference.value
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : '—'
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : 'CNY / 计费单位'
})
const feeConfirmEstimatedTotal = computed(() => {
const snapshot = feeConfirmReference.value
@@ -1669,6 +1673,7 @@ async function openFeeConfirmDialog() {
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
feeConfirmVolume.value = Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null
feeConfirmReference.value = null
feeConfirmPriceMissing.value = false
feeConfirmUnitPrice.value = null
feeConfirmPriceReason.value = ''
feeConfirmAttempt = null
@@ -1693,8 +1698,14 @@ async function openFeeConfirmDialog() {
feeConfirmReference.value = snapshot
feeConfirmUnitPrice.value = Number(snapshot.unitPrice)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '价格表参考单价加载失败,暂不能推送费用').message)
feeConfirmOpen.value = false
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
if (details.code === 'A0404') {
feeConfirmPriceMissing.value = true
ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
} else {
ElMessage.error(details.message)
feeConfirmOpen.value = false
}
} finally {
feeConfirmReferenceLoading.value = false
}
@@ -1715,6 +1726,7 @@ function currentFeeConfirmAttempt(
feeConfirmAttempt = {
fingerprint,
calculateKey: createFeeActionIdempotencyKey('calculate'),
manualKey: createFeeActionIdempotencyKey('manual'),
adjustKey: createFeeAdjustmentIdempotencyKey(),
confirmKey: createFeeActionIdempotencyKey('confirm'),
sendKey: createFeeActionIdempotencyKey('send'),
@@ -1747,14 +1759,25 @@ async function executeConfirmAndSendFee() {
try {
const tenantId = String(summary.tenantId)
const inboundId = String(summary.inboundId)
let snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg,
selectedVolumeCbm,
weightSource: 'ACTUAL',
volumeSource: 'ACTUAL',
}, attempt.calculateKey)
let snapshot: InboundFeeSnapshot
try {
snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg, selectedVolumeCbm, weightSource: 'ACTUAL', volumeSource: 'ACTUAL',
}, attempt.calculateKey)
} catch (error) {
if (getApiErrorDetails(error, '费用试算失败').code !== 'A0404') throw error
feeConfirmPriceMissing.value = true
if (feeConfirmPriceReason.value.trim().length < 2) {
ElMessage.warning('价格未命中,请填写至少 2 个字符的人工定价原因')
return
}
snapshot = await createManualInboundQuote(tenantId, inboundId, {
selectedWeightKg, selectedVolumeCbm, unitPrice: selectedUnitPrice,
reason: feeConfirmPriceReason.value.trim(),
}, attempt.manualKey)
}
const calculatedUnitPrice = Number(snapshot.unitPrice)
if (Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) {
if (snapshot.rateCode !== 'MANUAL_QUOTE' && Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) {
snapshot = await adjustPublishedInboundFee(tenantId, inboundId, {
expectedFeeVersion: snapshot.feeVersion,
chargeableQuantity: Number(snapshot.chargeableQuantity),
@@ -2311,13 +2334,12 @@ onBeforeUnmount(() => {
<el-form-item label="渠道"><el-input :model-value="detail?.summary.channelName || detail?.summary.channelCode || '—'" disabled /></el-form-item>
<el-form-item label="计费毛重"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" class="fee-confirm-number" placeholder="请输入计费毛重" /><span class="fee-confirm-unit">kg</span></div></el-form-item>
<el-form-item label="计费体积"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" class="fee-confirm-number" placeholder="请输入计费体积" /><span class="fee-confirm-unit">m³</span></div></el-form-item>
<el-form-item label="价格表参考单价"><div class="fee-confirm-number-wrap"><el-input-number :model-value="feeConfirmReference?.unitPrice == null ? undefined : Number(feeConfirmReference.unitPrice)" :precision="4" :controls="false" disabled class="fee-confirm-number" placeholder="正在读取参考单价" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item label="价格表参考单价"><div class="fee-confirm-number-wrap"><el-input-number :model-value="feeConfirmPriceMissing || feeConfirmReference?.unitPrice == null ? undefined : Number(feeConfirmReference.unitPrice)" :precision="4" :controls="false" disabled class="fee-confirm-number" :placeholder="feeConfirmPriceMissing ? '未命中' : '正在读取参考单价'" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item label="本次对客单价"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" class="fee-confirm-number" placeholder="请输入对客单价" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item v-if="feeConfirmPriceChanged" label="单价调整原因" class="fee-confirm-price-reason"><el-input v-model="feeConfirmPriceReason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请说明本次人工改单价的原因" /></el-form-item>
<el-form-item v-if="feeConfirmPriceChanged" :label="feeConfirmPriceMissing ? '人工定价原因' : '单价调整原因'" class="fee-confirm-price-reason"><el-input v-model="feeConfirmPriceReason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请说明本次人工定价的原因" /></el-form-item>
</el-form>
<dl v-if="feeConfirmReference" class="fee-confirm-price-summary">
<div><dt>参考计费量</dt><dd>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</dd></div>
<div><dt>固定附加费</dt><dd>{{ feeConfirmMoney(Number(feeConfirmReference.additionalAmount || 0), feeConfirmReference.currency) }}</dd></div>
<div><dt>按当前单价预计合计</dt><dd>{{ feeConfirmEstimatedTotal == null ? '—' : feeConfirmMoney(feeConfirmEstimatedTotal, feeConfirmReference.currency) }}</dd></div>
</dl>
<h4 class="fee-confirm-reference-title">参考数据</h4>