Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev
This commit is contained in:
@@ -267,6 +267,7 @@ export interface PlatformManagedUser {
|
||||
lastLoginAt: string | null
|
||||
protectedAccount: boolean
|
||||
overseasWarehouseId: number | string | null
|
||||
domesticWarehouseId?: number | string | null
|
||||
}
|
||||
|
||||
export interface PlatformManagedUserPage {
|
||||
@@ -309,6 +310,7 @@ export interface CreatePlatformManagedUserRequest {
|
||||
tenantId: number | string
|
||||
roleIds: Array<number | string>
|
||||
overseasWarehouseId: number | string | null
|
||||
domesticWarehouseId?: number | string | null
|
||||
password: string
|
||||
status: 'ACTIVE' | 'DISABLED'
|
||||
}
|
||||
@@ -320,6 +322,7 @@ export interface UpdatePlatformManagedUserRequest {
|
||||
tenantId: number | string
|
||||
roleIds: Array<number | string>
|
||||
overseasWarehouseId: number | string | null
|
||||
domesticWarehouseId?: number | string | null
|
||||
}
|
||||
|
||||
async function getData<T>(path: string): Promise<T> {
|
||||
|
||||
@@ -29,6 +29,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const auth = useAuthStore()
|
||||
@@ -37,6 +38,9 @@ const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
|
||||
const overseasWarehouses = ref<SharedReceivingOverseasWarehouseOption[]>([])
|
||||
const overseasWarehousesLoading = ref(false)
|
||||
const overseasWarehousesError = ref<ApiErrorDetails | null>(null)
|
||||
const domesticWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const domesticWarehousesLoading = ref(false)
|
||||
const domesticWarehousesError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -81,6 +85,7 @@ const emptyForm = () => ({
|
||||
tenantId: '0',
|
||||
roleIds: [] as string[],
|
||||
overseasWarehouseId: '',
|
||||
domesticWarehouseId: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
|
||||
@@ -111,6 +116,10 @@ const overseasPdaRoleIds = computed(() => new Set(
|
||||
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
|
||||
))
|
||||
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
|
||||
const domesticPdaRoleIds = computed(() => new Set(
|
||||
availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
|
||||
))
|
||||
const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
|
||||
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: editingUser.value ? [] : [
|
||||
@@ -127,6 +136,9 @@ const formRules = computed<FormRules>(() => ({
|
||||
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
||||
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
|
||||
],
|
||||
domesticWarehouseId: !hasDomesticPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
||||
{ required: true, message: '请选择国内作业仓', trigger: 'change' },
|
||||
],
|
||||
password: editingUser.value && !form.resetPassword ? [] : [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
|
||||
@@ -217,6 +229,7 @@ function openEdit(row: PlatformManagedUser) {
|
||||
tenantId: '0',
|
||||
roleIds: row.roleIds.map(String),
|
||||
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
|
||||
domesticWarehouseId: row.domesticWarehouseId == null ? '' : String(row.domesticWarehouseId),
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
status: row.status,
|
||||
@@ -243,6 +256,19 @@ async function loadOverseasWarehouses() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDomesticWarehouses() {
|
||||
domesticWarehousesLoading.value = true
|
||||
domesticWarehousesError.value = null
|
||||
try {
|
||||
domesticWarehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
|
||||
} catch (error) {
|
||||
domesticWarehouses.value = []
|
||||
domesticWarehousesError.value = getApiErrorDetails(error, '国内作业仓加载失败,请重试')
|
||||
} finally {
|
||||
domesticWarehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentUser(row: PlatformManagedUser | null) {
|
||||
if (!row) return false
|
||||
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
|
||||
@@ -266,6 +292,7 @@ async function saveUser() {
|
||||
tenantId: '0',
|
||||
roleIds: form.roleIds,
|
||||
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
||||
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
|
||||
password: form.password,
|
||||
status: form.status,
|
||||
})
|
||||
@@ -280,6 +307,7 @@ async function saveUser() {
|
||||
tenantId: '0',
|
||||
roleIds: form.roleIds,
|
||||
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
||||
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
|
||||
})
|
||||
}
|
||||
if (canChangeStatus.value && form.status !== row.status) {
|
||||
@@ -340,6 +368,14 @@ watch(hasOverseasPdaRole, (selected) => {
|
||||
}
|
||||
void loadOverseasWarehouses()
|
||||
})
|
||||
watch(hasDomesticPdaRole, (selected) => {
|
||||
if (!selected) {
|
||||
form.domesticWarehouseId = ''
|
||||
domesticWarehousesError.value = null
|
||||
return
|
||||
}
|
||||
void loadDomesticWarehouses()
|
||||
})
|
||||
onMounted(loadInitialData)
|
||||
</script>
|
||||
|
||||
@@ -435,6 +471,20 @@ onMounted(loadInitialData)
|
||||
</p>
|
||||
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasDomesticPdaRole" label="国内作业仓" prop="domesticWarehouseId" required>
|
||||
<el-select v-model="form.domesticWarehouseId" filterable
|
||||
:loading="domesticWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||
placeholder="请选择国内作业仓">
|
||||
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||
:value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
<p v-if="domesticWarehousesError" class="field-error">
|
||||
{{ domesticWarehousesError.message }}
|
||||
<el-button link type="primary" :loading="domesticWarehousesLoading" @click="loadDomesticWarehouses">重新加载</el-button>
|
||||
</p>
|
||||
<p v-else class="field-help">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingUser" label="最后登录">
|
||||
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
|
||||
</el-form-item>
|
||||
|
||||
@@ -146,11 +146,11 @@ describe('platform loading lists API', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
|
||||
const result = await confirmPreloadPlan('1900000000000000001', 3, '1900000000000000007', 'confirm-operation-1')
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
|
||||
{ expectedVersion: 3 },
|
||||
{ expectedVersion: 3, assigneeId: '1900000000000000007' },
|
||||
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
|
||||
@@ -186,6 +186,11 @@ export interface PreloadConfirmationResponse {
|
||||
readiness: PreloadReadiness
|
||||
}
|
||||
|
||||
export interface DomesticPdaOperator {
|
||||
userId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
type RawId = string | number | null | undefined
|
||||
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: string | number
|
||||
@@ -359,10 +364,17 @@ export async function updateLoadingItemRemarks(planId: string, itemId: string, r
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
|
||||
export async function listDomesticPdaOperators(warehouseId: string, keyword?: string): Promise<DomesticPdaOperator[]> {
|
||||
const response = await http.get<ApiEnvelope<DomesticPdaOperator[]>, ApiEnvelope<DomesticPdaOperator[]>>(
|
||||
'/platform/v1/iam/users/domestic-pda-operators', { params: { warehouseId, keyword } },
|
||||
)
|
||||
return unwrapApiResponse(response).map((operator) => ({ ...operator, userId: requiredId(operator.userId) }))
|
||||
}
|
||||
|
||||
export async function confirmPreloadPlan(planId: string, expectedVersion: number, assigneeId: string, operationId: string) {
|
||||
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
|
||||
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
|
||||
{ expectedVersion },
|
||||
{ expectedVersion, assigneeId },
|
||||
{ headers: { 'Idempotency-Key': operationId } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowLeft, Check, ChevronRight, Pencil } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -18,10 +18,11 @@ const props = defineProps<{
|
||||
detail: LoadingDetail | null
|
||||
error: ApiErrorDetails | null
|
||||
canEdit: boolean
|
||||
canPushPda?: boolean
|
||||
pdaAssigneeSelected?: boolean
|
||||
dense?: boolean
|
||||
embedded?: boolean
|
||||
canEditTransport?: boolean
|
||||
transportNote?: string
|
||||
confirmSaving?: boolean
|
||||
}>()
|
||||
|
||||
@@ -101,6 +102,13 @@ const visibleHistories = computed(() => {
|
||||
return true
|
||||
})
|
||||
})
|
||||
const historyExpanded = ref(props.mode !== 'preload')
|
||||
let lastHistoryPlanId: string | number | undefined
|
||||
watch(() => props.detail?.summary.planId, (id) => {
|
||||
if (id == null) return
|
||||
if (lastHistoryPlanId !== undefined && id !== lastHistoryPlanId) historyExpanded.value = props.mode !== 'preload'
|
||||
lastHistoryPlanId = id
|
||||
})
|
||||
const blockingReasonLabels: Record<string, string> = {
|
||||
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
|
||||
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
|
||||
@@ -156,13 +164,13 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div v-if="!embedded" class="detail-page-header__identity">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
|
||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
|
||||
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
<el-button v-if="canPushPda" type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected" @click="emit('confirmPreload')"><Check :size="16" />确认预装并推送 PDA</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
@@ -192,7 +200,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<strong>暂不能确认预装:</strong>
|
||||
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
|
||||
</div>
|
||||
<div v-if="preloadSummary" class="readiness-note transport-note" role="status"><strong>真实集装箱号:</strong><span>{{ transportNote }}</span></div>
|
||||
|
||||
<section class="detail-section">
|
||||
<DataTable
|
||||
@@ -204,7 +211,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
empty-text="暂无装柜货物明细"
|
||||
aria-label="装柜货物明细"
|
||||
>
|
||||
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
|
||||
<template #toolbar-start><div class="detail-section__heading"><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><slot v-if="mode === 'preload'" name="pda-operator" /></div></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button :class="{ 'density-action': mode === 'preload' || dense }" size="small" :type="mode === 'preload' ? undefined : 'primary'" :plain="mode !== 'preload'" :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的备注`" @click="emit('editRemarks', row as LoadingItem)"><Pencil v-if="mode !== 'preload'" :size="14" />编辑备注</el-button>
|
||||
</template>
|
||||
@@ -222,9 +229,9 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<section class="history-section" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
|
||||
<el-table :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
|
||||
<section class="history-section" :class="{ 'is-expanded': historyExpanded }" aria-label="操作历史">
|
||||
<header><button type="button" class="history-section__toggle" :aria-expanded="historyExpanded" aria-controls="loading-history-table" @click="historyExpanded = !historyExpanded"><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span><ChevronRight :size="16" class="history-section__arrow" :class="{ 'is-expanded': historyExpanded }" aria-hidden="true" /></button></header>
|
||||
<el-table v-if="historyExpanded" id="loading-history-table" :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
|
||||
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
|
||||
<el-table-column label="事件" width="140"><template #default="{ row }">{{ actionLabel(row.actionType) }}</template></el-table-column>
|
||||
<el-table-column label="状态变化" min-width="200"><template #default="{ row }"><span v-if="row.fromStatus || row.toStatus">{{ loadingListStatusLabel(row.fromStatus) }} → {{ loadingListStatusLabel(row.toStatus) }}</span><span v-else>—</span></template></el-table-column>
|
||||
@@ -262,16 +269,21 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.readiness-note strong { flex: 0 0 auto; color: var(--el-color-warning-dark-2); }
|
||||
.readiness-note span { min-width: 0; color: var(--cc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-section, .history-section { min-width: 0; overflow: hidden; padding: var(--admin-space-6, 16px) 0 0; border-top: 1px solid var(--cc-border); }
|
||||
.detail-section__heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.detail-section__heading h2 { margin: 0; font-size: 15px; }
|
||||
.detail-section__heading h2 span { margin-left: var(--admin-space-2, 6px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }
|
||||
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); line-height: 1.35; }
|
||||
.detail-section :deep(.el-table) { width: 100%; }
|
||||
.detail-section :deep(.el-table th.el-table__cell) { padding: 7px 0; }
|
||||
.detail-section :deep(.el-table td.el-table__cell) { padding: var(--admin-space-2, 6px) 0; }
|
||||
.detail-section :deep(.identifier-column .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
|
||||
.remark-cell { min-width: 0; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.history-section header { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); margin-bottom: 18px; }
|
||||
.history-section header { margin: 0; }
|
||||
.history-section.is-expanded header { margin-bottom: 18px; }
|
||||
.history-section__toggle { display: inline-flex; align-items: center; gap: var(--admin-space-4, 10px); padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; }
|
||||
.history-section__toggle:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; border-radius: 2px; }
|
||||
.history-section__arrow { color: var(--cc-muted); transition: transform 0.2s ease; }
|
||||
.history-section__arrow.is-expanded { transform: rotate(90deg); }
|
||||
.history-section h2 { margin: 0; font-size: var(--admin-font-title, 16px); }
|
||||
.history-section header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.loading-density { gap: var(--admin-space-5, 12px); padding: var(--admin-space-5, 12px); border-radius: var(--admin-radius, 4px); }
|
||||
@@ -287,8 +299,8 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.loading-density .detail-layout :deep(.detail-page-layout__content) { gap: var(--admin-space-5, 12px); }
|
||||
.loading-density .detail-section, .loading-density .history-section { padding-top: var(--admin-space-3, 8px); }
|
||||
.loading-density .detail-section__heading h2, .loading-density .history-section h2 { font-size: var(--admin-font-section, 14px); }
|
||||
.loading-density .history-section header { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.loading-density .detail-section__heading p, .loading-density small { font-size: var(--admin-font-caption, 12px); }
|
||||
.loading-density .history-section.is-expanded header { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.loading-density small { font-size: var(--admin-font-caption, 12px); }
|
||||
.loading-density .readiness-note { align-items: baseline; }
|
||||
.loading-density .readiness-note span { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .loading-density .detail-summary { grid-template-columns: 1fr; } .loading-density .detail-summary__wide { grid-column: auto; } }
|
||||
|
||||
@@ -116,11 +116,11 @@ describe('LoadingListsView', () => {
|
||||
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
|
||||
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
|
||||
expect(confirmedDetailSource).toContain('if (sequence !== loadSequence) return')
|
||||
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
|
||||
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
|
||||
})
|
||||
|
||||
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
|
||||
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
|
||||
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected"')
|
||||
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
|
||||
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
|
||||
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
|
||||
@@ -185,7 +185,7 @@ describe('LoadingListsView', () => {
|
||||
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
|
||||
expect(detailSource).toContain('来源:客户下单')
|
||||
expect(detailSource).toContain('来源:订单内部备注')
|
||||
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
|
||||
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
|
||||
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
|
||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||
expect(detailSource).not.toContain("emit('editCustomerRemark'")
|
||||
|
||||
@@ -68,9 +68,9 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(source).toContain("['EDITING', 'LOADING'].includes(summary.status)")
|
||||
expect(source).toContain('if (!summary.containerNo) return true')
|
||||
expect(source).not.toContain('summary?.containerNo && summary.status')
|
||||
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
|
||||
expect(source).toContain('该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。')
|
||||
expect(source).toContain('确认预装前必须补录真实集装箱号。')
|
||||
expect(source).not.toContain('transportCorrectionNote')
|
||||
expect(detailSource).not.toContain('class="readiness-note transport-note"')
|
||||
expect(detailSource).not.toContain('真实集装箱号:</strong>')
|
||||
expect(source).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
|
||||
expect(source).toContain("isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因')")
|
||||
expect(source).toContain("String(value || '').trim().toUpperCase()")
|
||||
@@ -86,24 +86,38 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(detailSource).toContain('role="status"')
|
||||
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
|
||||
expect(detailSource).not.toContain('type="success" :closable="false" show-icon title="整份预装计划已确认"')
|
||||
expect(detailSource).toContain('<el-button type="primary" :loading="confirmSaving"')
|
||||
expect(detailSource).toContain('type="primary" :loading="confirmSaving"')
|
||||
expect(detailSource).not.toContain('<el-button type="success" :loading="confirmSaving"')
|
||||
})
|
||||
|
||||
it('keeps explanatory notes outside the item list hidden', () => {
|
||||
expect(detailSource).not.toContain('核对运输信息、确认预装并维护逐条仓库备注')
|
||||
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
|
||||
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
|
||||
})
|
||||
|
||||
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
|
||||
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)')
|
||||
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)')
|
||||
expect(source).toContain('listDomesticPdaOperators(warehouseId.value)')
|
||||
expect(source).toContain('v-model="assigneeId"')
|
||||
expect(source).toContain('<template #pda-operator>')
|
||||
expect(detailSource).toContain('<slot v-if="mode === \'preload\'" name="pda-operator" />')
|
||||
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
|
||||
expect(detailSource).toContain('!pdaAssigneeSelected')
|
||||
expect(source).toContain('selectedAssignee?.displayName')
|
||||
expect(source).not.toContain('<el-form-item label="国内头程 PDA 作业员(必选)">')
|
||||
expect(source).toContain('summary?.readiness.canConfirm')
|
||||
expect(source).toContain('confirmSaving.value')
|
||||
expect(source).toContain('await loadDetail()')
|
||||
expect(source).toContain('本操作将确认整份预装计划')
|
||||
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm"')
|
||||
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
|
||||
expect(source).not.toContain('/inbounds/')
|
||||
})
|
||||
|
||||
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
|
||||
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
|
||||
expect(source).toContain('<OperationErrorToast :details="remarkError || editError || confirmError" />')
|
||||
expect(source).not.toContain(':message="confirmError.message"')
|
||||
expect(source).toContain(':message="confirmError.message"')
|
||||
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
|
||||
expect(source).toContain("label=\"柜次号\"")
|
||||
expect(source).toContain("label=\"集装箱号\"")
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, listDomesticPdaOperators, updateLoadingItemRemarks, updatePreloadTransport, type DomesticPdaOperator, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -28,6 +28,10 @@ const confirmOpen = ref(false)
|
||||
const confirmSaving = ref(false)
|
||||
const confirmError = ref<ApiErrorDetails | null>(null)
|
||||
const confirmOperationId = ref('')
|
||||
const assigneeId = ref('')
|
||||
const assigneeOptions = ref<DomesticPdaOperator[]>([])
|
||||
const assigneeLoading = ref(false)
|
||||
const selectedAssignee = computed(() => assigneeOptions.value.find((operator) => operator.userId === assigneeId.value))
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||
const remarkOpen = ref(false)
|
||||
@@ -37,6 +41,7 @@ const remarkTarget = ref<LoadingItem | null>(null)
|
||||
const remarkForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
|
||||
const remarkOperationId = ref('')
|
||||
let loadSequence = 0
|
||||
let assigneeLoadSequence = 0
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||
type LoadingPlanLifecycle = { planId: string | number; planStatus: string; detailResource: 'PRELOAD' | 'LOADING_LIST' }
|
||||
@@ -49,6 +54,7 @@ async function resolveLoadingPlanLifecycle() {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
|
||||
const canPushPda = computed(() => canEdit.value && access.can('platform:firstleg:pda:assign'))
|
||||
const canCorrectTransport = computed(() => {
|
||||
const summary = detail.value?.summary
|
||||
const pdaStatus = summary?.readiness.pdaTaskStatus
|
||||
@@ -57,15 +63,6 @@ const canCorrectTransport = computed(() => {
|
||||
return !pdaStatus || pdaStatus === 'PENDING'
|
||||
})
|
||||
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
|
||||
const transportCorrectionNote = computed(() => {
|
||||
const summary = detail.value?.summary
|
||||
if (!summary) return ''
|
||||
if (!summary.containerNo && summary.readiness.pdaTaskId) return '该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。'
|
||||
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
|
||||
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
|
||||
if (!summary.containerNo) return '确认预装前必须补录真实集装箱号。'
|
||||
return '真实集装箱号仅可在 PDA 领取前纠错,纠错保存时必须填写操作原因。'
|
||||
})
|
||||
const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
|
||||
const editRules: FormRules = {
|
||||
containerNo: [
|
||||
@@ -117,6 +114,7 @@ async function loadDetail() {
|
||||
return
|
||||
}
|
||||
detail.value = result
|
||||
if (canPushPda.value && !result.summary.readiness.pdaTaskId) void loadAssigneeOptions()
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return
|
||||
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
|
||||
@@ -165,6 +163,25 @@ async function saveTransport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssigneeOptions() {
|
||||
const sequence = ++assigneeLoadSequence
|
||||
assigneeLoading.value = true
|
||||
try {
|
||||
if (!warehouseId.value) return
|
||||
const options = await listDomesticPdaOperators(warehouseId.value)
|
||||
if (sequence !== assigneeLoadSequence) return
|
||||
assigneeOptions.value = options
|
||||
if (assigneeId.value && !assigneeOptions.value.some((operator) => operator.userId === assigneeId.value)) assigneeId.value = ''
|
||||
confirmError.value = null
|
||||
} catch (cause) {
|
||||
if (sequence !== assigneeLoadSequence) return
|
||||
confirmError.value = getApiErrorDetails(cause, '国内 PDA 作业员加载失败,请重试')
|
||||
assigneeOptions.value = []
|
||||
} finally {
|
||||
if (sequence === assigneeLoadSequence) assigneeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openRemarks(item: LoadingItem) {
|
||||
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
|
||||
remarkTarget.value = item
|
||||
@@ -224,7 +241,7 @@ async function saveRemarks() {
|
||||
|
||||
function openConfirmPreload() {
|
||||
const summary = detail.value?.summary
|
||||
if (!canEdit.value || !summary?.readiness.canConfirm) return
|
||||
if (!canPushPda.value || !summary?.readiness.canConfirm || !assigneeId.value) return
|
||||
confirmError.value = null
|
||||
confirmOperationId.value = `admin-preload-confirm-${summary.planId}-${summary.version}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
confirmOpen.value = true
|
||||
@@ -232,15 +249,15 @@ function openConfirmPreload() {
|
||||
|
||||
async function submitConfirmPreload() {
|
||||
const summary = detail.value?.summary
|
||||
if (!summary?.readiness.canConfirm || confirmSaving.value) return
|
||||
if (!summary?.readiness.canConfirm || !canPushPda.value || !assigneeId.value || confirmSaving.value) return
|
||||
confirmSaving.value = true
|
||||
confirmError.value = null
|
||||
try {
|
||||
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
|
||||
const result = await confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)
|
||||
await loadDetail()
|
||||
confirmOpen.value = false
|
||||
confirmOperationId.value = ''
|
||||
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
|
||||
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已推送给指定作业员' : '预装已确认')
|
||||
} catch (cause) {
|
||||
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
|
||||
} finally {
|
||||
@@ -249,14 +266,29 @@ async function submitConfirmPreload() {
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
watch([planId, warehouseId], () => void loadDetail())
|
||||
watch([planId, warehouseId], () => {
|
||||
assigneeLoadSequence++
|
||||
assigneeId.value = ''
|
||||
assigneeOptions.value = []
|
||||
assigneeLoading.value = false
|
||||
void loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page admin-density">
|
||||
<OperationErrorToast :details="remarkError || editError || confirmError" />
|
||||
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
|
||||
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-push-pda="canPushPda" :pda-assignee-selected="Boolean(assigneeId)" :can-edit-transport="canCorrectTransport" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload">
|
||||
<template #pda-operator>
|
||||
<div v-if="canPushPda && !detail?.summary.readiness.pdaTaskId" class="pda-operator-picker">
|
||||
<label for="preload-pda-operator">PDA 操作员 <span>(必选)</span></label>
|
||||
<el-select id="preload-pda-operator" v-model="assigneeId" filterable placeholder="请选择操作员" :loading="assigneeLoading" :disabled="confirmSaving" aria-label="PDA 操作员">
|
||||
<el-option v-for="operator in assigneeOptions" :key="operator.userId" :value="operator.userId" :label="operator.displayName" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
</LoadingListDetailDrawer>
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
@@ -278,7 +310,7 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认预装并推送 PDA" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
||||
<div class="confirm-content">
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
|
||||
<el-descriptions v-if="detail" :column="1" border>
|
||||
@@ -286,6 +318,8 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-descriptions :column="1" border><el-descriptions-item label="PDA 操作员">{{ selectedAssignee?.displayName || '未选择' }}</el-descriptions-item></el-descriptions>
|
||||
<LoadErrorState v-if="confirmError" compact title="推送失败" :message="confirmError.message" :show-retry="false" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
@@ -296,6 +330,10 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.confirm-content { display: grid; gap: var(--admin-space-5, 12px); }
|
||||
.pda-operator-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
|
||||
.pda-operator-picker label { flex: 0 0 auto; font-size: var(--admin-font-body, 13px); white-space: nowrap; }
|
||||
.pda-operator-picker label span { color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
|
||||
.pda-operator-picker :deep(.el-select) { width: 180px; }
|
||||
.remarks-form { margin-top: 14px; }
|
||||
.remarks-form small { display: block; margin-top: var(--admin-space-1, 4px); color: var(--cc-muted); }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
|
||||
Reference in New Issue
Block a user