合并 release/test 管理前端冲突
This commit is contained in:
@@ -15,7 +15,11 @@ test('production frontend starts and validates the candidate before switching ng
|
||||
})
|
||||
|
||||
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"/)
|
||||
|
||||
@@ -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(() =>')
|
||||
})
|
||||
|
||||
|
||||
@@ -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('当前输入仍保留')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
|
||||
export {
|
||||
adjustPublishedInboundFee,
|
||||
calculateInboundFee,
|
||||
createManualInboundQuote,
|
||||
confirmInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
createFeeActionIdempotencyKey,
|
||||
|
||||
@@ -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
|
||||
@@ -811,13 +813,27 @@ function hasRelabelActions(row: LastMileOrderListItem) {
|
||||
&& 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) },
|
||||
@@ -925,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 #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
|
||||
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
|
||||
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user