@@ -5,10 +5,12 @@ import { AlertTriangle, Image, Plus, Save, Send, Upload } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels , inboundStatusLabel } from '../status'
import { createManualCartonScan , downloadInboundMeasurementImage , getInboundMeasurement , saveInboundMeasurementDraft , submitInboundMeasurement , updateConfirmedPackageCount } from './api'
import { listCustomerChargeRules , type CustomerChargeRule } from '@/modules/fba-pricing'
import type {
InboundMeasurementDetail ,
InboundMeasurementItem ,
@@ -19,6 +21,7 @@ const props = defineProps<{
customerId ? : string
tenantId ? : string
inboundId : string
openWarehouseSuggestion ? : boolean
} > ( )
const emit = defineEmits < {
@@ -28,10 +31,12 @@ const emit = defineEmits<{
submitted : [ ]
} > ( )
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
type EditableMeasurementItem = Omit < SaveInboundMeasurementItemRequest , ' coefficientMode ' > & {
itemId : string
clientKey : string
serverValues : Pick < InboundMeasurementItem , ' girthCm ' | ' unitVolumeCbm ' | ' totalWeightKg ' | ' totalVolumeCbm ' | ' volumeWeightKg ' >
chargeRuleId : string | null
coefficientMode : 'ACTUAL' | 'ADD' | 'MULTIPLY'
}
interface MeasurementFormModel {
@@ -57,10 +62,15 @@ const loading = ref(false)
const saving = ref ( false )
const submitting = ref ( false )
const manualEntryOpen = ref ( false )
const warehouseSuggestionOpen = ref ( false )
const warehouseSuggestionAutoOpened = ref ( false )
const warehouseSuggestion = reactive ( { itemId : '' , packageCount : 1 , remark : '' , coefficientMode : 'ADD' as 'ADD' | 'MULTIPLY' } )
const manualEntrySubmitting = ref ( false )
const manualEntryImage = ref < File | null > ( null )
const manualEntryErrors = ref < string [ ] > ( [ ] )
const detail = ref < InboundMeasurementDetail | null > ( null )
const chargeRules = ref < CustomerChargeRule [ ] > ( [ ] )
const chargeRulesLoading = ref ( false )
const loadError = ref < ApiErrorDetails | null > ( null )
const operationError = ref < ApiErrorDetails | null > ( null )
const conflictDetected = ref ( false )
@@ -95,6 +105,7 @@ const canView = computed(() => access.can('platform:firstleg:inbound:measure:vie
const canEdit = computed ( ( ) => access . can ( 'platform:firstleg:inbound:measure:edit' ) )
const canManualEntry = computed ( ( ) => access . can ( 'platform:firstleg:inbound:measure:manual-entry' ) )
const canSubmit = computed ( ( ) => access . can ( 'platform:firstleg:inbound:measure:submit' ) )
const canWarehouseSuggestion = computed ( ( ) => access . can ( 'platform:firstleg:inbound:warehouse-suggestion:edit' ) )
const customerId = computed ( ( ) => props . customerId || props . tenantId || '' )
const hasContext = computed ( ( ) => Boolean ( customerId . value && props . inboundId ) )
const countEditableProcess = computed ( ( ) => [ 'WAIT_MEASURE' , 'MEASURING' , 'MEASURE_PENDING' , 'MEASURE_REJECTED' ] . includes ( detail . value ? . processStatus || '' ) )
@@ -112,6 +123,9 @@ const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== basel
const registrationOpen = computed ( ( ) => hasContext . value && canView . value && Boolean ( detail . value ) )
const requiredPermission = computed ( ( ) => canEdit . value ? 'platform:firstleg:inbound:measure:edit' : null )
const sourceLabel = computed ( ( ) => ( { THIRD _PARTY : '第三方设备' , MANUAL : '平台人工' , OPENAPI : '第三方设备' } [ detail . value ? . sourceType || '' ] || ( detail . value ? . sourceType ? '未知来源' : '尚未测量' ) ) )
const suggestionItem = computed ( ( ) => form . items . find ( ( item ) => item . itemId === warehouseSuggestion . itemId ) || null )
const suggestionTotalVolume = computed ( ( ) => Number ( suggestionItem . value ? . serverValues . unitVolumeCbm || 0 ) * warehouseSuggestion . packageCount )
const suggestionGirth = computed ( ( ) => suggestionItem . value ? suggestionItem . value . lengthCm + 2 * ( suggestionItem . value . widthCm + suggestionItem . value . heightCm ) : 0 )
interface MeasurementDeviationWarning {
key : string
@@ -261,6 +275,7 @@ async function submitManualEntry() {
function editableItem ( item : InboundMeasurementItem ) : EditableMeasurementItem {
return {
itemId : String ( item . id ) ,
inboundDetailId : String ( item . inboundDetailId || item . id ) ,
clientKey : nextItemKey ( ) ,
boxCode : item . boxCode ,
itemNo : item . itemNo ,
@@ -285,9 +300,41 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
totalVolumeCbm : item . totalVolumeCbm ,
volumeWeightKg : item . volumeWeightKg ,
} ,
chargeRuleId : item . chargeRuleId == null ? null : String ( item . chargeRuleId ) ,
coefficientMode : item . coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item . coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL' ,
}
}
function openWarehouseSuggestion ( ) {
if ( ! canWarehouseSuggestion . value && ! canEdit . value ) return
const first = form . items [ 0 ]
warehouseSuggestion . itemId = first ? . itemId || ''
warehouseSuggestion . packageCount = first ? . packageCount || 1
warehouseSuggestion . remark = first ? . remark || ''
warehouseSuggestion . coefficientMode = first ? . coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD'
warehouseSuggestionOpen . value = true
}
function applyWarehouseSuggestion ( ) {
const item = suggestionItem . value
if ( ! item ) { ElMessage . warning ( '请选择当前实测 SKU' ) ; return }
if ( ! Number . isInteger ( warehouseSuggestion . packageCount ) || warehouseSuggestion . packageCount < 1 ) { ElMessage . warning ( '箱数必须是大于 0 的整数' ) ; return }
item . packageCount = warehouseSuggestion . packageCount
item . remark = warehouseSuggestion . remark . trim ( ) || null
item . coefficientMode = warehouseSuggestion . coefficientMode
warehouseSuggestionOpen . value = false
ElMessage . success ( '仓库建议数据已加入,请保存测量数据' )
}
async function loadChargeRules ( ) {
if ( ! customerId . value || ! access . can ( 'firstleg:fba-pricing:rule:view' ) ) return
chargeRulesLoading . value = true
try {
chargeRules . value = await listCustomerChargeRules ( customerId . value )
} catch { chargeRules . value = [ ] }
finally { chargeRulesLoading . value = false }
}
function clearForm ( ) {
form . measurementId = null
form . version = 0
@@ -319,6 +366,11 @@ async function loadMeasurement() {
const resource = await getInboundMeasurement ( customerId . value , props . inboundId )
if ( sequence !== requestSequence ) return
applyDetail ( resource )
void loadChargeRules ( )
if ( props . openWarehouseSuggestion && ! warehouseSuggestionAutoOpened . value && form . items . length ) {
warehouseSuggestionAutoOpened . value = true
openWarehouseSuggestion ( )
}
} catch ( error ) {
if ( sequence !== requestSequence ) return
detail . value = null
@@ -362,6 +414,14 @@ function validateDraft() {
else if ( value > 999 _999 _999 .999 ) itemErrors . push ( ` ${ label } 超出系统允许上限 999,999,999.999 cm ` )
else if ( Math . abs ( value * 1000 - Math . round ( value * 1000 ) ) > 1 e - 6 ) itemErrors . push ( ` ${ label } 最多保留 3 位小数 ` )
}
const outerDimensions = [ item . outerLengthCm , item . outerWidthCm , item . outerHeightCm ]
if ( outerDimensions . some ( ( value ) => value != null ) ) {
if ( outerDimensions . some ( ( value ) => value == null ) ) itemErrors . push ( '仓库修改外箱长、宽、高必须同时填写' )
outerDimensions . forEach ( ( value ) => {
if ( value != null && ( ! Number . isFinite ( value ) || value <= 0 ) ) itemErrors . push ( '仓库修改外箱尺寸必须大于 0' )
else if ( value != null && Math . abs ( value * 1000 - Math . round ( value * 1000 ) ) > 1 e - 6 ) itemErrors . push ( '仓库修改外箱尺寸最多保留 3 位小数' )
} )
}
if ( itemErrors . length ) errors [ item . clientKey ] = itemErrors . map ( ( message ) => ` 第 ${ index + 1 } 条: ${ message } ` )
} )
if ( general . length ) errors . general = general
@@ -406,7 +466,16 @@ async function saveDraft() {
conflictDetected . value = false
try {
const originalCounts = new Map ( ( detail . value ? . items || [ ] ) . map ( ( item ) => [ String ( item . id ) , item . packageCount ] ) )
const draftChanged = Boolean ( detail . value && ( detail . value . actualGoodsName || '' ) !== form . actualGoodsName )
const originalDraftValues = new Map ( ( detail . value ? . items || [ ] ) . map ( ( item ) => [ String ( item . id ) , JSON . stringify ( {
lengthCm : item . lengthCm , widthCm : item . widthCm , heightCm : item . heightCm ,
outerLengthCm : item . outerLengthCm , outerWidthCm : item . outerWidthCm , outerHeightCm : item . outerHeightCm ,
chargeRuleId : item . chargeRuleId || null , coefficientMode : item . coefficientMode || 'ACTUAL' , remark : item . remark || null ,
} ) ] ) )
const draftChanged = Boolean ( detail . value && ( ( detail . value . actualGoodsName || '' ) !== form . actualGoodsName || form . items . some ( ( item ) => originalDraftValues . get ( item . itemId ) !== JSON . stringify ( {
lengthCm : item . lengthCm , widthCm : item . widthCm , heightCm : item . heightCm ,
outerLengthCm : item . outerLengthCm , outerWidthCm : item . outerWidthCm , outerHeightCm : item . outerHeightCm ,
chargeRuleId : item . chargeRuleId || null , coefficientMode : item . coefficientMode , remark : item . remark || null ,
} ) ) ) )
const changedItems = form . items . filter ( ( item ) => originalCounts . get ( item . itemId ) !== item . packageCount )
let version = form . version
if ( draftChanged ) {
@@ -414,7 +483,14 @@ async function saveDraft() {
measurementId : form . measurementId ,
version ,
actualGoodsName : form . actualGoodsName . trim ( ) || null ,
items : form . items . map ( ( { itemId : _itemId , clientKey : _clientKey , serverValues : _serverValues , ... item } ) => item ) ,
items : form . items . map ( ( item ) => ( {
inboundDetailId : item . inboundDetailId , itemNo : item . itemNo , boxSpecification : item . boxSpecification ,
packageCount : item . packageCount , lengthCm : item . lengthCm , widthCm : item . widthCm , heightCm : item . heightCm ,
intelligentImageFileId : item . intelligentImageFileId , overLimitWarning : item . overLimitWarning ,
outerLengthCm : item . outerLengthCm , outerWidthCm : item . outerWidthCm , outerHeightCm : item . outerHeightCm ,
chargeRuleId : item . coefficientMode === 'MULTIPLY' ? item . chargeRuleId : null ,
coefficientMode : item . coefficientMode === 'ACTUAL' ? null : item . coefficientMode , remark : item . remark ,
} ) ) ,
} )
version = saved . version
}
@@ -528,6 +604,17 @@ function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
return validationErrors . value [ row . clientKey ] ? . length ? 'measurement-table__row--error' : ''
}
function chargeRuleName ( ruleId : string | null | undefined ) {
if ( ! ruleId ) return '默认规则'
const rule = chargeRules . value . find ( ( item ) => item . id === ruleId )
return rule ? chargeRuleLabel ( rule ) : ruleId
}
function chargeRuleLabel ( rule : CustomerChargeRule ) {
const remark = rule . remark ? . trim ( )
return ` 系数 ${ rule . lengthFactor } ${ remark ? ` · ${ remark } ` : '' } ${ rule . defaultRule ? '(默认)' : '' } `
}
watch (
( ) => [ customerId . value , props . inboundId , canView . value ] as const ,
( ) => {
@@ -537,6 +624,7 @@ watch(
operationError . value = null
conflictDetected . value = false
validationErrors . value = { }
warehouseSuggestionAutoOpened . value = false
clearForm ( )
if ( hasContext . value && canView . value ) void loadMeasurement ( )
} ,
@@ -645,7 +733,10 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
< section class = "measurement-section measurement-items-section" >
< div class = "measurement-section__heading" >
< div > < h3 > 箱规明细 < / h3 > < / div >
< el-button v-if = "canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" / > 手动录入箱规 < / el-button >
< div class = "measurement-section-actions" >
< el-button v-if = "canWarehouseSuggestion" type="primary" plain :disabled="!mayMaintain || !form.items.length" @click="openWarehouseSuggestion"><Plus :size="15" / > 添加仓库建议数据 < / el-button >
< el-button v-if = "canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" / > 手动录入箱规 < / el-button >
< / div >
< / div >
< el-table
@@ -669,13 +760,22 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
< / dl >
< / template >
< / el-table-column >
< el-table-column type = "index " label = "#" width = "48 " align = "center" / >
< el-table-column prop = "itemNo " label = "#" width = "70 " align = "center" / >
< el-table-column label = "箱号" min -width = " 156 " show -overflow -tooltip >
< template # default = "{ row }" > < span class = "measurement-box-code" > { { row . boxCode || '—' } } < / span > < / template >
< / el-table-column >
< el-table-column label = "尺寸 长×宽×高(cm)" width = "166" align = "center" >
< template # default = "{ row }" > { { formatNumber ( row . lengthCm ) } } × { { formatNumber ( row . widthCm ) } } × { { formatNumber ( row . heightCm ) } } < / template >
< / el-table-column >
< el-table-column label = "仓库修改外箱(长)" width = "165" align = "center" >
< template # default = "{ row }" > < el-input-number v-model = "row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" / > < / template >
< / el-table-column >
< el-table-column label = "仓库修改外箱(宽)" width = "165" align = "center" >
< template # default = "{ row }" > < el-input-number v-model = "row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="不修改" / > < / template >
< / el-table-column >
< el-table-column label = "仓库修改外箱(高)" width = "165" align = "center" >
< template # default = "{ row }" > < el-input-number v-model = "row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="不修改" / > < / template >
< / el-table-column >
< el-table-column label = "确认箱数" width = "132" align = "center" >
< template # default = "{ row }" >
< el-input-number
@@ -690,6 +790,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
/ >
< / template >
< / el-table-column >
< el-table-column label = "计算方式" min -width = " 150 " >
< template # default = "{ row }" >
< el-select v-model = "row.coefficientMode" placeholder="选择计算方式" >
< el -option label = "实测原尺寸(不调整)" value = "ACTUAL" / >
< el-option label = "按箱规尺寸策略" value = "ADD" / >
< el-option label = "按箱规尺寸系数" value = "MULTIPLY" : disabled = "!chargeRules.some((rule) => rule.defaultRule && rule.status === 'ACTIVE')" / >
< / el-select >
< / template >
< / el-table-column >
< el-table-column label = "总体积(m³)" width = "108" align = "right" >
< template # default = "{ row }" > { { formatNumber ( row . serverValues . totalVolumeCbm , 4 ) } } < / template >
< / el-table-column >
@@ -704,6 +813,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
< / el-table-column >
< / el-table >
< el-empty v-else description = "暂无扫描箱测量数据" / >
< div v-if = "form.items.length" class="warehouse-suggestion-table" >
< h3 > 仓库建议数据 < / h3 >
< el-table :data = "form.items" border stripe empty -text = " 暂无仓库建议数据 " >
< el-table-column prop = "itemNo" label = "当前实测 SKU" min -width = " 150 " / >
< el-table-column label = "外箱规 长×宽×高(cm)" min -width = " 190 " > < template # default = "{ row }" > { { formatNumber ( row . outerLengthCm ) } } × { { formatNumber ( row . outerWidthCm ) } } × { { formatNumber ( row . outerHeightCm ) } } < / template > < / el-table-column >
< el-table-column prop = "packageCount" label = "箱数" width = "90" / >
< el-table-column label = "周长(cm)" width = "110" > < template # default = "{ row }" > { { formatNumber ( row . serverValues . girthCm ) } } < / template > < / el-table-column >
< el-table-column label = "总体积(m³)" width = "120" > < template # default = "{ row }" > { { formatNumber ( row . serverValues . unitVolumeCbm == null ? null : row . serverValues . unitVolumeCbm * row . packageCount , 6 ) } } < / template > < / el-table-column >
< el-table-column label = "计算策略" min -width = " 190 " > < template # default = "{ row }" > { { row . coefficientMode === 'MULTIPLY' ? '按箱规尺寸系数' : '按箱规尺寸策略' } } < span v-if = "row.coefficientMode === 'MULTIPLY'" > ( {{ chargeRuleName ( row.chargeRuleId ) }} ) < / span > < / template > < / el -table -column >
< el-table-column prop = "remark" label = "备注" min -width = " 160 " show -overflow -tooltip / >
< / el-table >
< / div >
< / section >
< / fieldset >
< / form >
@@ -717,6 +838,21 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
< / div >
< / footer >
< AppDialog v-model = "warehouseSuggestionOpen" title="添加仓库建议数据" width="min(680px, calc(100vw - 28px))" confirm-text="加入建议" @confirm="applyWarehouseSuggestion" >
< el -form label -position = " top " class = "warehouse-suggestion-form" >
< el-form-item label = "当前实测 SKU" required > < el-select v-model = "warehouseSuggestion.itemId" filterable><el-option v-for="item in form.items" :key="item.itemId" :label="`${item.itemNo} · ${item.boxSpecification || item.boxCode || '实测SKU'}`" :value="item.itemId" / > < / el-select > < / el-form-item >
< el-form-item label = "箱数" required > < el-input-number v-model = "warehouseSuggestion.packageCount" :min="1" :max="1000000" step -strictly / > < / el-form-item >
< el-form-item label = "计算方式" > < el-select v-model = "warehouseSuggestion.coefficientMode"><el-option label="按箱规尺寸策略" value="ADD" /><el-option label="按箱规尺寸系数" value="MULTIPLY" / > < / el-select > < / el-form-item >
< el-form-item label = "备注" > < el-input v-model = "warehouseSuggestion.remark" type="textarea" :rows="3" maxlength="500" show -word -limit / > < / el-form-item >
< / el-form >
< dl v-if = "suggestionItem" class="suggestion-preview" >
< div > < dt > 长 × 宽 × 高 < / dt > < dd > { { formatNumber ( suggestionItem . lengthCm ) } } × { { formatNumber ( suggestionItem . widthCm ) } } × { { formatNumber ( suggestionItem . heightCm ) } } cm < / dd > < / div >
< div > < dt > 箱规周长 < / dt > < dd > { { formatNumber ( suggestionGirth ) } } cm < / dd > < / div >
< div > < dt > 单箱体积 < / dt > < dd > { { formatNumber ( suggestionItem . serverValues . unitVolumeCbm , 6 ) } } m³ < / dd > < / div >
< div > < dt > 总体积 < / dt > < dd > { { formatNumber ( suggestionTotalVolume , 6 ) } } m³ < / dd > < / div >
< / dl >
< / AppDialog >
< el-dialog v-model = "imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview" >
< div class = "measurement-image-preview" >
< el-skeleton v-if = "imagePreviewLoading" :rows="5" animated / >
@@ -797,6 +933,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
. measurement - declaration h4 { margin : 0 0 10 px ; color : # 344054 ; font - size : 12 px ; font - weight : 700 ; }
. measurement - section _ _heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 18 px ; margin - bottom : 16 px ; }
. measurement - section _ _heading > div { min - width : 0 ; }
. measurement - section - actions { display : flex ; flex - wrap : wrap ; justify - content : flex - end ; gap : 8 px ; } . measurement - section - actions : deep ( . el - button ) { margin - left : 0 ; }
. warehouse - suggestion - form { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 0 16 px ; } . warehouse - suggestion - form : deep ( . el - select ) , . warehouse - suggestion - form : deep ( . el - input - number ) { width : 100 % ; } . warehouse - suggestion - form : deep ( . el - form - item : last - child ) { grid - column : 1 / - 1 ; } . suggestion - preview { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; margin : 4 px 0 0 ; border : 1 px solid var ( -- cc - border ) ; border - radius : 6 px ; } . suggestion - preview div { padding : 12 px ; border - right : 1 px solid var ( -- cc - border ) ; } . suggestion - preview div : last - child { border - right : 0 ; } . suggestion - preview dt { color : var ( -- cc - muted ) ; font - size : 11 px ; } . suggestion - preview dd { margin : 5 px 0 0 ; color : var ( -- cc - text ) ; font - size : 13 px ; font - weight : 650 ; }
. measurement - form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px 24 px ; }
. measurement - data - grid { display : grid ; grid - template - columns : repeat ( 5 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. measurement - form label { display : flex ; min - width : 0 ; flex - direction : column ; gap : 7 px ; color : # 344054 ; font - size : 12 px ; font - weight : 650 ; }
@@ -818,6 +956,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
. measurement - table : deep ( . el - table _ _cell ) { padding : 7 px 0 ; }
. measurement - table : deep ( . cell ) { line - height : 1.35 ; }
. measurement - table : deep ( . measurement - table _ _row -- error > td . el - table _ _cell ) { background : var ( -- el - color - danger - light - 9 ) ; }
. warehouse - suggestion - table { display : grid ; gap : 10 px ; margin - top : 18 px ; } . warehouse - suggestion - table h3 { margin : 0 ; color : var ( -- cc - text ) ; font - size : 14 px ; }
. measurement - box - code { font - weight : 650 ; }
. measurement - count - editor { width : 104 px ! important ; }
. measurement - row - details { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 8 px 20 px ; margin : 0 ; padding : 4 px 18 px 8 px 62 px ; }