Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
calculateInboundFee,
|
||||
confirmInboundFee,
|
||||
createFeeActionIdempotencyKey,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
sendInboundFee,
|
||||
} from './api'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
import { getInboundMeasurement } from '../measurement/api'
|
||||
import type { InboundMeasurementDetail } from '../measurement/types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
@@ -44,6 +47,10 @@ const adjustmentError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictRefreshed = ref(false)
|
||||
const snapshot = ref<InboundFeeSnapshot | null>(null)
|
||||
const adjustOpen = ref(false)
|
||||
const trialOpen = ref(false)
|
||||
const trialLoading = ref(false)
|
||||
const measurement = ref<InboundMeasurementDetail | null>(null)
|
||||
const trial = reactive({ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
|
||||
const adjustFormRef = ref<FormInstance>()
|
||||
let loadSequence = 0
|
||||
let adjustmentFingerprint = ''
|
||||
@@ -71,6 +78,7 @@ const canView = computed(() => access.can('firstleg:fba-pricing:view'))
|
||||
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
|
||||
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
|
||||
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
|
||||
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate'))
|
||||
const customerContextId = computed(() => props.customerId || props.tenantId || '')
|
||||
const normalizedInboundId = computed(() => {
|
||||
const identifier = String(props.inboundId ?? '').trim()
|
||||
@@ -83,6 +91,18 @@ const adjustmentEligible = computed(() => (
|
||||
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
|
||||
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
|
||||
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
|
||||
const sourceOptions = computed(() => {
|
||||
const value = measurement.value
|
||||
if (!value) return []
|
||||
const itemWeight = value.items.reduce((sum, item) => sum + Number(item.totalWeightKg || 0), 0)
|
||||
const itemVolume = value.items.reduce((sum, item) => sum + Number(item.totalVolumeCbm || 0), 0)
|
||||
return [
|
||||
{ value: 'CUSTOMER', label: '客户下单数据', weight: Number(value.originalGrossWeightKg || 0), volume: Number(value.originalVolumeCbm || 0) },
|
||||
{ value: 'OUTER', label: '外箱规数据', weight: itemWeight, volume: itemVolume },
|
||||
{ value: 'ACTUAL', label: '实际测量数据', weight: Number(value.actualNetWeightKg || value.actualGrossWeightKg || 0), volume: Number(value.actualVolumeCbm || 0) },
|
||||
{ value: 'WAREHOUSE', label: '仓库建议数据', weight: itemWeight, volume: itemVolume },
|
||||
]
|
||||
})
|
||||
|
||||
function feeStatusInfo(status?: string) {
|
||||
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||
@@ -177,6 +197,38 @@ function openAdjustment() {
|
||||
adjustOpen.value = true
|
||||
}
|
||||
|
||||
async function openTrial() {
|
||||
trialLoading.value = true
|
||||
try { measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value); trial.weightSource = 'ACTUAL'; trial.volumeSource = 'ACTUAL'; trialOpen.value = true }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '测量数据加载失败,无法试算').message) }
|
||||
finally { trialLoading.value = false }
|
||||
}
|
||||
|
||||
async function submitTrial() {
|
||||
const weight = sourceOptions.value.find((item) => item.value === trial.weightSource)
|
||||
const volume = sourceOptions.value.find((item) => item.value === trial.volumeSource)
|
||||
if (!weight?.weight || !volume?.volume) { ElMessage.warning('所选重量或体积没有有效数据'); return }
|
||||
trialLoading.value = true
|
||||
try {
|
||||
const result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value, { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate'))
|
||||
snapshot.value = result; trialOpen.value = false; emit('changed', result); ElMessage.success('费用试算已更新')
|
||||
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '费用试算失败') }
|
||||
finally { trialLoading.value = false }
|
||||
}
|
||||
|
||||
async function confirmAndPush() {
|
||||
if (!snapshot.value || feeAction.value) return
|
||||
try { await ElMessageBox.confirm('将确认当前试算结果并立即推送给客户,是否继续?', '确认推送费用', { type: 'warning', confirmButtonText: '确认推送' }) } catch { return }
|
||||
feeAction.value = 'confirm'
|
||||
try {
|
||||
await confirmInboundFee(customerContextId.value, normalizedInboundId.value, createFeeActionIdempotencyKey('confirm'))
|
||||
feeAction.value = 'send'
|
||||
const result = await sendInboundFee(customerContextId.value, normalizedInboundId.value, createFeeActionIdempotencyKey('send'))
|
||||
snapshot.value = result; emit('changed', result); ElMessage.success('费用已确认并推送给客户')
|
||||
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '确认推送费用失败'); await loadFee() }
|
||||
finally { feeAction.value = null }
|
||||
}
|
||||
|
||||
async function transitionFee(action: 'confirm' | 'send') {
|
||||
if (!snapshot.value || feeAction.value) return
|
||||
const confirming = action === 'confirm'
|
||||
@@ -299,6 +351,13 @@ defineExpose({ reload: loadFee })
|
||||
@click="loadFee()"
|
||||
><RefreshCw :size="16" /></el-button>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
v-if="canCalculate"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="trialLoading"
|
||||
@click="openTrial"
|
||||
><CircleDollarSign :size="16" />费用试算</el-button>
|
||||
<el-button
|
||||
v-if="canAdjust && adjustmentEligible"
|
||||
data-testid="adjust-fee-button"
|
||||
@@ -313,6 +372,13 @@ defineExpose({ reload: loadFee })
|
||||
:disabled="Boolean(feeAction)"
|
||||
@click="transitionFee('confirm')"
|
||||
><Check :size="16" />确认费用</el-button>
|
||||
<el-button
|
||||
v-if="canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
|
||||
type="primary"
|
||||
:loading="Boolean(feeAction)"
|
||||
:disabled="Boolean(feeAction)"
|
||||
@click="confirmAndPush"
|
||||
><Send :size="16" />确认推送费用</el-button>
|
||||
<el-button
|
||||
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
|
||||
data-testid="send-fee-button"
|
||||
@@ -324,6 +390,14 @@ defineExpose({ reload: loadFee })
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AppDialog v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
|
||||
<el-alert title="重量和体积各选择一项;默认选择实际测量数据。" type="info" :closable="false" show-icon />
|
||||
<div class="trial-source-grid">
|
||||
<section><h4>计费重量(单选)</h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
|
||||
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
<template #title>当前账号没有查看入库费用的权限</template>
|
||||
<p>需要权限:firstleg:fba-pricing:view</p>
|
||||
@@ -539,6 +613,7 @@ defineExpose({ reload: loadFee })
|
||||
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total strong { justify-self: center; font-size: 18px; white-space: nowrap; }
|
||||
.adjustment-total small { color: var(--cc-muted); font-size: 11px; text-align: right; }
|
||||
.trial-source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 16px; }.trial-source-grid section { padding: 14px; border: 1px solid var(--cc-border); border-radius: 8px; }.trial-source-grid h4 { margin: 0 0 12px; }.trial-source-grid :deep(.el-radio-group) { display: grid; gap: 10px; }.trial-source-grid :deep(.el-radio) { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; height: auto; margin: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; }.trial-source-grid :deep(.el-radio__label) { display: flex; min-width: 0; justify-content: space-between; gap: 10px; }.trial-source-grid strong { color: var(--cc-text); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -15,6 +15,14 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: { selectedWeightKg: number; selectedVolumeCbm: number; weightSource: string; volumeSource: string }, idempotencyKey: string) {
|
||||
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/calculate`, payload,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
async function transitionInboundFee(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
@@ -55,7 +63,7 @@ export function createFeeAdjustmentIdempotencyKey() {
|
||||
return createFeeActionIdempotencyKey('adjust')
|
||||
}
|
||||
|
||||
export function createFeeActionIdempotencyKey(action: 'adjust' | 'confirm' | 'send') {
|
||||
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') {
|
||||
const uuid = globalThis.crypto?.randomUUID?.()
|
||||
if (uuid) return `fee-${action}-${uuid}`
|
||||
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
export { getInboundMeasurement } from './measurement/api'
|
||||
export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api'
|
||||
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
|
||||
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
|
||||
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
|
||||
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
|
||||
export {
|
||||
calculateInboundFee,
|
||||
confirmInboundFee,
|
||||
createFeeActionIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
sendInboundFee,
|
||||
} from './fee/api'
|
||||
export type { InboundFeeSnapshot } from './fee/types'
|
||||
export { downloadWeightImage } from './weight/api/weightOcr'
|
||||
export type { InboundMeasurementDetail } from './measurement/types'
|
||||
|
||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
||||
customerId: string
|
||||
inboundId: string
|
||||
inboundCode?: string | null
|
||||
openWarehouseSuggestion?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -84,6 +85,7 @@ function handleSubmitted() {
|
||||
ref="measurementPanelRef"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
:open-warehouse-suggestion="openWarehouseSuggestion"
|
||||
@updated="handleUpdated"
|
||||
@saved="handleUpdated"
|
||||
@submitted="handleSubmitted"
|
||||
|
||||
@@ -321,6 +321,22 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(payload).not.toHaveProperty('originalVolumeCbm')
|
||||
})
|
||||
|
||||
it('saves all three warehouse outer dimensions against the measured sequence', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
expect(wrapper.text()).toContain('001')
|
||||
await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
|
||||
await wrapper.find('input[aria-label="仓库修改外箱宽"]').setValue('45')
|
||||
await wrapper.find('input[aria-label="仓库修改外箱高"]').setValue('35')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
|
||||
expect(payload?.items[0]).toMatchObject({
|
||||
itemNo: '001', outerLengthCm: 55, outerWidthCm: 45, outerHeightCm: 35,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the server weight summary without discarding unsaved measurement edits', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const countInput = wrapper.find('.measurement-count-editor input')
|
||||
|
||||
@@ -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)) > 1e-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)) > 1e-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 10px; color: #344054; font-size: 12px; font-weight: 700; }
|
||||
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.measurement-section__heading > div { min-width: 0; }
|
||||
.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.measurement-section-actions :deep(.el-button) { margin-left: 0; }
|
||||
.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.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, 1fr)); margin: 4px 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: 12px; border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: 11px; }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; }
|
||||
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
|
||||
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
|
||||
@@ -818,6 +956,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
|
||||
.measurement-table :deep(.el-table__cell) { padding: 7px 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: 10px; margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||
.measurement-box-code { font-weight: 650; }
|
||||
.measurement-count-editor { width: 104px !important; }
|
||||
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 20px; margin: 0; padding: 4px 18px 8px 62px; }
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface InboundMeasurementItem {
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
chargeRuleId?: MeasurementIdentifier | null
|
||||
coefficientMode?: 'ADD' | 'MULTIPLY' | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
@@ -114,21 +116,24 @@ export interface InboundMeasurementDetail {
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementItemRequest {
|
||||
boxCode: string | null
|
||||
inboundDetailId?: MeasurementIdentifier
|
||||
boxCode?: string | null
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitWeightKg: number
|
||||
unitWeightKg?: number
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
machineNo: string | null
|
||||
measuredAt: string | null
|
||||
machineNo?: string | null
|
||||
measuredAt?: string | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
chargeRuleId?: MeasurementIdentifier | null
|
||||
coefficientMode?: 'ADD' | 'MULTIPLY' | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user