优化
This commit is contained in:
@@ -80,7 +80,7 @@
|
||||
|
||||
- 当前产品层级只有平台和客户(企业)两级;Admin 不把“租户”作为用户可见概念,不新增租户选择器、目标租户文案或依赖租户上下文才能使用的页面流程。多租户产品能力后续单独设计。
|
||||
- 平台仓库是公共主数据,不与客户绑定。托盘、库位等仓储资源页面必须能够独立查询全部客户和全部仓库,选择客户不得缩小、清空或禁用仓库候选。
|
||||
- 后端兼容字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。
|
||||
- 后端字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。
|
||||
|
||||
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
|
||||
- 列表权限包含列表、详情和只读辅助数据访问;有正式列表页时,导航、列表路由和详情路由统一只使用 `*:list`。当前未上线,不兼容旧独立 `*:view`,不得通过 `permissionAnyOf` 或页面等价判断继续接受它;新增、编辑、删除、启停、审核、导出、上传、完成等操作仍分别校验独立权限。
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
阶段依据(路径相对本文件所在目录):一期 `../../../docs/一期/`,二期 `../../../docs/二期/`,三期 `../../../docs/三期/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
|
||||
|
||||
## 兼容性与验证
|
||||
## 契约与验证
|
||||
|
||||
- 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。
|
||||
- 优先采用兼容性新增;破坏性修改必须同步更新当前应用消费者。
|
||||
- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。
|
||||
- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('platform container loading API', () => {
|
||||
it('normalizes aggregate ids and uses the frozen overview and exception contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
|
||||
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
|
||||
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23' }], exceptionReasons: [] },
|
||||
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] },
|
||||
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
|
||||
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }],
|
||||
} })
|
||||
@@ -117,10 +117,11 @@ describe('platform container loading API', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('downloads PDA photos only from the backend-provided protected evidence path', async () => {
|
||||
it('downloads PDA photos only from the backend-provided protected container path', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
|
||||
await downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/files/loading-evidence/21/22/23', { responseType: 'blob' })
|
||||
await downloadContainerPdaPhoto('/api/platform/v1/containers/7/pda/photos/23/download')
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download', { responseType: 'blob' })
|
||||
expect(() => downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')).toThrow('invalid container photo path')
|
||||
expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,7 +145,7 @@ export function downloadContainerDocument(containerId: string, documentType: Ext
|
||||
}
|
||||
|
||||
export function downloadContainerPdaPhoto(downloadPath: string) {
|
||||
if (!downloadPath.startsWith('/api/platform/v1/files/loading-evidence/')) throw new Error('invalid container photo path')
|
||||
if (!/^\/api\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(downloadPath)) throw new Error('invalid container photo path')
|
||||
return http.get<Blob, Blob>(downloadPath, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,18 @@ describe('InboundTaskList measurement scope', () => {
|
||||
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
|
||||
})
|
||||
|
||||
it('embeds the weight OCR workflow in the actual measurement task without closing on weight changes', () => {
|
||||
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain("import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'")
|
||||
expect(source).toContain('<WeightOcrPanel')
|
||||
expect(source).toContain(':customer-id="selectedInbound.tenantId"')
|
||||
expect(source).toContain('@changed="handleWeightChanged"')
|
||||
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
|
||||
expect(source).toContain('measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)')
|
||||
expect(source).not.toContain('<WeightOcrPanel\n :customer-id="selectedInbound.tenantId"\n :inbound-id="selectedInbound.id"\n @changed="handleTaskChanged"')
|
||||
})
|
||||
|
||||
it('loads only active customers and exposes a searchable name-only select', () => {
|
||||
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const columns'))
|
||||
|
||||
@@ -18,6 +18,7 @@ import InboundMeasurementPanel from '../measurement/InboundMeasurementPanel.vue'
|
||||
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
|
||||
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
|
||||
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
customerId: string
|
||||
@@ -42,6 +43,7 @@ const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
||||
const taskDialogOpen = ref(false)
|
||||
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
|
||||
let requestSerial = 0
|
||||
let optionsRequestSerial = 0
|
||||
let disposed = false
|
||||
@@ -183,6 +185,14 @@ function handleTaskChanged() {
|
||||
void loadTasks()
|
||||
}
|
||||
|
||||
function handleWeightChanged() {
|
||||
void loadTasks()
|
||||
}
|
||||
|
||||
function handleWeightSummaryChanged(actualGrossWeightKg: number | null) {
|
||||
measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadTasks() })
|
||||
onMounted(() => { void loadOptions(); void loadTasks() })
|
||||
onActivated(() => {
|
||||
@@ -283,12 +293,22 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
:inbound-id="selectedInbound.id"
|
||||
@changed="handleTaskChanged"
|
||||
/>
|
||||
<InboundMeasurementPanel
|
||||
v-else-if="selectedInbound"
|
||||
:tenant-id="selectedInbound.tenantId"
|
||||
:inbound-id="selectedInbound.id"
|
||||
@changed="handleTaskChanged"
|
||||
/>
|
||||
<div v-else-if="selectedInbound" class="measurement-task-workspace">
|
||||
<section class="surface-panel inbound-task-weight" aria-label="称重图片与 OCR 核对">
|
||||
<WeightOcrPanel
|
||||
:customer-id="selectedInbound.tenantId"
|
||||
:inbound-id="selectedInbound.id"
|
||||
@changed="handleWeightChanged"
|
||||
@summary-changed="handleWeightSummaryChanged"
|
||||
/>
|
||||
</section>
|
||||
<InboundMeasurementPanel
|
||||
ref="measurementPanelRef"
|
||||
:tenant-id="selectedInbound.tenantId"
|
||||
:inbound-id="selectedInbound.id"
|
||||
@changed="handleTaskChanged"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -301,9 +321,12 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
.primary-cell { display: grid; gap: 3px; min-width: 0; }
|
||||
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
|
||||
.measurement-task-workspace { display: grid; min-width: 0; gap: 20px; }
|
||||
.inbound-task-weight { min-width: 0; padding: 20px; }
|
||||
@media (max-width: 520px) {
|
||||
.row-actions { align-items: stretch; flex-direction: column; }
|
||||
.row-actions :deep(.el-button) { width: 100%; }
|
||||
.inbound-task-weight { padding: 14px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -213,7 +213,19 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(source).toContain('.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
|
||||
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
||||
expect(source).toContain('defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })')
|
||||
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })')
|
||||
})
|
||||
|
||||
it('updates the server weight summary without discarding unsaved measurement edits', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const countInput = wrapper.find('.measurement-count-editor input')
|
||||
await countInput.setValue('9')
|
||||
|
||||
wrapper.vm.updateActualGrossWeight(125.75)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '125.75')).toBe(true)
|
||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
|
||||
@@ -435,6 +435,11 @@ function processStatusLabel(status: string | undefined) {
|
||||
return inboundStatusLabel(status, inboundProcessStatusLabels)
|
||||
}
|
||||
|
||||
function updateActualGrossWeight(actualGrossWeightKg: number | null) {
|
||||
if (!detail.value) return
|
||||
detail.value = { ...detail.value, actualGrossWeightKg }
|
||||
}
|
||||
|
||||
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
||||
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
|
||||
}
|
||||
@@ -456,7 +461,7 @@ watch(
|
||||
|
||||
onBeforeUnmount(releaseImagePreview)
|
||||
|
||||
defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
|
||||
defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -219,6 +219,27 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits the latest server total after confirming a weight', async () => {
|
||||
vi.mocked(getWeightWorkspace)
|
||||
.mockResolvedValueOnce(workspace)
|
||||
.mockResolvedValueOnce({
|
||||
...workspace,
|
||||
actualGrossWeightKg: 285.5,
|
||||
weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', finalWeightKg: 285.5, version: 1 }],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('summaryChanged')).toEqual([[285.5]])
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('已确认总毛重')
|
||||
expect(wrapper.text()).toContain('285.500 KG')
|
||||
})
|
||||
|
||||
it('requires explicit image review before confirming a low-confidence OCR value', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('WeightOcrPanel contract', () => {
|
||||
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
||||
expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()")
|
||||
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
||||
expect(source).toContain("defineEmits<{ changed: [] }>()")
|
||||
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null]')
|
||||
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||
expect(template).not.toContain('租户')
|
||||
@@ -26,6 +26,8 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain('识别重量')
|
||||
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
||||
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
|
||||
expect(source).toContain('已确认总毛重')
|
||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null)")
|
||||
})
|
||||
|
||||
it('matches backend image limits and keeps technical OCR data out of the list', () => {
|
||||
|
||||
@@ -25,7 +25,10 @@ import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../../status'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
summaryChanged: [actualGrossWeightKg: number | null]
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
@@ -145,6 +148,11 @@ function buildAdjustmentReason(row: WeightResultResource) {
|
||||
return `OCR 原值 ${ocrValue},人工确认 ${formatWeight(Number(draft.value), draft.unit)}`
|
||||
}
|
||||
|
||||
function emitWorkspaceChanged() {
|
||||
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null)
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
workspace.value = null
|
||||
@@ -208,7 +216,7 @@ async function handleFileChange(event: Event) {
|
||||
await openPreview(created, URL.createObjectURL(file))
|
||||
await loadWorkspace()
|
||||
ensureWeightDraft(created)
|
||||
emit('changed')
|
||||
emitWorkspaceChanged()
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
|
||||
} finally {
|
||||
@@ -244,7 +252,7 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals
|
||||
ElMessage.success('重量已确认')
|
||||
await loadWorkspace()
|
||||
if (closeAfterSuccess) previewOpen.value = false
|
||||
emit('changed')
|
||||
emitWorkspaceChanged()
|
||||
return true
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
|
||||
@@ -303,7 +311,7 @@ async function removeResult(row: WeightResultResource) {
|
||||
if (previewResultId.value === row.id) previewOpen.value = false
|
||||
ElMessage.success('称重图片已删除')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
emitWorkspaceChanged()
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片删除失败,请刷新后重试')
|
||||
} finally {
|
||||
@@ -345,6 +353,10 @@ defineExpose({ reload: loadWorkspace })
|
||||
<p>上传后直接核对重量,确认结果自动计入实际总毛重。</p>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<div v-if="workspace" class="weight-summary" aria-live="polite">
|
||||
<span>已确认总毛重</span>
|
||||
<strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden-input"
|
||||
@@ -517,7 +529,10 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
|
||||
.weight-heading h3 svg { color: var(--cc-primary); }
|
||||
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.weight-heading__actions { flex: 0 0 auto; }
|
||||
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
|
||||
.weight-summary { display: grid; gap: 2px; text-align: right; }
|
||||
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-summary strong { color: #067647; font-size: 18px; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
||||
.weight-list { min-height: 120px; margin-top: 16px; }
|
||||
@@ -550,7 +565,9 @@ defineExpose({ reload: loadWorkspace })
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.weight-heading { flex-direction: column; }
|
||||
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.weight-heading__actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.weight-summary { text-align: left; }
|
||||
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user