diff --git a/src/deployment/viteConfig.test.ts b/src/deployment/viteConfig.test.ts new file mode 100644 index 00000000..cbbf3d10 --- /dev/null +++ b/src/deployment/viteConfig.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const configSource = readFileSync(resolve(process.cwd(), 'vite.config.ts'), 'utf8') + +describe('Vite development server', () => { + it('fails fast when the dedicated admin development port is occupied', () => { + expect(configSource).toMatch(/server:\s*\{\s*strictPort: true,/) + }) +}) diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts index a1757e18..c7e54a15 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -6,17 +6,20 @@ import { ApiResponseError } from '@/shared/api/http' import { useAuthStore } from '@/core/auth/auth' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { + downloadInboundMeasurementImage, getInboundMeasurement, - saveInboundMeasurementDraft, submitInboundMeasurement, + updateConfirmedPackageCount, } from './api' import type { InboundMeasurementDetail } from './types' import InboundMeasurementPanel from './InboundMeasurementPanel.vue' vi.mock('./api', () => ({ + downloadInboundMeasurementImage: vi.fn(), getInboundMeasurement: vi.fn(), saveInboundMeasurementDraft: vi.fn(), submitInboundMeasurement: vi.fn(), + updateConfirmedPackageCount: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { @@ -122,7 +125,10 @@ describe('InboundMeasurementPanel', () => { vi.stubGlobal('sessionStorage', createMemoryStorage()) document.body.innerHTML = '' vi.mocked(getInboundMeasurement).mockReset().mockResolvedValue(measurement()) - vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' }) + vi.mocked(updateConfirmedPackageCount).mockReset().mockResolvedValue({ + measurementId: '9001', measurementVersion: 3, itemId: '9101', + boxCode: 'RK202608040001#10-1', confirmedPackageCount: 10, + }) vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({ measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null, @@ -139,7 +145,10 @@ describe('InboundMeasurementPanel', () => { expect(wrapper.text()).toContain('箱规周长') expect(wrapper.text()).toContain('190 cm') expect(wrapper.text()).toContain('0.6 CBM') - expect((wrapper.find('input[placeholder="请输入本箱规的补充说明"]').element as HTMLInputElement).value).toBe('设备自动回传') + expect(wrapper.text()).toContain('MJ20210327') + const measuredAtInput = wrapper.findAll('label').find((label) => label.text().includes('测量时间'))?.find('input') + expect((measuredAtInput?.element as HTMLInputElement).value).toBe('2026-08-04 10:30:00+08:00') + expect(wrapper.text()).toContain('预览图片') }) it('follows the prototype grouping, labels, and editable field order', async () => { @@ -158,11 +167,11 @@ describe('InboundMeasurementPanel', () => { const labels = wrapper.findAll('.measurement-item__grid label > span').map((label) => label.text()) expect(labels.slice(0, 8)).toEqual([ '箱号', - '测量长(cm)*', - '测量宽(cm)*', - '测量高(cm)*', + '测量长(cm)', + '测量宽(cm)', + '测量高(cm)', '确认箱数 *', - '每箱毛重(kg)*', + '每箱毛重(kg)', '机器编号', '测量时间', ]) @@ -176,46 +185,45 @@ describe('InboundMeasurementPanel', () => { expect(wrapper.find('.status-badge').text()).toBe('测量中') }) - it('saves only editable raw fields with the current measurement version', async () => { + it('saves only the changed confirmed package count with the current measurement version', async () => { + vi.mocked(updateConfirmedPackageCount).mockResolvedValueOnce({ + measurementId: '9001', measurementVersion: 3, itemId: '9101', + boxCode: 'RK202608040001#10-1', confirmedPackageCount: 9, + }) const wrapper = await mountPanel() - await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('智能音箱') - const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿')) + const countInput = wrapper.findAll('label').find((label) => label.text().includes('确认箱数'))?.find('input') + await countInput?.setValue('9') + const save = wrapper.findAll('button').find((button) => button.text().includes('保存确认箱数')) await save?.trigger('click') await flushPromises() - expect(saveInboundMeasurementDraft).toHaveBeenCalledOnce() - expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ - measurementId: '9001', - version: 2, - actualGoodsName: '智能音箱', - items: [expect.objectContaining({ - itemNo: '001', packageCount: 10, lengthCm: 50, intelligentImageFileId: '88001', - })], - })) - const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0][2] - expect(payload.items[0]).not.toHaveProperty('totalVolumeCbm') + expect(updateConfirmedPackageCount).toHaveBeenCalledWith('10001', '7001', '9101', { + measurementVersion: 2, + confirmedPackageCount: 9, + }) }) it('preserves the edited form when the backend reports a version conflict', async () => { - vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce( + vi.mocked(updateConfirmedPackageCount).mockRejectedValueOnce( new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'), ) const wrapper = await mountPanel() - const goodsInput = wrapper.find('input[placeholder="请输入实际货物名称"]') - await goodsInput.setValue('保留我的修改') - const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿')) + const countInput = wrapper.findAll('label').find((label) => label.text().includes('确认箱数'))?.find('input') + await countInput?.setValue('9') + const save = wrapper.findAll('button').find((button) => button.text().includes('保存确认箱数')) await save?.trigger('click') await flushPromises() expect(wrapper.text()).toContain('数据版本已变化,当前修改尚未丢失') expect(wrapper.text()).toContain('req-conflict') - expect((goodsInput.element as HTMLInputElement).value).toBe('保留我的修改') + expect((countInput?.element as HTMLInputElement).value).toBe('9') expect(getInboundMeasurement).toHaveBeenCalledTimes(1) }) it('requires the dirty draft to be saved before submitting for review', async () => { const wrapper = await mountPanel() - await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('尚未保存') + const countInput = wrapper.findAll('label').find((label) => label.text().includes('确认箱数'))?.find('input') + await countInput?.setValue('9') const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核')) await submit?.trigger('click') await flushPromises() @@ -250,6 +258,17 @@ describe('InboundMeasurementPanel', () => { expect(submitInboundMeasurement).not.toHaveBeenCalled() }) + it('keeps device facts read-only but allows manual count correction after submission', async () => { + vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ processStatus: 'MEASURE_PENDING' })) + const wrapper = await mountPanel() + const countInput = wrapper.findAll('label').find((label) => label.text().includes('确认箱数'))?.find('input') + + expect((countInput?.element as HTMLInputElement).matches(':disabled')).toBe(false) + expect(wrapper.findAll('button').find((button) => button.text().includes('保存确认箱数'))?.attributes('disabled')).toBeUndefined() + expect(wrapper.findAll('button').find((button) => button.text().includes('提交审核'))?.attributes('disabled')).toBeDefined() + expect(vi.mocked(downloadInboundMeasurementImage)).not.toHaveBeenCalled() + }) + it('clears the previous customer context before loading the next one', async () => { const wrapper = await mountPanel() vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ inboundId: '7002', inboundCode: 'RK202608040002', companyName: '第二企业' })) diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index f88add7b..40744dfe 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -8,11 +8,10 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api' +import { downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api' import type { InboundMeasurementDetail, InboundMeasurementItem, - SaveInboundMeasurementDraftRequest, SaveInboundMeasurementItemRequest, } from './types' @@ -27,6 +26,7 @@ const emit = defineEmits<{ }>() interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest { + itemId: string clientKey: string serverValues: Pick } @@ -73,8 +73,9 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edi const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit')) const customerId = computed(() => props.customerId || props.tenantId || '') const hasContext = computed(() => Boolean(customerId.value && props.inboundId)) -const editableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || '')) -const mayMaintain = computed(() => canEdit.value && editableProcess.value && !loading.value && !submitting.value) +const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || '')) +const submitEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || '')) +const mayMaintain = computed(() => canEdit.value && countEditableProcess.value && !loading.value && !submitting.value) const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0)) const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null) const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value) @@ -85,7 +86,7 @@ const packageCountDifferenceLabel = computed(() => { ? `超出 ${packageCountDifference.value} 箱` : `还差 ${Math.abs(packageCountDifference.value)} 箱` }) -const maySubmit = computed(() => canSubmit.value && editableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && !loading.value && !saving.value) +const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && !loading.value && !saving.value) const snapshot = computed(() => JSON.stringify(form)) const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value) const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value)) @@ -101,11 +102,6 @@ const { captureBaseline } = useUnsavedWorkRegistration({ requiredPermission, }) -function optionalText(value: string) { - const normalized = value.trim() - return normalized || null -} - function nextItemKey() { itemSequence += 1 return `measurement-item-${itemSequence}` @@ -113,6 +109,7 @@ function nextItemKey() { function editableItem(item: InboundMeasurementItem): EditableMeasurementItem { return { + itemId: String(item.id), clientKey: nextItemKey(), boxCode: item.boxCode, itemNo: item.itemNo, @@ -202,36 +199,14 @@ async function reloadLatest() { await loadMeasurement() } -function isPositive(value: number | null) { - return value != null && Number.isFinite(value) && value > 0 -} - -function isNonNegative(value: number | null) { - return value == null || (Number.isFinite(value) && value >= 0) -} - function validateDraft() { const errors: Record = {} const general: string[] = [] if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录') if (form.items.length > 500) general.push('单个测量批次最多包含 500 条实际扫描箱记录') - if (form.originalDocumentNo.trim().length > 64) general.push('原始单据号不能超过 64 个字符') - if (form.actualGoodsName.trim().length > 255) general.push('实际品名不能超过 255 个字符') - if (!isNonNegative(form.originalGrossWeightKg)) general.push('申报毛重不能小于 0') - if (!isNonNegative(form.originalVolumeCbm)) general.push('申报体积不能小于 0') form.items.forEach((item, index) => { const itemErrors: string[] = [] - if (item.boxCode && item.boxCode.length > 128) itemErrors.push('箱号不能超过 128 个字符') - if (!item.itemNo.trim()) itemErrors.push('请输入测量记录序号') - else if (item.itemNo.trim().length > 32) itemErrors.push('箱规序号不能超过 32 个字符') - if (!item.boxSpecification.trim()) itemErrors.push('请输入箱规描述') - else if (item.boxSpecification.trim().length > 64) itemErrors.push('箱规描述不能超过 64 个字符') if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数') - if (!isPositive(item.lengthCm) || !isPositive(item.widthCm) || !isPositive(item.heightCm)) itemErrors.push('长、宽、高必须大于 0') - if (!isPositive(item.unitWeightKg)) itemErrors.push('单箱重量必须大于 0') - if (![item.outerLengthCm, item.outerWidthCm, item.outerHeightCm].every(isNonNegative)) itemErrors.push('外包装尺寸不能小于 0') - if (item.intelligentImageFileId && !/^\d+$/.test(item.intelligentImageFileId)) itemErrors.push('测量图片文件 ID 必须是正整数') - if ((item.overLimitWarning || '').length > 500 || (item.remark || '').length > 500) itemErrors.push('提示或备注不能超过 500 个字符') if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`) }) if (general.length) errors.general = general @@ -239,35 +214,6 @@ function validateDraft() { return Object.keys(errors).length === 0 } -function buildPayload(): SaveInboundMeasurementDraftRequest { - return { - measurementId: form.measurementId, - version: form.version, - originalDocumentNo: optionalText(form.originalDocumentNo), - originalGrossWeightKg: form.originalGrossWeightKg, - originalVolumeCbm: form.originalVolumeCbm, - actualGoodsName: optionalText(form.actualGoodsName), - items: form.items.map((item) => ({ - boxCode: optionalText(item.boxCode || ''), - itemNo: item.itemNo.trim(), - boxSpecification: item.boxSpecification.trim(), - packageCount: item.packageCount, - lengthCm: item.lengthCm, - widthCm: item.widthCm, - heightCm: item.heightCm, - unitWeightKg: item.unitWeightKg, - intelligentImageFileId: item.intelligentImageFileId || null, - machineNo: optionalText(item.machineNo || ''), - measuredAt: item.measuredAt || null, - overLimitWarning: optionalText(item.overLimitWarning || ''), - outerLengthCm: item.outerLengthCm, - outerWidthCm: item.outerWidthCm, - outerHeightCm: item.outerHeightCm, - remark: optionalText(item.remark || ''), - })), - } -} - function setOperationError(error: unknown, fallback: string) { const details = getApiErrorDetails(error, fallback) operationError.value = details @@ -285,10 +231,18 @@ async function saveDraft() { operationError.value = null conflictDetected.value = false try { - const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, buildPayload()) - form.measurementId = String(saved.measurementId) - form.version = saved.version - ElMessage.success('测量草稿已保存,计算结果已由服务端更新') + const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount])) + const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount) + let version = form.version + for (const item of changedItems) { + const saved = await updateConfirmedPackageCount(customerId.value, props.inboundId, item.itemId, { + measurementVersion: version, + confirmedPackageCount: item.packageCount, + }) + version = saved.measurementVersion + } + form.version = version + ElMessage.success('确认箱数已保存,计算结果已由服务端更新') await loadMeasurement() emit('changed') } catch (error) { @@ -307,7 +261,7 @@ async function submitForReview() { } try { await ElMessageBox.confirm( - '提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑。', + '提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑,设备也不能再覆盖已提交数据。', '提交测量审核', { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' }, ) @@ -399,7 +353,7 @@ onBeforeUnmount(releaseImagePreview) 刷新 - 保存草稿 + 保存确认箱数 提交审核 @@ -452,9 +406,9 @@ onBeforeUnmount(releaseImagePreview) - + - 测量草稿只允许在待测量、测量中或审核驳回状态维护。 + 确认箱数只允许在待测量、测量中、待审核或审核驳回状态维护。 @@ -491,16 +445,16 @@ onBeforeUnmount(releaseImagePreview)

原单数据

原单值仅用于审核对比,不参与前端计算。

- - - - + + + +
-

扫描箱测量数据明细

每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备数据由服务端计算汇总。

+

扫描箱测量数据明细

每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备实测值不可在此修改。

@@ -511,25 +465,15 @@ onBeforeUnmount(releaseImagePreview)
- - - + + + - +
-
- 外包装尺寸与补充说明 -
- - - - - -
-
箱规周长
{{ formatNumber(item.serverValues.girthCm) }} cm
单箱体积
{{ formatNumber(item.serverValues.unitVolumeCbm, 4) }} CBM
@@ -546,7 +490,7 @@ onBeforeUnmount(releaseImagePreview) 有尚未保存的修改 当前显示版本 {{ form.version }}
- 保存草稿 + 保存确认箱数 提交审核
diff --git a/src/modules/inbounds/measurement/api.test.ts b/src/modules/inbounds/measurement/api.test.ts index e3c6d618..166ef191 100644 --- a/src/modules/inbounds/measurement/api.test.ts +++ b/src/modules/inbounds/measurement/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api' +import { downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api' import type { SaveInboundMeasurementDraftRequest } from './types' vi.mock('@/shared/api/http', () => ({ @@ -75,4 +75,30 @@ describe('inbound measurement API', () => { { params: { tenantId: '10001' } }, ) }) + + it('downloads the current measurement image using the scoped file route', async () => { + const image = new Blob(['image-data'], { type: 'image/jpeg' }) + vi.mocked(http.get).mockResolvedValue(image) + + await expect(downloadInboundMeasurementImage('100/01', '7001#1', '88001')).resolves.toBe(image) + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/files/measurements/100%2F01/7001%231/88001', + { responseType: 'blob' }, + ) + }) + + it('updates one scanned carton confirmed package count', async () => { + const saved = { measurementId: '9001', measurementVersion: 3, itemId: '1', boxCode: 'RK#10-1', confirmedPackageCount: 9 } + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved }) + + await expect(updateConfirmedPackageCount('10001', '7001', '1', { + measurementVersion: 2, + confirmedPackageCount: 9, + })).resolves.toEqual(saved) + expect(http.put).toHaveBeenCalledWith( + '/platform/v1/first-leg/inbounds/7001/measurement/items/1/confirmed-package-count', + { measurementVersion: 2, confirmedPackageCount: 9 }, + { params: { tenantId: '10001' } }, + ) + }) }) diff --git a/src/modules/inbounds/measurement/api.ts b/src/modules/inbounds/measurement/api.ts index 328f3474..c8e6cfc5 100644 --- a/src/modules/inbounds/measurement/api.ts +++ b/src/modules/inbounds/measurement/api.ts @@ -3,6 +3,8 @@ import type { InboundMeasurementDetail, InboundMeasurementDraftSaved, InboundMeasurementSubmitted, + CartonMeasurementResult, + ConfirmedPackageCountRequest, SaveInboundMeasurementDraftRequest, SubmitInboundMeasurementRequest, } from './types' @@ -11,11 +13,27 @@ function measurementPath(inboundId: string) { return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement` } +export async function updateConfirmedPackageCount( + customerId: string, + inboundId: string, + itemId: string, + payload: ConfirmedPackageCountRequest, +) { + const response = await http.put, ApiEnvelope>( + `${measurementPath(inboundId)}/items/${segment(itemId)}/confirmed-package-count`, + payload, + { params: { tenantId: customerId } }, + ) + return unwrapApiResponse(response) +} + function segment(value: string) { return encodeURIComponent(value) } export async function getInboundMeasurement(customerId: string, inboundId: string) { + // Device-scanned fields expected from the backend: originalPackageCount and + // items[].boxCode, machineNo, measuredAt. Keep this compatibility mapping here. const response = await http.get, ApiEnvelope>( measurementPath(inboundId), { params: { tenantId: customerId } }, diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index b873331c..93ae63b4 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -120,6 +120,19 @@ export interface InboundMeasurementDraftSaved { status: string } +export interface ConfirmedPackageCountRequest { + measurementVersion: number + confirmedPackageCount: number +} + +export interface CartonMeasurementResult { + measurementId: MeasurementIdentifier + measurementVersion: number + itemId: MeasurementIdentifier + boxCode: string + confirmedPackageCount: number +} + export interface SubmitInboundMeasurementRequest { measurementId: MeasurementIdentifier version: number diff --git a/vite.config.ts b/vite.config.ts index 96362806..8b6c6f08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig(({ mode }) => { }, }, server: { + strictPort: true, proxy: { '/api': { target: proxyTarget,