diff --git a/AGENTS.md b/AGENTS.md index 911fc0f1..d1fe214c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ - 当前产品层级只有平台和客户(企业)两级;Admin 不把“租户”作为用户可见概念,不新增租户选择器、目标租户文案或依赖租户上下文才能使用的页面流程。多租户产品能力后续单独设计。 - 平台仓库是公共主数据,不与客户绑定。托盘、库位等仓储资源页面必须能够独立查询全部客户和全部仓库,选择客户不得缩小、清空或禁用仓库候选。 -- 后端兼容字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。 +- 后端字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。 - 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。 - 列表权限包含列表、详情和只读辅助数据访问;有正式列表页时,导航、列表路由和详情路由统一只使用 `*:list`。当前未上线,不兼容旧独立 `*:view`,不得通过 `permissionAnyOf` 或页面等价判断继续接受它;新增、编辑、删除、启停、审核、导出、上传、完成等操作仍分别校验独立权限。 diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 7e283465..47a5dd88 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -23,8 +23,8 @@ 阶段依据(路径相对本文件所在目录):一期 `../../../docs/一期/`,二期 `../../../docs/二期/`,三期 `../../../docs/三期/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。 -## 兼容性与验证 +## 契约与验证 - 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。 -- 优先采用兼容性新增;破坏性修改必须同步更新当前应用消费者。 +- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。 - 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。 diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index cd7557fd..d3262f5a 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -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') }) }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 58c056b8..54f05bac 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -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(downloadPath, { responseType: 'blob' }) } diff --git a/src/modules/inbounds/components/InboundTaskList.test.ts b/src/modules/inbounds/components/InboundTaskList.test.ts index a23210ef..ed03012b 100644 --- a/src/modules/inbounds/components/InboundTaskList.test.ts +++ b/src/modules/inbounds/components/InboundTaskList.test.ts @@ -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(' { const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8') const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const columns')) diff --git a/src/modules/inbounds/components/InboundTaskList.vue b/src/modules/inbounds/components/InboundTaskList.vue index 31294aed..c97145ad 100644 --- a/src/modules/inbounds/components/InboundTaskList.vue +++ b/src/modules/inbounds/components/InboundTaskList.vue @@ -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(null) const warehouseOptions = ref>([]) const selectedInbound = ref(null) const taskDialogOpen = ref(false) +const measurementPanelRef = ref | 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" /> - +
+
+ +
+ +
@@ -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; } } diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts index a3db9746..58f81308 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -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 () => { diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index afec202f..3c4c71ce 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -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 })