Files
admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue
T

3144 lines
196 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
downloadPlatformProductImage,
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
updatePlatformUnifiedOrderRemarks,
listPlatformUnifiedOrders,
updatePlatformUnifiedOrderPlanning,
unifiedOrderStatusOptions,
type UnifiedOrderDetailResource,
type UnifiedOrderCoLoadGroupSummary,
type UnifiedOrderMainStatus,
type UnifiedOrderResource,
type UnifiedOrderBatchApprovalPreviewItem,
type UnifiedOrderBatchReviewItem,
importPlatformLegacyOrders,
createLegacyOrderImportIdempotencyKey,
type LegacyOrderImportResult,
} from '@/modules/unified-orders/api/unifiedOrders'
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue'
import OrderDeclarationEditorDialog from '@/modules/unified-orders/components/OrderDeclarationEditorDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation, type LoadingAssociationSummary, type OverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import {
firstLegBusinessTypeLabel,
firstLegContainerModeLabel,
firstLegDestinationTypeLabel,
firstLegPickupMethodLabel,
firstLegTaxModeLabel,
firstLegTradeTermLabel,
firstLegTransportModeLabel,
} from '@/shared/utils/firstLegBusinessLabels'
import { SmartLoadingDialog } from '@/modules/container-loading'
import {
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
unifiedOrderReviewStatusLabel,
unifiedOrderStatusLabel,
} from '@/modules/unified-orders/status'
import {
adjustPublishedInboundFee,
downloadWeightImage,
calculateInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
getLatestInboundFee,
getInboundMeasurement,
getPlatformInboundHold,
downloadInboundCartonLabelsBatch,
downloadPlatformInboundHoldEvidence,
holdPlatformInbound,
releasePlatformInbound,
uploadPlatformInboundHoldEvidence,
updateMeasurementCalculationStrategy,
updateMeasurementOuterDimensions,
InboundFeePanel,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
DropshipPalletPrintPanel,
sendInboundFee,
type InboundFeeSnapshot,
type InboundMeasurementDetail,
type InboundHoldDetail,
} from '@/modules/inbounds'
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config'
type FilterModel = QueryModel & {
tenantId: string
receivingWarehouseId: string
destinationWarehouseId: string
inboundCode: string
expectedArrivalDate: string
feeReviewStatus: string
channelName: string
containerNo: string
cabinetNo: string
sailingDate: string
loaded: string
fullContainer: string
measurementReviewStatus: string
fbaCode: string
createdRange: string[]
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const legacyImportOpen = ref(false)
const legacyImportFile = ref<File | null>(null)
const legacyImportIdempotencyKey = ref('')
const legacyImportSaving = ref(false)
const legacyImportResult = ref<LegacyOrderImportResult | null>(null)
const legacyImportError = ref<ApiErrorDetails | null>(null)
type PlanningDraft = {
expectedArrivalAt: string
estimatedVolumeCbm: number | null
savedExpectedArrivalAt: string
savedEstimatedVolumeCbm: number | null
saving: boolean
}
const planningDrafts = reactive<Record<string, PlanningDraft | undefined>>({})
const customerLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const warehouseLoading = ref(false)
const warehouseLoadError = ref<ApiErrorDetails | null>(null)
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const declarationEditorOpen = ref(false)
const activeDetailTab = ref('basic')
const automaticFee = ref<AutomaticFeeSummary | null>(null)
const feePanelRevision = ref(0)
const otherFeeSummary = ref<OtherFeeSummary>({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 })
const customerFeeTotal = computed(() => (
(automaticFee.value?.feeStatus === 'SENT' ? Number(automaticFee.value.totalAmount || 0) : 0)
+ otherFeeSummary.value.approvedReceivable
))
function feeMoney(value: number) {
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: otherFeeSummary.value.currency || 'CNY' }).format(value)
}
function feeConfirmMoney(value: number, currency = 'CNY') {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
}).format(value)
}
type OtherFeeSummary = { currency: string; approvedReceivable: number; approvedPayable: number }
type AutomaticFeeSummary = { feeStatus: string; totalAmount: number }
const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
const batchApprovalSaving = ref(false)
const batchApprovalLoading = ref(false)
const batchApprovalOpen = ref(false)
const batchApprovalError = ref<ApiErrorDetails | null>(null)
const batchApprovalPreviewRows = ref<UnifiedOrderBatchApprovalPreviewItem[]>([])
const batchApprovalSelectedRows = ref<UnifiedOrderBatchApprovalPreviewItem[]>([])
const batchApprovalTableRef = ref<{ toggleRowSelection: (row: UnifiedOrderBatchApprovalPreviewItem, selected?: boolean) => void }>()
const exportSubmitting = ref(false)
const printingCartonLabels = ref(false)
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
const selectedCartonLabelOrders = computed(() => selectedOrders.value.filter((row) => row.inboundId != null))
const coLoadCreateOpen = ref(false)
const coLoadDetailOpen = ref(false)
const coLoadDetailInboundCode = ref('')
const coLoadDetailSummary = ref<CoLoadBusinessSummary | null>(null)
const coLoadDetailLoading = ref(false)
const coLoadDetailError = ref<ApiErrorDetails | null>(null)
const coLoadCancelError = ref<ApiErrorDetails | null>(null)
const coLoadCancelSaving = ref(false)
const coLoadSaving = ref(false)
const coLoadError = ref<ApiErrorDetails | null>(null)
const approvalOrder = ref<UnifiedOrderResource | null>(null)
const noteOpen = ref(false)
const noteSaving = ref(false)
const noteForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
const noteOrder = ref<UnifiedOrderResource | null>(null)
const holdDetail = ref<InboundHoldDetail | null>(null)
const holdLoading = ref(false)
const holdError = ref<ApiErrorDetails | null>(null)
const holdLoadedKey = ref('')
const holdDialogOpen = ref(false)
const holdSaving = ref(false)
const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD')
const holdReason = ref('')
const holdEvidenceFile = ref<File | null>(null)
const holdEvidenceUploadKey = ref(0)
const holdUploadedEvidenceFileId = ref('')
const holdEvidenceError = ref<ApiErrorDetails | null>(null)
const holdDownloadingFileId = ref('')
const relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null)
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
const measurementLoading = ref(false)
const measurementError = ref<ApiErrorDetails | null>(null)
const measurementLoadedKey = ref('')
const measurementChargeRules = ref<CustomerChargeRule[]>([])
const measurementChargeRulesLoading = ref(false)
const measurementChargeRulesLoadedKey = ref('')
const measurementDimensionConfig = ref<CustomerDimensionConfig | null>(null)
const measurementDimensionConfigLoading = ref(false)
const measurementDimensionConfigLoadedKey = ref('')
const measurementStrategySavingId = ref('')
const measurementOuterSavingId = ref('')
const measurementPreviewOpen = ref(false)
const measurementPreviewLoading = ref(false)
const measurementPreviewError = ref<ApiErrorDetails | null>(null)
const measurementPreviewUrl = ref('')
const measurementPreviewTitle = ref('测量图片')
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
type MeasurementSection = keyof typeof measurementCollapsed
type MeasurementCalculationMode = 'ACTUAL' | 'ADD' | 'MULTIPLY'
const measurementSectionModes = reactive<Record<MeasurementSection, MeasurementCalculationMode>>({
customer: 'ACTUAL', outer: 'ACTUAL', actual: 'ACTUAL',
})
const measurementDraftEditableStatuses = new Set([
'PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED',
])
const measurementDraftEditable = computed(() => (
can('platform:firstleg:inbound:measure:edit')
&& measurementDraftEditableStatuses.has(measurementDetail.value?.processStatus || '')
))
const measurementStrategyEditable = computed(() => (
can('platform:firstleg:inbound:measure:edit')
&& Boolean(measurementDetail.value?.measurementId)
&& measurementDetail.value?.version != null
&& !measurementDetail.value.customerFeeConfirmed
))
const measurementStrategyLockedHint = computed(() => {
if (!can('platform:firstleg:inbound:measure:edit')) return '当前账号缺少测量编辑权限'
if (!measurementDetail.value?.measurementId) return '当前没有可修改的测量批次'
if (measurementDetail.value.customerFeeConfirmed) return '费用已由客户确认,测量数据已锁定'
return '当前测量数据不可修改'
})
const cargoImagePreviewUrls = new Set<string>()
const measurementDialogOpen = ref(false)
const openWarehouseSuggestionOnMeasurementLoad = ref(false)
const confirmFeeSending = ref(false)
const feeConfirmOpen = ref(false)
const feeConfirmWeight = ref<number | null>(null)
const feeConfirmVolume = ref<number | null>(null)
const feeConfirmReferenceLoading = ref(false)
const feeConfirmReference = ref<InboundFeeSnapshot | null>(null)
const feeConfirmPriceMissing = ref(false)
const feeConfirmUnitPrice = ref<number | null>(null)
const feeConfirmPriceReason = ref('')
type FeeConfirmAttempt = {
fingerprint: string
calculateKey: string
manualKey: string
adjustKey: string
confirmKey: string
sendKey: string
}
let feeConfirmAttempt: FeeConfirmAttempt | null = null
const feeConfirmPriceChanged = computed(() => {
if (feeConfirmPriceMissing.value) return true
const reference = Number(feeConfirmReference.value?.unitPrice)
const current = Number(feeConfirmUnitPrice.value)
return reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
})
const feeConfirmPriceUnit = computed(() => {
const snapshot = feeConfirmReference.value
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : 'CNY / 计费单位'
})
const feeConfirmEstimatedTotal = computed(() => {
const snapshot = feeConfirmReference.value
const unitPrice = Number(feeConfirmUnitPrice.value)
if (!snapshot || !(unitPrice >= 0)) return null
return Number(snapshot.chargeableQuantity || 0) * unitPrice + Number(snapshot.additionalAmount || 0)
})
const feeConfirmRows = computed(() => {
const summary = detail.value?.summary
const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)
const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)
const actualTotals = measurementTotals(actualMeasurementRows.value)
const actualWeight = Number(actualTotals.gross) > 0
? actualTotals.gross
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
const actualVolume = Number(actualTotals.volume) > 0 ? actualTotals.volume : null
const customerTotals = measurementTotals(customerMeasurementRows.value)
return [
{ source: '客户下单数据', count: customerTotals.count, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm },
{ source: '外箱规数据', count: measurementTotals(outerMeasurementRows.value).count, weight: measurementTotals(outerMeasurementRows.value).gross, volume: measurementTotals(outerMeasurementRows.value).volume },
{ source: '实际测量数据', count: actualTotals.count, weight: actualWeight, volume: actualVolume },
]
})
const smartLoadingOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null)
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
const overseasRelationship = ref<OverseasInboundCodeRelationship | null>(null)
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
const overseasRelationshipError = ref<ApiErrorDetails | null>(null)
const subsequentFulfillmentLoading = ref(false)
const resolvedDetailTarget = ref<{ orderId: string; tenantId: string } | null>(null)
let detailLoadingKey = ''
let detailRequestSerial = 0
let detailAssociationRequestSerial = 0
let measurementRequestSerial = 0
let measurementPreviewRequestSerial = 0
let subsequentFulfillmentRequestSerial = 0
let holdRequestSerial = 0
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const businessInboundCode = computed(() => route.name === 'unified-order-detail-by-inbound-code' && typeof route.params.inboundCode === 'string'
? route.params.inboundCode.trim()
: '')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : resolvedDetailTarget.value?.orderId || '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : resolvedDetailTarget.value?.tenantId || '')
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
const isDetailRoute = computed(() => Boolean(orderId.value || businessInboundCode.value))
const requestedDetailTab = computed(() => route.query.tab === 'measurement' ? 'measurement' : 'basic')
const fromMeasurementReview = computed(() => route.query.from === 'measurement-review')
function normalizedIdentifier(value: unknown) {
const identifier = String(value ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
}
const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))
const currentHoldFlag = computed(() => holdDetail.value ? holdDetail.value.holdFlag : detail.value?.summary.holdFlag ?? null)
const currentHoldReason = computed(() => holdDetail.value ? holdDetail.value.currentHoldReason : detail.value?.summary.currentHoldReason ?? null)
const canHoldInbound = computed(() => Boolean(
normalizedInboundId.value
&& can('platform:firstleg:inbound:hold')
&& holdDetail.value?.canHold,
))
const canReleaseInbound = computed(() => Boolean(
normalizedInboundId.value
&& can('platform:firstleg:inbound:hold')
&& holdDetail.value?.canRelease,
))
const isDropshipOrder = computed(() => detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const detailTabs = computed(() => [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'cargo', label: '货品信息', icon: '📦' },
{ key: 'fees', label: '费用信息', icon: '💰' },
{ key: 'history', label: '状态历史', icon: '📝' },
{ key: 'measurement', label: '测量数据', icon: '📐' },
...(isDropshipOrder.value ? [{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }] : []),
{ key: 'other', label: '其他', icon: '🗂️' },
{ key: 'fulfillment', label: '拆分履约', icon: '🧩' },
])
const customerMeasurementColumns = [
{ key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' },
{ key: 'heightCm', label: '箱规高(cm)' }, { key: 'packageCount', label: '箱数' }, { key: 'unitWeightKg', label: '每箱毛重' },
{ key: 'unitVolumeCbm', label: '每箱体积' }, { key: 'remark', label: '备注' },
]
const measuredColumns = [
...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' },
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' },
{ key: 'remark', label: '备注' },
]
const outerColumns = measuredColumns
const actualMeasuredColumns = measuredColumns.filter((column) => column.key !== 'itemNo')
function sortedDimensions(row: Record<string, unknown>) {
const values = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)]
return values.every((value) => Number.isFinite(value) && value > 0)
? values.sort((left, right) => right - left)
: null
}
function matchingDimensionRule(row: Record<string, unknown>): CustomerDimensionRule | null {
const measured = sortedDimensions(row)
if (!measured) return null
const rules = [...(measurementDimensionConfig.value?.rules || [])]
.sort((left, right) => (
left.innerLengthCm * left.innerWidthCm * left.innerHeightCm
- right.innerLengthCm * right.innerWidthCm * right.innerHeightCm
))
if (!rules.length) return null
return rules.find((rule) => {
const inner = [rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm].sort((left, right) => right - left)
return measured.every((value, index) => value <= inner[index])
}) || rules[rules.length - 1]
}
function addCoefficientLabel(row?: Record<string, unknown>) {
const rows = row ? [row] : (measurementDetail.value?.items || []) as unknown as Record<string, unknown>[]
const values = [...new Set(rows.map((item) => matchingDimensionRule(item)?.edgeAdditionCm).filter((value) => value != null))]
if (values.length === 1) return `原尺寸 + ${Number(values[0]) * 2}cm(单边 +${values[0]}cm)`
if (measurementDimensionConfigLoading.value) return '原尺寸 + 加载中…'
return values.length > 1 ? '原尺寸 + 按箱规匹配' : '原尺寸 + 未匹配箱规'
}
function multiplyCoefficientLabel() {
const rule = measurementChargeRules.value.find((item) => item.defaultRule && item.status === 'ACTIVE')
if (rule) return `原尺寸 × ${rule.lengthFactor}`
return measurementChargeRulesLoading.value ? '原尺寸 × 加载中…' : '原尺寸 × 未启用系数'
}
function matchedSpecificationCoefficient(rule: CustomerChargeRule, row: Record<string, unknown>) {
const tiers = [...(rule.remark || '').matchAll(
/(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+(?:\.\d+)?);系数:(\d+(?:\.\d+)?)(?:;重量:(\d+(?:\.\d+)?))?/g,
)].map((match) => ({
dimensions: [Number(match[2]), Number(match[3]), Number(match[4])].sort((left, right) => right - left),
coefficient: Number(match[5]),
maxWeightKg: match[6] == null ? null : Number(match[6]),
})).filter((tier) => tier.dimensions.every((value) => Number.isFinite(value) && value > 0)
&& Number.isFinite(tier.coefficient) && tier.coefficient > 0
&& (tier.maxWeightKg == null || (Number.isFinite(tier.maxWeightKg) && tier.maxWeightKg > 0)))
.sort((left, right) => left.dimensions.reduce((total, value) => total * value, 1)
- right.dimensions.reduce((total, value) => total * value, 1))
if (!tiers.length) return Number(rule.lengthFactor || 1)
const measured = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)]
.sort((left, right) => right - left)
const unitWeight = Number(row.unitWeightKg)
const matched = tiers.find((tier) => measured.every((value, index) => value <= tier.dimensions[index])
&& (tier.maxWeightKg == null || !Number.isFinite(unitWeight) || unitWeight <= tier.maxWeightKg))
return (matched || tiers[tiers.length - 1]).coefficient
}
// 系数模式可使用客户名下任一启用规则;默认规则仅用于优先选择,不作为唯一条件。
const activeMeasurementChargeRule = computed(() => measurementChargeRules.value
.find((rule) => rule.defaultRule && rule.status === 'ACTIVE')
|| measurementChargeRules.value.find((rule) => rule.status === 'ACTIVE')
|| null)
const boundOuterMeasurementChargeRule = computed(() => {
const boundId = outerMeasurementRows.value
.map((row) => String(row.chargeRuleId || ''))
.find((id) => id)
return boundId
? measurementChargeRules.value.find((rule) => rule.id === boundId && rule.status === 'ACTIVE') || null
: null
})
const activeMeasurementChargeRules = computed(() => measurementChargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
const multiplyModeDisabled = computed(() => !measurementChargeRulesLoading.value && !activeMeasurementChargeRule.value)
function roundedUp(value: number, digits: number) {
const factor = 10 ** digits
return Math.ceil((value - Number.EPSILON) * factor) / factor
}
function calculatedMeasurementRow(
source: Record<string, unknown>,
mode: MeasurementCalculationMode,
customerSource = false,
useWarehouseOuterOverrides = true,
): Record<string, unknown> {
const outerOverrides = [source.outerLengthCm, source.outerWidthCm, source.outerHeightCm]
const hasOuterOverride = useWarehouseOuterOverrides && !customerSource
&& outerOverrides.some((value) => value != null && value !== '')
const rawDimensions = [source.lengthCm, source.widthCm, source.heightCm].map((value, index) => {
const override = outerOverrides[index]
return Number(hasOuterOverride && override != null && override !== '' ? override : value)
})
const calculationSource = {
...source,
lengthCm: rawDimensions[0],
widthCm: rawDimensions[1],
heightCm: rawDimensions[2],
}
const rule = matchingDimensionRule(calculationSource)
if (!rawDimensions.every((value) => Number.isFinite(value) && value > 0)) return { ...source }
const matchedDimensions = mode === 'ADD' && rule
? rawDimensions.map((value) => value + rule.edgeAdditionCm * 2)
: rawDimensions
const boundChargeRuleId = String(source.chargeRuleId || '')
const chargeRule = measurementChargeRules.value
.find((item) => item.id === boundChargeRuleId && item.status === 'ACTIVE')
|| activeMeasurementChargeRule.value
const specificationCoefficient = mode === 'MULTIPLY' && chargeRule
? matchedSpecificationCoefficient(chargeRule, calculationSource)
: 1
const strategyDimensions = matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))
const [lengthCm, widthCm, heightCm] = strategyDimensions
const packageCount = Number(source.packageCount)
const unitVolumeCbm = mode === 'ADD' && rule && !hasOuterOverride
? Math.round((lengthCm * widthCm * heightCm / 1_000_000) * 1000) / 1000
: roundedUp(lengthCm * widthCm * heightCm / 1_000_000, 4)
const totalVolumeCbm = Number.isFinite(packageCount) ? roundedUp(unitVolumeCbm * packageCount, 4) : null
const fallbackMeasuredUnitWeight = !customerSource && Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
&& Number(measurementDetail.value?.actualPackageCount) > 0
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
: null
const sourceUnitWeight = customerSource
? Number(source.grossWeightKg) / packageCount
: Number(source.unitWeightKg) > 0 ? Number(source.unitWeightKg) : fallbackMeasuredUnitWeight
const unitWeightKg = Number.isFinite(sourceUnitWeight) ? sourceUnitWeight : null
const totalWeightKg = unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null
return {
...source,
lengthCm, widthCm, heightCm, unitWeightKg, unitVolumeCbm, totalWeightKg, totalVolumeCbm,
girthCm: roundedUp(lengthCm + 2 * (widthCm + heightCm), 3),
volumeWeightKg: totalVolumeCbm == null ? null : roundedUp(totalVolumeCbm * 167, 3),
remark: customerSource ? source.cargoRemark : source.remark,
}
}
const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || []).map((row) => (
calculatedMeasurementRow(row, measurementSectionModes.customer, true)
)))
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => {
const source = row as unknown as Record<string, unknown>
const recalculated = calculatedMeasurementRow(source, measurementSectionModes.outer)
const savedDimensions = [row.calculatedOuterLengthCm, row.calculatedOuterWidthCm, row.calculatedOuterHeightCm]
const recalculatedDimensions = [recalculated.lengthCm, recalculated.widthCm, recalculated.heightCm]
const warehouseOuterOverride = String(row.calculationRuleVersion || '').startsWith('WAREHOUSE_OUTER_OVERRIDE_')
if (savedDimensions.every((value) => Number.isFinite(Number(value)) && Number(value) > 0)
&& (warehouseOuterOverride
|| savedDimensions.every((value, index) => Math.abs(Number(value) - Number(recalculatedDimensions[index])) < 0.001))
&& measurementCalculationModeForRow(source) === measurementSectionModes.outer) {
const packageCount = Number(row.packageCount)
const unitWeightKg = Number(row.unitWeightKg) > 0
? Number(row.unitWeightKg)
: Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
&& Number(measurementDetail.value?.actualPackageCount) > 0
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
: null
return {
...source,
lengthCm: Number(savedDimensions[0]),
widthCm: Number(savedDimensions[1]),
heightCm: Number(savedDimensions[2]),
unitWeightKg,
totalWeightKg: unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null,
}
}
return recalculated
}))
const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => (
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual, false, false)
)))
const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0)
function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) {
if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) {
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
return
}
measurementSectionModes[section] = mode
}
async function selectAndSaveOuterMeasurementMode(mode: MeasurementCalculationMode, ruleId?: string) {
if (measurementStrategySavingId.value) return
if (!measurementStrategyEditable.value) {
ElMessage.warning(measurementStrategyLockedHint.value)
return
}
const previousMode = measurementSectionModes.outer
if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) {
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
return
}
measurementSectionModes.outer = mode
const saved = await updateMeasurementCalculationMode(null, mode, ruleId)
if (!saved) measurementSectionModes.outer = previousMode
}
async function waitForSavedMeasurementVersion(tenantId: string, inboundId: string, savedVersion: number) {
for (let attempt = 0; attempt < 6; attempt += 1) {
try {
const refreshed = await getInboundMeasurement(tenantId, inboundId)
if (Number(refreshed.version) >= savedVersion) return refreshed
} catch {
// 保存结果已由 PUT 确认,短暂的详情读取失败只影响同步等待。
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return null
}
const statusTabs = computed(() => [
{ value: '' as const, label: '全部' },
...unifiedOrderStatusOptions,
])
const warehouseOptions = computed(() => warehouses.value.map((warehouse) => ({
value: String(warehouse.id),
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
})))
const receivingWarehouseOptions = computed(() => warehouses.value
.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
.map((warehouse) => ({ value: String(warehouse.id), label: `${warehouse.warehouseName}(${warehouse.warehouseCode})` })))
const primaryQueryFields = computed<QueryField[]>(() => [
{ key: 'receivingWarehouseId', type: 'select', width: 220, label: '定位仓库', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择定位仓库', options: receivingWarehouseOptions.value, props: { 'aria-label': '定位仓库', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
{ key: 'destinationWarehouseId', type: 'select', width: 220, label: '交货仓', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '请输入入仓码', props: { 'aria-label': '入仓码' } },
])
const advancedQueryFields = computed<QueryField[]>(() => [
{ key: 'expectedArrivalDate', type: 'date', width: 190, label: '预计到达时间', placeholder: '请选择预计到达日期', props: { 'aria-label': '预计到达时间' } },
{ key: 'feeReviewStatus', type: 'select', width: 190, label: '费用审核状态', placeholder: '请选择费用审核状态', options: [
{ value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' },
{ value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
], props: { 'aria-label': '费用审核状态' } },
{
key: 'tenantId',
type: 'select',
width: 220,
label: '公司',
placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '请选择公司',
options: customers.value.map((customer) => ({
value: String(customer.id),
label: customer.legalName?.trim() || customer.tenantName,
})),
props: { 'aria-label': '公司', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) },
},
{ key: 'containerNo', type: 'input', width: 210, label: '真实集装箱号', placeholder: '请输入真实集装箱号', props: { 'aria-label': '真实集装箱号' } },
{ key: 'cabinetNo', type: 'input', width: 190, label: '内部柜次号', placeholder: '请输入内部柜次号', props: { 'aria-label': '内部柜次号' } },
{ key: 'channelName', type: 'input', width: 210, label: '渠道', placeholder: '请输入渠道名称', props: { 'aria-label': '渠道' } },
{ key: 'sailingDate', type: 'date', width: 190, label: '船期', placeholder: '请选择船期', props: { 'aria-label': '船期' } },
{ key: 'loaded', type: 'select', width: 180, label: '是否已配载', placeholder: '请选择配载状态', options: [{ value: 'true', label: '已配载' }, { value: 'false', label: '未配载' }], props: { 'aria-label': '是否已配载' } },
{ key: 'fullContainer', type: 'select', width: 180, label: '是否整柜', placeholder: '请选择装柜方式', options: [{ value: 'true', label: '整柜' }, { value: 'false', label: '拼箱' }], props: { 'aria-label': '是否整柜' } },
{ key: 'measurementReviewStatus', type: 'select', width: 220, label: '测量数据审核状态', placeholder: '请选择测量审核状态', options: [
{ value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' },
{ value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
], props: { 'aria-label': '测量数据审核状态' } },
{ key: 'fbaCode', type: 'input', width: 220, label: '商品 SKU 编码', placeholder: '请输入商品 SKU 编码', props: { 'aria-label': '商品 SKU 编码' } },
{ key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
])
const allQueryFields = computed(() => [...primaryQueryFields.value, ...advancedQueryFields.value])
const queryFields = computed(() => showAdvancedFilters.value
? allQueryFields.value
: primaryQueryFields.value)
const columns = computed<DataTableColumn[]>(() => [
// 业务表格保持操作列第一;批量选择列仅在有对应权限时紧随其后。
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') || can('platform:order:export') || can('platform:firstleg:inbound:view') ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value && !exportSubmitting.value,
} satisfies DataTableColumn] : []),
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode' },
{ key: 'orderType', label: '订单类型', minWidth: 120, slotName: 'orderType' },
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 120, slotName: 'mainStatus' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'field' },
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 100, slotName: 'field' },
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 100, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 140, slotName: 'channel' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 80, slotName: 'field' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 170, slotName: 'expectedArrival' },
{ key: 'estimatedVolumeCbm', prop: 'estimatedVolumeCbm', label: '预估体积(m³)', minWidth: 120, slotName: 'estimatedVolume' },
{ key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '下单体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'actualVolumeCbm', prop: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', minWidth: 90, slotName: 'field' },
{ key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间', minWidth: 140, slotName: 'field' },
{ key: 'locationCodes', prop: 'locationCodes', label: '库位位置', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 150, slotName: 'measurementReviewStatus' },
{ key: 'actualGrossWeightKg', prop: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 120, slotName: 'field' },
{ key: 'totalNetWeightKg', prop: 'totalNetWeightKg', label: '货物净重(kg)', minWidth: 120, slotName: 'field' },
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
{ key: 'chargeableVolumeCbm', prop: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'expectedShipDate', prop: 'expectedShipDate', label: '预计出运日期', minWidth: 150, slotName: 'field' },
{ key: 'feeReviewStatus', prop: 'feeReviewStatus', label: '费用审核状态', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'skuNos', prop: 'skuNos', label: 'SKU 编码', minWidth: 180, slotName: 'field', defaultVisible: false },
{ key: 'totalQuantity', prop: 'totalQuantity', label: '货物总数量', minWidth: 120, slotName: 'field', defaultVisible: false },
{ key: 'expressCompany', prop: 'expressCompany', label: '快递公司', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'expressTrackingNo', prop: 'expressTrackingNo', label: '快递单号', minWidth: 170, slotName: 'field', defaultVisible: false },
{ key: 'shipmentId', prop: 'shipmentId', label: 'Shipment ID', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'referenceId', prop: 'referenceId', label: 'Reference ID', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'containerNo', prop: 'containerNo', label: '真实集装箱号', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '内部柜次号', minWidth: 150, slotName: 'field', defaultVisible: false },
{ key: 'effectiveSailingAt', prop: 'effectiveSailingAt', label: '开船时间', minWidth: 180, slotName: 'field' },
{ key: 'effectivePortArrivalAt', prop: 'effectivePortArrivalAt', label: '到港时间', minWidth: 180, slotName: 'field' },
{ key: 'customerRemark', prop: 'customerRemark', label: '客户备注', minWidth: 180, slotName: 'field', defaultVisible: false },
{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注', minWidth: 180, slotName: 'field', defaultVisible: false },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 150, slotName: 'field', defaultVisible: false },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, slotName: 'field' },
])
function emptyFilters(): FilterModel {
return {
tenantId: '', receivingWarehouseId: '', destinationWarehouseId: '',
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
fbaCode: '', createdRange: [],
}
}
function cloneFilters(source: FilterModel): FilterModel {
return { ...source, createdRange: [...source.createdRange] }
}
function trimmed(value: string) {
return value.trim() || undefined
}
function optionalBoolean(value: string | null | undefined) {
if (value === 'true') return true
if (value === 'false') return false
return undefined
}
function selectStatus(status: UnifiedOrderMainStatus | '') {
if (selectedStatus.value === status) return
selectedStatus.value = status
if (page.value === 1) void loadOrders()
else page.value = 1
}
function handleTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.value.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.value.length) % statusTabs.value.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.value.length - 1
else return
event.preventDefault()
const tab = statusTabs.value[nextIndex]
if (!tab) return
selectStatus(tab.value)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
function can(permission: string) {
return access.can(permission)
}
function planningDraft(row: UnifiedOrderResource) {
const key = String(row.orderId)
let draft = planningDrafts[key]
if (!draft) {
draft = {
expectedArrivalAt: row.expectedArrivalAt || '',
estimatedVolumeCbm: row.estimatedVolumeCbm ?? null,
savedExpectedArrivalAt: row.expectedArrivalAt || '',
savedEstimatedVolumeCbm: row.estimatedVolumeCbm ?? null,
saving: false,
}
planningDrafts[key] = draft
}
return draft
}
async function savePlanning(row: UnifiedOrderResource) {
const draft = planningDraft(row)
if (!canEditPlanning(row) || draft.saving) return
const estimatedVolumeCbm = draft.estimatedVolumeCbm ?? null
draft.estimatedVolumeCbm = estimatedVolumeCbm
if (!Number.isInteger(row.orderVersion)) {
ElMessage.error('订单版本缺失,请刷新列表后重试')
return
}
if (estimatedVolumeCbm !== null && (!Number.isFinite(estimatedVolumeCbm) || estimatedVolumeCbm <= 0)) {
ElMessage.warning('预估体积必须大于 0,或清空该值')
return
}
if (draft.expectedArrivalAt === draft.savedExpectedArrivalAt
&& estimatedVolumeCbm === draft.savedEstimatedVolumeCbm) return
draft.saving = true
try {
const result = await updatePlatformUnifiedOrderPlanning(String(row.tenantId), String(row.orderId), {
expectedVersion: row.orderVersion!,
expectedArrivalAt: draft.expectedArrivalAt || null,
estimatedVolumeCbm,
})
row.orderVersion = result.version
row.expectedArrivalAt = draft.expectedArrivalAt || null
row.estimatedVolumeCbm = estimatedVolumeCbm
draft.savedExpectedArrivalAt = draft.expectedArrivalAt
draft.savedEstimatedVolumeCbm = estimatedVolumeCbm
ElMessage.success('订单运营预估已更新')
} catch (error) {
const details = getApiErrorDetails(error, '订单运营预估保存失败')
const evidence = [
details.code ? `错误码 ${details.code}` : '',
details.requestId ? `Request ID ${details.requestId}` : '',
details.traceId ? `Trace ID ${details.traceId}` : '',
].filter(Boolean).join(';')
ElMessage.error({
message: evidence ? `${details.message}(${evidence})` : details.message,
duration: 6_000,
showClose: true,
})
} finally {
draft.saving = false
}
}
const planningEditableOrderStatuses = new Set(['DRAFT', 'REJECTED', 'SUBMITTED', 'APPROVED'])
function canEditPlanning(row: UnifiedOrderResource) {
return can('platform:order:declaration:edit')
&& planningEditableOrderStatuses.has(row.internalOrderStatus || '')
}
const declarationEditableStatuses = new Set<UnifiedOrderMainStatus>(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT'])
const canEditDeclaration = computed(() => Boolean(
detail.value
&& declarationEditableStatuses.has(detail.value.summary.mainStatus)
&& detail.value.order.order?.version !== null
&& detail.value.order.order?.version !== undefined
&& can('platform:order:declaration:edit'),
))
async function handleDeclarationUpdated() {
activeDetailTab.value = 'basic'
await loadDetail(true)
}
function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || '客户名称待补充'
}
function orderRelationLabel(row: UnifiedOrderResource) {
if (row.relation?.role === 'PARENT') return `父单 · ${row.relation.childCount} 个子单`
if (row.relation?.role === 'CHILD' && row.relation.splitSequence !== null) {
return `子单 · 第 ${row.relation.splitSequence} 次`
}
return '子单'
}
function openOrderRelation(row: UnifiedOrderResource) {
relationOrder.value = row
relationOpen.value = true
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value: number | null | undefined, digits = 2) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function partyValue(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER', key: string) {
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
const value = party?.[key]
return value === null || value === undefined || value === '' ? '—' : String(value)
}
function partyAddress(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER') {
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
if (!party) return '—'
return ['province', 'city', 'district', 'addressLine'].map((key) => party[key]).filter((value) => typeof value === 'string' && value.trim()).join(' ') || '—'
}
function cargoTotal(cargoes: Array<Record<string, unknown>>, key: string) {
const total = cargoes.reduce((sum, cargo) => {
const value = Number(cargo[key])
return Number.isFinite(value) ? sum + value : sum
}, 0)
return total > 0 ? total : null
}
function displayBoolean(value: boolean | null | undefined) {
return value === null || value === undefined ? '—' : value ? '是' : '否'
}
function formatEffectiveTransportTime(value: string | null | undefined, estimated: boolean | null | undefined) {
if (!value) return '—'
return `${estimated === true ? '预计 ' : ''}${formatDate(value)}`
}
function displayField(row: UnifiedOrderResource, property: string | undefined) {
if (!property) return '—'
const source = row as unknown as Record<string, unknown>
const nested: Record<string, string> = {
measurementReviewStatus: 'measurement.reviewStatus', expectedArrivalAt: 'expectedArrivalAt',
containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
effectiveSailingAt: 'transport.effectiveSailingAt', effectivePortArrivalAt: 'transport.effectivePortArrivalAt',
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
expressTrackingNo: 'delivery.trackingNo',
}
const path = nested[property] || property
let value = path.split('.').reduce<unknown>((current, key) => (
current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined
), source)
if ((value === null || value === undefined || value === '') && property === 'internalOrderStatus') value = source.mainStatus
if (property === 'requestedPickupAt' && (row.pickupMethod !== 'PICKUP' || !value)) return '—'
if (property === 'effectiveSailingAt') return formatEffectiveTransportTime(row.transport?.effectiveSailingAt, row.transport?.effectiveSailingAtEstimated)
if (property === 'effectivePortArrivalAt') return formatEffectiveTransportTime(row.transport?.effectivePortArrivalAt, row.transport?.effectivePortArrivalAtEstimated)
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'boolean') return value ? '是' : '否'
if (Array.isArray(value)) return value.length ? (property === 'skuNos' ? value.join('、') : `${value.length} 个文件`) : '—'
if (typeof value === 'number') {
if (['actualVolumeCbm', 'chargeableVolumeCbm'].includes(property)) return formatNumber(value, 3)
return formatNumber(value)
}
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
if (property === 'internalOrderStatus') return orderStatusLabel(String(value))
if (property === 'feeReviewStatus') return feeReviewLabel(String(value))
if (property === 'businessType') return firstLegBusinessTypeLabel(String(value))
if (property === 'pickupMethod') return firstLegPickupMethodLabel(String(value))
if (property === 'taxMode') return firstLegTaxModeLabel(String(value))
if (property === 'destinationType') return firstLegDestinationTypeLabel(String(value))
return String(value)
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusLabel(value, '状态更新')
}
function historyActionLabel(value: string | null | undefined) {
return unifiedOrderHistoryActionLabel(value)
}
function measurementStatusLabel(value: string | null | undefined) {
return unifiedOrderReviewStatusLabel(value, '未录入')
}
function orderStatusLabel(value: string | null | undefined) {
return unifiedOrderStatusLabel(value)
}
function cargoValue(cargo: Record<string, unknown>, key: string) {
const value = cargo[key]
if (key === 'circumferenceCm') {
const length = Number(cargo.lengthCm)
const width = Number(cargo.widthCm)
const height = Number(cargo.heightCm)
return Number.isFinite(length) && Number.isFinite(width) && Number.isFinite(height)
? formatNumber(length + 2 * (width + height))
: '—'
}
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number') return formatNumber(value)
return String(value)
}
function cargoImage(cargo: Record<string, unknown>) {
const value = cargo.imageUrl || cargo.cargoImageUrl || cargo.image
return typeof value === 'string' && value ? value : ''
}
function releaseCargoImagePreviews() {
cargoImagePreviewUrls.forEach((url) => URL.revokeObjectURL(url))
cargoImagePreviewUrls.clear()
}
async function loadCargoImagePreviews(resource: UnifiedOrderDetailResource, serial: number) {
const tenantId = String(resource.summary.tenantId || '')
if (!tenantId) return
const cargoesByFileId = new Map<string, typeof resource.order.cargoes>()
for (const cargo of resource.order?.cargoes ?? []) {
const fileId = cargo.productImageFileId === null || cargo.productImageFileId === undefined
? ''
: String(cargo.productImageFileId)
if (!fileId) continue
const cargoes = cargoesByFileId.get(fileId) ?? []
cargoes.push(cargo)
cargoesByFileId.set(fileId, cargoes)
}
await Promise.all([...cargoesByFileId.entries()].map(async ([fileId, cargoes]) => {
try {
const blob = await downloadPlatformProductImage(tenantId, fileId)
if (serial !== detailRequestSerial || detailRouteKey.value !== `${tenantId}:${orderId.value}`) return
const url = URL.createObjectURL(blob)
cargoes.forEach((cargo) => { cargo.imageUrl = url })
cargoImagePreviewUrls.add(url)
} catch {
// Historical files may have been removed; keep the remaining cargo fields visible.
}
}))
}
function feeReviewLabel(value: string | null | undefined) {
return unifiedOrderReviewStatusLabel(value)
}
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false, outerSource = false) {
if (key === 'coefficientMode') return measurementCalculationLabel(row)
if (outerSource && ['lengthCm', 'widthCm', 'heightCm'].includes(key)) {
const outerValue = row[`outer${key[0].toUpperCase()}${key.slice(1)}`]
return outerValue == null ? '—' : formatNumber(Number(outerValue), 6)
}
if (customerSource && key === 'unitWeightKg') {
const total = Number(row.grossWeightKg)
const count = Number(row.packageCount)
return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 4) : '—'
}
if (key === 'unitWeightKg') {
const value = Number(row[key])
return Number.isFinite(value) ? formatNumber(value, 4) : '—'
}
if (customerSource && key === 'unitVolumeCbm') {
const total = Number(row.volumeCbm)
const count = Number(row.packageCount)
return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 6) : '—'
}
if (customerSource && key === 'remark') return String(row.cargoRemark || '—')
const value = row[key]
if (value === null || value === undefined || value === '') return '—'
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
}
function measurementCalculationLabel(row: Record<string, unknown>) {
if (measurementCalculationModeForRow(row) === 'MULTIPLY') {
return `乘以${measurementChargeRuleName(row.chargeRuleId)}`
}
if (measurementCalculationModeForRow(row) === 'ACTUAL') return '实测原尺寸(不调整)'
return addCoefficientLabel(row).replace('原尺寸 + ', '加')
}
function measurementCalculationModeForRow(row: Record<string, unknown>): MeasurementCalculationMode {
if (row.coefficientMode === 'MULTIPLY') return 'MULTIPLY'
if (row.coefficientMode === 'ADD') return 'ADD'
const itemNo = normalizedIdentifier(row.itemNo)
const measuredItem = measurementDetail.value?.items.find((item) => normalizedIdentifier(item.itemNo) === itemNo)
return measuredItem?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : measuredItem?.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL'
}
async function updateMeasurementCalculationMode(row: Record<string, unknown> | null, mode: MeasurementCalculationMode, selectedRuleId?: string): Promise<boolean> {
const summary = detail.value?.summary
const measurement = measurementDetail.value
if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null || !measurementStrategyEditable.value) return false
const itemId = normalizedIdentifier(row?.id)
const itemNo = normalizedIdentifier(row?.itemNo)
const targetIds = new Set(measurement.items
.filter((item) => !row || (itemId ? String(item.id) === itemId : normalizedIdentifier(item.itemNo) === itemNo))
.map((item) => String(item.id)))
if (!targetIds.size) {
ElMessage.warning('当前数据尚未关联实测明细,暂不能设置计算方式')
return false
}
measurementStrategySavingId.value = row ? (itemId || `item-${itemNo}`) : 'all'
try {
await Promise.all([loadMeasurementChargeRules(), loadMeasurementDimensionConfig()])
const existingRuleId = selectedRuleId || measurement.items
.map((item) => String(item.chargeRuleId || ''))
.find((id) => id)
const activeRule = measurementChargeRules.value.find((rule) => rule.id === existingRuleId && rule.status === 'ACTIVE')
|| activeMeasurementChargeRule.value
if (mode === 'MULTIPLY' && selectedRuleId && !activeRule) {
ElMessage.warning('请选择当前客户已启用的计费规则')
return false
}
const requiresMultiply = measurement.items.some((item) => (
targetIds.has(String(item.id)) ? mode : item.coefficientMode
) === 'MULTIPLY')
if (requiresMultiply && !activeRule) {
ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则')
return false
}
const savedDraft = await updateMeasurementCalculationStrategy(String(summary.tenantId), String(summary.inboundId), {
measurementId: String(measurement.measurementId),
version: measurement.version,
coefficientMode: mode,
chargeRuleId: mode === 'MULTIPLY' ? activeRule!.id : null,
})
// PUT 返回的新版本已经代表事务保存成功。详情查询可能经过读副本并短暂返回旧版本,
// 不能用紧随其后的旧数据否定本次成功保存;先按新版本和本次规则更新本地视图。
measurementDetail.value = {
...measurement,
version: savedDraft.version,
measurementStatus: savedDraft.status,
items: measurement.items.map((item) => targetIds.has(String(item.id)) ? {
...item,
coefficientMode: mode,
chargeRuleId: mode === 'MULTIPLY' ? activeRule!.id : null,
calculatedOuterLengthCm: null,
calculatedOuterWidthCm: null,
calculatedOuterHeightCm: null,
} : item),
}
measurementSectionModes.outer = mode
const refreshed = await waitForSavedMeasurementVersion(
String(summary.tenantId), String(summary.inboundId), savedDraft.version,
)
if (refreshed) measurementDetail.value = refreshed
measurementSectionModes.outer = mode
measurementLoadedKey.value = ''
ElMessage.success(`${row ? '当前数据' : '全部测量数据'}已保存并绑定计算规则,体积已重新计算`)
return true
} catch (error) {
measurementLoadedKey.value = ''
await loadMeasurementDetails()
ElMessage.error(getApiErrorDetails(error, '计算方式更新失败').message)
return false
} finally {
measurementStrategySavingId.value = ''
}
}
async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
const summary = detail.value?.summary
const measurement = measurementDetail.value
if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null
|| !measurementStrategyEditable.value) return
const dimensions = [row.outerLengthCm, row.outerWidthCm, row.outerHeightCm]
.map((value) => value == null || value === '' ? null : Number(value))
if (dimensions.some((value) => value != null && (!Number.isFinite(value) || value <= 0))) {
ElMessage.warning('仓库修改外箱尺寸必须大于 0')
return
}
measurementOuterSavingId.value = String(row.id)
try {
const saved = await updateMeasurementOuterDimensions(
String(summary.tenantId), String(summary.inboundId), String(row.id), {
measurementId: String(measurement.measurementId),
version: measurement.version,
outerLengthCm: dimensions[0],
outerWidthCm: dimensions[1],
outerHeightCm: dimensions[2],
})
measurementDetail.value = {
...measurement,
version: saved.version,
measurementStatus: saved.status,
items: measurement.items.map((item) => String(item.id) === String(row.id) ? {
...item,
outerLengthCm: dimensions[0],
outerWidthCm: dimensions[1],
outerHeightCm: dimensions[2],
calculatedOuterLengthCm: dimensions[0],
calculatedOuterWidthCm: dimensions[1],
calculatedOuterHeightCm: dimensions[2],
} : item),
}
const refreshed = await waitForSavedMeasurementVersion(
String(summary.tenantId), String(summary.inboundId), saved.version,
)
if (refreshed) measurementDetail.value = refreshed
measurementLoadedKey.value = ''
ElMessage.success(`序号 ${row.itemNo || '—'} 的外箱规已更新,相关数值已重新计算`)
} catch (error) {
measurementLoadedKey.value = ''
await loadMeasurementDetails()
ElMessage.error(getApiErrorDetails(error, '仓库外箱尺寸保存失败').message)
} finally {
measurementOuterSavingId.value = ''
}
}
function measurementChargeRuleName(ruleId: unknown) {
const normalizedRuleId = normalizedIdentifier(ruleId)
const selected = normalizedRuleId
? measurementChargeRules.value.find((rule) => rule.id === normalizedRuleId)
: null
const fallback = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE')
|| measurementChargeRules.value.find((rule) => rule.status === 'ACTIVE')
const rule = selected || fallback
if (rule) {
const remark = rule.remark?.trim()
return `系数 ${rule.lengthFactor}${remark ? ` · ${remark}` : ''}${rule.defaultRule ? '(默认)' : ''}`
}
if (measurementChargeRulesLoading.value) return '加载中…'
return normalizedRuleId || '未配置'
}
async function loadMeasurementChargeRules() {
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
const customerId = normalizedIdentifier(detail.value?.summary.customerId) || tenantId
const loadedKey = `${tenantId}:${customerId}`
if (!customerId || measurementChargeRulesLoadedKey.value === loadedKey || measurementChargeRulesLoading.value) return
measurementChargeRulesLoading.value = true
try {
measurementChargeRules.value = await listCustomerChargeRules(customerId, tenantId)
measurementChargeRulesLoadedKey.value = loadedKey
} catch {
measurementChargeRules.value = []
} finally {
measurementChargeRulesLoading.value = false
}
}
async function loadMeasurementDimensionConfig(force = false) {
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!tenantId || (!force && measurementDimensionConfigLoadedKey.value === tenantId) || measurementDimensionConfigLoading.value) return
measurementDimensionConfigLoading.value = true
try {
measurementDimensionConfig.value = await getCustomerDimensionConfig(tenantId)
measurementDimensionConfigLoadedKey.value = tenantId
} catch {
measurementDimensionConfig.value = null
} finally {
measurementDimensionConfigLoading.value = false
}
}
interface MeasurementTotals {
count: number
gross: number
volume: number
volumetric: number
}
function measurementTotals(rows: Record<string, unknown>[], customerSource = false) {
const values = rows.reduce<MeasurementTotals>((result, row) => {
const count = Number(row.packageCount)
const gross = Number(customerSource ? row.grossWeightKg : row.totalWeightKg)
const volume = Number(customerSource ? row.volumeCbm : row.totalVolumeCbm)
const volumetric = Number(row.volumeWeightKg)
if (Number.isFinite(count)) result.count += count
if (Number.isFinite(gross)) result.gross += gross
if (Number.isFinite(volume)) result.volume += volume
if (Number.isFinite(volumetric)) result.volumetric += volumetric
return result
}, { count: 0, gross: 0, volume: 0, volumetric: 0 })
return {
count: values.count || null, gross: values.gross || null, volume: values.volume || null,
volumetric: values.volumetric || null, weightVolume: values.volume || null,
}
}
function releaseMeasurementPreview() {
measurementPreviewRequestSerial += 1
if (measurementPreviewUrl.value) URL.revokeObjectURL(measurementPreviewUrl.value)
measurementPreviewUrl.value = ''
measurementPreviewLoading.value = false
measurementPreviewError.value = null
}
async function openMeasurementEvidence(fileId: string | null, title: string) {
const summary = detail.value?.summary
if (!fileId || !summary?.inboundId) return
releaseMeasurementPreview()
const serial = ++measurementPreviewRequestSerial
measurementPreviewTitle.value = title
measurementPreviewOpen.value = true
measurementPreviewLoading.value = true
try {
const blob = await downloadWeightImage(String(summary.tenantId), String(summary.inboundId), fileId)
if (serial !== measurementPreviewRequestSerial) return
measurementPreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
if (serial !== measurementPreviewRequestSerial) return
measurementPreviewError.value = getApiErrorDetails(error, '测量原图读取失败,请稍后重试')
} finally {
if (serial === measurementPreviewRequestSerial) measurementPreviewLoading.value = false
}
}
async function loadMeasurementDetails() {
const summary = detail.value?.summary
if (!summary?.inboundId || !can('platform:firstleg:inbound:measure:view')) return
const key = `${summary.tenantId}:${summary.inboundId}`
if (measurementLoadedKey.value === key || measurementLoading.value) return
const serial = ++measurementRequestSerial
measurementLoading.value = true
measurementError.value = null
try {
const [response] = await Promise.all([
getInboundMeasurement(String(summary.tenantId), String(summary.inboundId)),
loadMeasurementChargeRules(),
loadMeasurementDimensionConfig(true),
])
if (serial !== measurementRequestSerial) return
measurementDetail.value = response
const configuredMode = measurementDimensionConfig.value?.defaultCalculationMode
const savedModes = [...new Set(response.items.map((item) => item.coefficientMode)
.filter((mode): mode is MeasurementCalculationMode => (
mode === 'ACTUAL' || mode === 'ADD' || mode === 'MULTIPLY'
)))]
// 订单测量明细中已落库的规则是本次计费快照,必须优先于客户当前默认规则。
// 客户默认值只用于尚未绑定规则的新测量数据,避免保存后又被覆盖回 ACTUAL。
measurementSectionModes.outer = savedModes.length === 1
? savedModes[0]!
: configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value
? 'MULTIPLY' : configuredMode === 'ADD' ? 'ADD' : 'ACTUAL'
measurementSectionModes.customer = configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value
? 'MULTIPLY' : configuredMode === 'ADD' ? 'ADD' : 'ACTUAL'
measurementSectionModes.actual = 'ACTUAL'
measurementLoadedKey.value = key
} catch (error) {
if (serial !== measurementRequestSerial) return
if (!measurementDetail.value) measurementDetail.value = null
measurementError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
} finally {
if (serial === measurementRequestSerial) measurementLoading.value = false
}
}
async function loadHoldDetail(force = false) {
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
const key = inboundId && tenantId ? `${tenantId}:${inboundId}` : ''
if (!key || !can('platform:firstleg:inbound:list')) {
holdDetail.value = null
holdError.value = null
holdLoadedKey.value = ''
return
}
if (!force && (holdLoading.value || holdLoadedKey.value === key)) return
const serial = ++holdRequestSerial
holdLoading.value = true
holdError.value = null
try {
const result = await getPlatformInboundHold(tenantId, inboundId)
if (serial !== holdRequestSerial) return
holdDetail.value = result
holdLoadedKey.value = key
} catch (error) {
if (serial !== holdRequestSerial) return
holdDetail.value = null
holdLoadedKey.value = ''
holdError.value = getApiErrorDetails(error, '扣货状态与历史加载失败')
} finally {
if (serial === holdRequestSerial) holdLoading.value = false
}
}
function selectDetailTab(key: string) {
activeDetailTab.value = key
if (key === 'measurement') void loadMeasurementDetails()
if (key === 'fulfillment') void loadSubsequentFulfillment()
if (key === 'other') void loadHoldDetail()
}
const hasLoadingAssociation = computed(() => Boolean(loadingAssociation.value
&& (loadingAssociation.value.loadingPlanNo || loadingAssociation.value.containerNo || loadingAssociation.value.cabinetNo
|| loadingAssociation.value.loadingStatus || loadingAssociation.value.pdaTaskStatus)))
const hasOverseasRelationship = computed(() => Boolean(overseasRelationship.value?.found
&& (overseasRelationship.value.overseasWarehouse || overseasRelationship.value.overseasReceivingStatus
|| overseasRelationship.value.taillegOrders.length || overseasRelationship.value.inboundPlans.length
|| overseasRelationship.value.palletCodes.length || overseasRelationship.value.inventory.length)))
const hasSubsequentFulfillment = computed(() => hasLoadingAssociation.value || hasOverseasRelationship.value)
async function loadSubsequentFulfillment() {
const inboundCode = detail.value?.summary.inboundCode?.trim() || ''
const serial = ++subsequentFulfillmentRequestSerial
loadingAssociation.value = null
overseasRelationship.value = null
loadingAssociationError.value = null
overseasRelationshipError.value = null
if (!inboundCode) return
subsequentFulfillmentLoading.value = true
const tasks: Promise<void>[] = []
if (can('platform:firstleg:smart-loading:list')) tasks.push(getLoadingAssociationSummary(inboundCode)
.then((value) => { if (serial === subsequentFulfillmentRequestSerial) loadingAssociation.value = value })
.catch((error) => { if (serial === subsequentFulfillmentRequestSerial) loadingAssociationError.value = getApiErrorDetails(error, '装柜与PDA履约数据加载失败') }))
if (can('platform:overseas-warehouse:inventory:list')) tasks.push(getOverseasInboundCodeRelationship(inboundCode)
.then((value) => { if (serial === subsequentFulfillmentRequestSerial) overseasRelationship.value = value })
.catch((error) => { if (serial === subsequentFulfillmentRequestSerial) overseasRelationshipError.value = getApiErrorDetails(error, '尾程与库存履约数据加载失败') }))
await Promise.all(tasks)
if (serial === subsequentFulfillmentRequestSerial) subsequentFulfillmentLoading.value = false
}
function toggleMeasurementSection(section: keyof typeof measurementCollapsed) {
measurementCollapsed[section] = !measurementCollapsed[section]
}
function nextActionBoundary(row: UnifiedOrderResource) {
return row.nextAction === 'CONFIRM_MEASUREMENT' ? '待完成测量' : ''
}
function canApprove(row: UnifiedOrderResource) {
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
}
function openApproval(row: UnifiedOrderResource) {
if (!canApprove(row)) return
approvalOrder.value = row
approvalOpen.value = true
}
function closeApproval() {
if (approvalSaving.value) return
approvalOpen.value = false
approvalOrder.value = null
}
async function approveOrder() {
if (!approvalOrder.value) return
const order = approvalOrder.value
const orderId = String(order.orderId)
const tenantId = String(order.tenantId)
approvalSaving.value = true
approvalLoadingId.value = orderId
try {
await approvePlatformUnifiedOrder(tenantId, orderId)
approvalOpen.value = false
await loadOrders()
if (detail.value?.summary.orderId === order.orderId) await loadDetail()
approvalOrder.value = null
ElMessage.success('订单审核通过')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '订单审核失败,请稍后重试').message)
} finally {
approvalSaving.value = false
approvalLoadingId.value = ''
}
}
function openNoteDialog(order?: UnifiedOrderResource) {
if (!can('platform:order:note')) return
const target = order || detail.value?.summary || null
if (!target) return
noteOrder.value = target
noteForm.internalRemark = target.latestInternalNote || target.internalRemark || ''
noteForm.warehouseRemark = target.warehouseRemark || ''
noteForm.customerServiceRemark = target.customerServiceRemark || ''
noteOpen.value = true
}
async function saveNote() {
if (!noteOrder.value) return
noteSaving.value = true
try {
const target = noteOrder.value
await updatePlatformUnifiedOrderRemarks(String(target.tenantId), String(target.orderId), {
expectedVersion: Number(target.orderVersion || 0),
internalRemark: noteForm.internalRemark.trim() || null,
warehouseRemark: noteForm.warehouseRemark.trim() || null,
customerServiceRemark: noteForm.customerServiceRemark.trim() || null,
})
noteOpen.value = false
await loadOrders()
if (detail.value?.summary.orderId === target.orderId) await loadDetail(true)
noteOrder.value = null
ElMessage.success('订单备注已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '订单备注保存失败,请稍后重试').message)
} finally {
noteSaving.value = false
}
}
function openHoldDialog(action: 'HOLD' | 'RELEASE') {
const allowed = action === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
if (!allowed || !holdDetail.value) return
holdAction.value = action
holdReason.value = ''
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
holdEvidenceUploadKey.value += 1
holdDialogOpen.value = true
}
const holdEvidenceMaxBytes = 10 * 1024 * 1024
const holdEvidenceExtensions = new Set(['txt', 'pdf', 'jpg', 'jpeg', 'png'])
function selectHoldEvidenceFile(upload: UploadFile) {
const file = upload.raw || null
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
if (!file) return
const extension = file.name.split('.').pop()?.toLowerCase() || ''
if (!holdEvidenceExtensions.has(extension)) {
holdEvidenceError.value = { code: 'FILE_TYPE_INVALID', message: '关联文件仅支持 TXT、PDF、JPG、PNG' }
holdEvidenceUploadKey.value += 1
return
}
if (file.size <= 0 || file.size > holdEvidenceMaxBytes) {
holdEvidenceError.value = { code: 'FILE_SIZE_INVALID', message: '关联文件不能为空且不能超过 10MB' }
holdEvidenceUploadKey.value += 1
return
}
holdEvidenceFile.value = file
}
function removeHoldEvidenceFile() {
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
}
async function submitHoldAction() {
if (holdSaving.value) return
const allowed = holdAction.value === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
if (!allowed) return
const reason = holdReason.value.trim()
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!holdDetail.value || !inboundId || !tenantId) return
if (!reason) {
ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因')
return
}
if (reason.length > 1000) {
ElMessage.warning('原因不能超过 1000 个字符')
return
}
holdSaving.value = true
try {
if (holdEvidenceFile.value && !holdUploadedEvidenceFileId.value) {
try {
holdEvidenceError.value = null
const uploaded = await uploadPlatformInboundHoldEvidence(tenantId, inboundId, holdEvidenceFile.value)
holdUploadedEvidenceFileId.value = uploaded.fileId
} catch (error) {
holdEvidenceError.value = getApiErrorDetails(error, '关联文件上传失败,请稍后重试')
ElMessage.error(holdEvidenceError.value.message)
return
}
}
const command = {
expectedVersion: holdDetail.value.version,
reason,
...(holdUploadedEvidenceFileId.value ? { evidenceFileId: holdUploadedEvidenceFileId.value } : {}),
}
holdDetail.value = holdAction.value === 'HOLD'
? await holdPlatformInbound(tenantId, inboundId, command)
: await releasePlatformInbound(tenantId, inboundId, command)
holdDialogOpen.value = false
await Promise.all([loadDetail(true), loadOrders()])
ElMessage.success(holdAction.value === 'HOLD' ? '扣货成功' : '解除扣货成功')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, holdAction.value === 'HOLD' ? '扣货失败' : '解除扣货失败').message)
} finally {
holdSaving.value = false
}
}
async function downloadHoldEvidence(fileId: string) {
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!fileId || !inboundId || !tenantId || holdDownloadingFileId.value || !can('platform:firstleg:inbound:list')) return
holdDownloadingFileId.value = fileId
try {
const blob = await downloadPlatformInboundHoldEvidence(tenantId, inboundId, fileId)
const extension = blob.type === 'application/pdf' ? '.pdf'
: blob.type === 'image/png' ? '.png'
: blob.type === 'image/jpeg' ? '.jpg'
: blob.type === 'text/plain' ? '.txt' : ''
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `扣货凭证${extension}`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '扣货凭证下载失败,请稍后重试').message)
} finally {
holdDownloadingFileId.value = ''
}
}
async function loadCustomers() {
customerLoading.value = true
customerLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按客户筛选')
} finally {
customerLoading.value = false
}
}
async function loadWarehouses() {
warehouseLoading.value = true
warehouseLoadError.value = null
try {
warehouses.value = await listAllPlatformWarehouses({ status: 'ACTIVE' })
} catch (error) {
warehouses.value = []
warehouseLoadError.value = getApiErrorDetails(error, '平台仓库加载失败,暂时无法按仓库筛选')
} finally {
warehouseLoading.value = false
}
}
async function loadOrders() {
selectedApprovalOrders.value = []
selectedOrders.value = []
loading.value = true
loadError.value = null
coLoadError.value = null
Object.keys(planningDrafts).forEach((key) => delete planningDrafts[key])
try {
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: activeFilters.tenantId || undefined,
mainStatus: selectedStatus.value || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize.value,
})
records.value = result.records
total.value = result.total
} catch (error) {
records.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '统一订单列表加载失败,请检查 C02 统一查询接口和订单权限')
} finally {
loading.value = false
}
}
function search() {
appliedFilters.value = cloneFilters(filters.value)
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
function reset() {
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
if (page.value === 1) void loadOrders()
else page.value = 1
}
async function openDetail(row: UnifiedOrderResource) {
await router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
await nextTick()
window.scrollTo({ top: 0 })
}
function openMeasurement(row: UnifiedOrderResource) {
if (!row.inboundId || !can('platform:firstleg:inbound:measure:view')) return
measurementOrder.value = row
measurementDialogOpen.value = true
}
async function openFeeConfirmDialog() {
const summary = detail.value?.summary
if (!summary?.inboundId) {
ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用')
return
}
if (!hasActualMeasurementData.value) {
ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
return
}
if (confirmFeeSending.value) {
return
}
if (measurementStrategySavingId.value) {
ElMessage.warning('外箱规计算规则正在保存,请稍后再发送费用')
return
}
const outerTotals = measurementTotals(outerMeasurementRows.value)
if (!outerMeasurementRows.value.length || !(Number(outerTotals.volume) > 0)) {
ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')
return
}
const actualTotals = measurementTotals(actualMeasurementRows.value)
const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)
const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)
feeConfirmWeight.value = Number(actualTotals.gross) > 0
? Number(actualTotals.gross)
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
feeConfirmVolume.value = Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null
feeConfirmReference.value = null
feeConfirmPriceMissing.value = false
feeConfirmUnitPrice.value = null
feeConfirmPriceReason.value = ''
feeConfirmAttempt = null
feeConfirmOpen.value = true
feeConfirmReferenceLoading.value = true
try {
const tenantId = String(summary.tenantId)
const inboundId = String(summary.inboundId)
let snapshot: InboundFeeSnapshot
try {
snapshot = await getLatestInboundFee(tenantId, inboundId)
} catch (error) {
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
if (details.code !== 'A0404') throw error
snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg: Number(feeConfirmWeight.value),
selectedVolumeCbm: Number(feeConfirmVolume.value),
weightSource: 'ACTUAL',
volumeSource: 'ACTUAL',
}, createFeeActionIdempotencyKey('calculate'))
}
feeConfirmReference.value = snapshot
feeConfirmUnitPrice.value = Number(snapshot.unitPrice)
} catch (error) {
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
if (details.code === 'A0404') {
feeConfirmPriceMissing.value = true
ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
} else {
ElMessage.error(details.message)
feeConfirmOpen.value = false
}
} finally {
feeConfirmReferenceLoading.value = false
}
}
function currentFeeConfirmAttempt(
selectedWeightKg: number,
selectedVolumeCbm: number,
selectedUnitPrice: number,
) {
const fingerprint = JSON.stringify([
selectedWeightKg,
selectedVolumeCbm,
selectedUnitPrice,
feeConfirmPriceReason.value.trim(),
])
if (!feeConfirmAttempt || feeConfirmAttempt.fingerprint !== fingerprint) {
feeConfirmAttempt = {
fingerprint,
calculateKey: createFeeActionIdempotencyKey('calculate'),
manualKey: createFeeActionIdempotencyKey('manual'),
adjustKey: createFeeAdjustmentIdempotencyKey(),
confirmKey: createFeeActionIdempotencyKey('confirm'),
sendKey: createFeeActionIdempotencyKey('send'),
}
}
return feeConfirmAttempt
}
async function executeConfirmAndSendFee() {
if (confirmFeeSending.value) return
const summary = detail.value?.summary
if (!summary?.inboundId) return
const selectedWeightKg = Number(feeConfirmWeight.value)
const selectedVolumeCbm = Number(feeConfirmVolume.value)
const selectedUnitPrice = Number(feeConfirmUnitPrice.value)
if (!(selectedWeightKg > 0) || !(selectedVolumeCbm > 0)) {
ElMessage.warning('计费毛重和计费体积必须大于 0')
return
}
if (!(selectedUnitPrice > 0)) {
ElMessage.warning('对客单价必须大于 0')
return
}
if (feeConfirmPriceChanged.value && feeConfirmPriceReason.value.trim().length < 2) {
ElMessage.warning('人工修改参考单价后,请填写至少 2 个字符的调整原因')
return
}
const attempt = currentFeeConfirmAttempt(selectedWeightKg, selectedVolumeCbm, selectedUnitPrice)
confirmFeeSending.value = true
try {
const tenantId = String(summary.tenantId)
const inboundId = String(summary.inboundId)
let snapshot: InboundFeeSnapshot
try {
snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg, selectedVolumeCbm, weightSource: 'ACTUAL', volumeSource: 'ACTUAL',
}, attempt.calculateKey)
} catch (error) {
if (getApiErrorDetails(error, '费用试算失败').code !== 'A0404') throw error
feeConfirmPriceMissing.value = true
if (feeConfirmPriceReason.value.trim().length < 2) {
ElMessage.warning('价格未命中,请填写至少 2 个字符的人工定价原因')
return
}
snapshot = await createManualInboundQuote(tenantId, inboundId, {
selectedWeightKg, selectedVolumeCbm, unitPrice: selectedUnitPrice,
reason: feeConfirmPriceReason.value.trim(),
}, attempt.manualKey)
}
const calculatedUnitPrice = Number(snapshot.unitPrice)
if (snapshot.rateCode !== 'MANUAL_QUOTE' && Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) {
snapshot = await adjustPublishedInboundFee(tenantId, inboundId, {
expectedFeeVersion: snapshot.feeVersion,
chargeableQuantity: Number(snapshot.chargeableQuantity),
unitPrice: selectedUnitPrice,
additionalAmount: Number(snapshot.additionalAmount || 0),
reason: feeConfirmPriceReason.value.trim() || '推送前按已展示参考单价人工确认',
toleranceConfirmed: true,
}, attempt.adjustKey)
}
snapshot = await confirmInboundFee(tenantId, inboundId, attempt.confirmKey)
if (snapshot.feeStatus === 'CONFIRMED') {
snapshot = await sendInboundFee(tenantId, inboundId, attempt.sendKey)
}
if (snapshot.feeStatus === 'SENT') {
automaticFee.value = { feeStatus: snapshot.feeStatus, totalAmount: Number(snapshot.totalAmount || 0) }
feePanelRevision.value += 1
ElMessage.success('计费量和单价已确认并发送给客户')
feeConfirmOpen.value = false
feeConfirmAttempt = null
await loadDetail(true)
return
}
ElMessage.warning('当前费用状态不允许发送,请刷新后重试')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)
} finally {
confirmFeeSending.value = false
}
}
function handleOrderSelectionChange(rows: UnifiedOrderResource[]) {
selectedOrders.value = rows
selectedApprovalOrders.value = rows.filter(canApprove)
}
function coLoadSelectionMemberLabel(row: UnifiedOrderResource) {
const inboundCode = row.inboundCode?.trim() || '待生成入仓码'
const warehouseName = row.receivingWarehouseName?.trim() || '未配置仓库'
const channelName = row.channelName?.trim() || '未配置渠道'
return `${inboundCode}(仓库:${warehouseName};渠道:${channelName})`
}
function coLoadSelectionMembersLabel(rows: UnifiedOrderResource[]) {
return rows.map(coLoadSelectionMemberLabel).join('、')
}
const coLoadSelectionProblem = computed(() => {
if (selectedOrders.value.length < 2) return null
const allowedStatuses = new Set<UnifiedOrderMainStatus>(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT', 'LOADING'])
const invalidStatusRows = selectedOrders.value.filter((row) => !allowedStatuses.has(row.mainStatus))
if (invalidStatusRows.length) return `以下入仓码当前状态不能合柜:${coLoadSelectionMembersLabel(invalidStatusRows)}`
const groupedRows = selectedOrders.value.filter((row) => row.coLoadGroup && row.coLoadGroup.status !== 'CANCELLED')
if (groupedRows.length) return `以下入仓码已有同柜组:${coLoadSelectionMembersLabel(groupedRows)},请先解除原同柜组`
const missingWarehouseRows = selectedOrders.value.filter((row) => !String(row.receivingWarehouseId || '').trim())
if (missingWarehouseRows.length) return `以下入仓码缺少平台收货仓:${coLoadSelectionMembersLabel(missingWarehouseRows)},请刷新后重试`
const warehouseIds = selectedOrders.value.map((row) => String(row.receivingWarehouseId).trim())
if (new Set(warehouseIds).size > 1) return `所选订单的平台收货仓不同:${coLoadSelectionMembersLabel(selectedOrders.value)},不能设为同柜组`
const missingChannelRows = selectedOrders.value.filter((row) => !row.channelCode?.trim())
if (missingChannelRows.length) return `以下入仓码缺少渠道:${coLoadSelectionMembersLabel(missingChannelRows)},请刷新后重试`
const channelCodes = selectedOrders.value.map((row) => row.channelCode!.trim())
if (new Set(channelCodes).size > 1) return `所选订单的渠道不同:${coLoadSelectionMembersLabel(selectedOrders.value)},不能设为同柜组`
return null
})
const canCreateCoLoadGroup = computed(() => selectedOrders.value.length >= 2 && !coLoadSelectionProblem.value)
function coLoadGroup(row: UnifiedOrderResource) {
return row.coLoadGroup || null
}
function openLegacyImport() {
legacyImportFile.value = null
legacyImportResult.value = null
legacyImportError.value = null
legacyImportIdempotencyKey.value = ''
legacyImportOpen.value = true
}
function selectLegacyImportFile(uploadFile: UploadFile) {
legacyImportFile.value = uploadFile.raw || null
legacyImportResult.value = null
legacyImportError.value = null
legacyImportIdempotencyKey.value = createLegacyOrderImportIdempotencyKey()
}
async function submitLegacyImport() {
if (!legacyImportFile.value || legacyImportSaving.value) return
legacyImportSaving.value = true
legacyImportError.value = null
try {
legacyImportResult.value = await importPlatformLegacyOrders(
legacyImportFile.value,
legacyImportIdempotencyKey.value,
)
const result = legacyImportResult.value
if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`)
else ElMessage.success(`已导入 ${result.succeeded} 条旧系统订单`)
await loadOrders()
} catch (error) {
legacyImportError.value = getApiErrorDetails(error, '旧系统订单导入失败,请核对文件后重试')
} finally {
legacyImportSaving.value = false
}
}
function orderTypeLabel(row: UnifiedOrderResource) {
if (row.fulfillmentType === 'ONE_CLICK_DROPSHIP') return '一件代发'
if (row.businessType === 'FBA' || row.businessType === 'FBA_SEA') return 'FBA 头程'
if (row.businessType === 'OVERSEAS_SEA') return '海外中转'
return firstLegBusinessTypeLabel(row.businessType)
}
function unifiedOrderRowClassName({ row }: { row: UnifiedOrderResource }) {
return row.coLoadGroup && row.coLoadGroup.status !== 'CANCELLED' ? 'is-coload-group-row' : ''
}
function coLoadGroupMemberLabel(group: CoLoadGroup) {
const orderCount = new Set(group.members.map((member) => member.orderId)).size
return group.members.length === orderCount
? `${orderCount} 票`
: `${orderCount} 票 · ${group.members.length} 个入仓片段`
}
function coLoadGroupStatusLabel(group: { status: string }) {
return group.status === 'ACTIVE' ? '未锁定' : group.status === 'LOCKED' ? '已锁定' : group.status === 'CONSUMED' ? '已消费' : '已取消'
}
function coLoadGroupSummary(group: UnifiedOrderCoLoadGroupSummary | CoLoadBusinessSummary) {
return `同柜组 · ${group.memberCount} 票 · ${coLoadGroupStatusLabel(group)}`
}
function hasCoLoadMemberInboundCode(member: CoLoadBusinessSummary['members'][number]) {
return typeof member.inboundCode === 'string' && Boolean(member.inboundCode.trim())
}
const coLoadDetailMembers = computed(() => coLoadDetailSummary.value?.members.filter(hasCoLoadMemberInboundCode) || [])
const coLoadDetailHasMissingInboundCodes = computed(() => Boolean(coLoadDetailSummary.value)
&& (coLoadDetailMembers.value.length !== coLoadDetailSummary.value!.memberCount
|| coLoadDetailSummary.value!.members.some((member) => !hasCoLoadMemberInboundCode(member))))
async function loadCoLoadDetail() {
const inboundCode = coLoadDetailInboundCode.value
if (!inboundCode || coLoadDetailLoading.value) return
coLoadDetailLoading.value = true
coLoadDetailError.value = null
coLoadDetailSummary.value = null
try {
coLoadDetailSummary.value = await getPlatformCoLoadGroupByInboundCode(inboundCode)
} catch (error) {
coLoadDetailError.value = getApiErrorDetails(error, '同柜组成员加载失败,请刷新后重试')
} finally {
coLoadDetailLoading.value = false
}
}
function openCoLoadDetail(row: UnifiedOrderResource) {
coLoadDetailInboundCode.value = row.inboundCode?.trim() || ''
coLoadDetailOpen.value = true
coLoadDetailSummary.value = null
coLoadCancelError.value = null
coLoadDetailError.value = coLoadDetailInboundCode.value ? null : getApiErrorDetails(new Error('同柜组成员入仓码尚未返回'), '同柜组成员入仓码尚未返回,请刷新后重试')
if (coLoadDetailInboundCode.value) void loadCoLoadDetail()
}
function openCoLoadCreate() {
if (!canCreateCoLoadGroup.value) return
coLoadError.value = null
coLoadCreateOpen.value = true
}
async function createCoLoadGroup() {
if (!canCreateCoLoadGroup.value || coLoadSaving.value) return
coLoadSaving.value = true
coLoadError.value = null
try {
const group = await createPlatformCoLoadGroup(selectedOrders.value.map((row) => String(row.orderId)))
coLoadCreateOpen.value = false
ElMessage.success(`已创建同柜不可拆组,共 ${coLoadGroupMemberLabel(group)}`)
await loadOrders()
} catch (error) {
coLoadError.value = getApiErrorDetails(error, '创建同柜不可拆组失败')
} finally {
coLoadSaving.value = false
}
}
async function cancelCoLoadGroup() {
const inboundCode = coLoadDetailInboundCode.value
if (!inboundCode || coLoadDetailSummary.value?.status !== 'ACTIVE' || coLoadCancelSaving.value) return
try {
await ElMessageBox.confirm(
'确认解除整个同柜组吗?解除后这些订单可重新选择成员建组;不会拆分订单或子单。',
'解除同柜组',
{ customClass: 'admin-density-confirm', confirmButtonText: '确认解除', cancelButtonText: '取消', type: 'warning' },
)
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
coLoadCancelSaving.value = true
coLoadCancelError.value = null
try {
await cancelPlatformCoLoadGroup(inboundCode, '重新调整同柜组成员')
coLoadDetailOpen.value = false
ElMessage.success('同柜组已整组解除')
await loadOrders()
} catch (error) {
coLoadCancelError.value = getApiErrorDetails(error, '解除同柜组失败')
} finally {
coLoadCancelSaving.value = false
}
}
function batchReviewItem(order: UnifiedOrderResource): UnifiedOrderBatchReviewItem {
return { orderId: String(order.orderId), orderVersion: Number(order.orderVersion || 0) }
}
function handleBatchApprovalSelection(rows: UnifiedOrderBatchApprovalPreviewItem[]) {
batchApprovalSelectedRows.value = rows
}
async function openBatchApproval() {
const selected = [...selectedApprovalOrders.value]
if (!selected.length || batchApprovalLoading.value || batchApprovalSaving.value) return
batchApprovalOpen.value = true
batchApprovalLoading.value = true
batchApprovalError.value = null
batchApprovalPreviewRows.value = []
batchApprovalSelectedRows.value = []
try {
const groups = new Map<string, UnifiedOrderResource[]>()
selected.forEach((order) => {
const tenantId = String(order.tenantId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
const previews = await Promise.all([...groups].map(([tenantId, orders]) => (
getPlatformUnifiedOrderBatchApprovalPreview(tenantId, orders.map(batchReviewItem))
)))
batchApprovalPreviewRows.value = previews.flatMap((preview) => preview.items)
await nextTick()
batchApprovalPreviewRows.value.forEach((row) => {
if (row.canApprove) batchApprovalTableRef.value?.toggleRowSelection(row, true)
})
} catch (error) {
batchApprovalError.value = getApiErrorDetails(error, '批量审核预览加载失败,请刷新订单后重试')
} finally {
batchApprovalLoading.value = false
}
}
async function batchApproveOrders() {
const selected = [...batchApprovalSelectedRows.value]
if (!selected.length || batchApprovalSaving.value) return
batchApprovalSaving.value = true
try {
const groups = new Map<string, UnifiedOrderBatchApprovalPreviewItem[]>()
selected.forEach((order) => {
const tenantId = String(order.customerId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
let successCount = 0
let failureCount = 0
const failures: string[] = []
for (const [tenantId, orders] of groups) {
try {
const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => ({
orderId: order.orderId,
orderVersion: order.orderVersion,
})))
successCount += result.successCount
failureCount += result.failureCount
failures.push(...result.failedOrders.map((failure) => failure.message))
} catch (error) {
failureCount += orders.length
failures.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
}
}
batchApprovalOpen.value = false
await loadOrders()
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount) ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reason ? `。${reason}` : ''}`)
else ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reason ? `。${reason}` : ''}`)
} finally {
batchApprovalSaving.value = false
}
}
function handleMeasurementUpdated() {
void loadOrders()
}
function handleMeasurementSubmitted() {
void loadOrders()
}
function clearMeasurementOrder() {
measurementOrder.value = null
openWarehouseSuggestionOnMeasurementLoad.value = false
}
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRowAction[] {
return [
...(canApprove(row) ? ['approve' as const] : []),
...(['PENDING_REVIEW', 'WAIT_MEASURE'].includes(row.mainStatus) && row.inboundId && can('platform:firstleg:inbound:measure:view') ? ['measurement' as const] : []),
...(can('platform:order:note') ? ['note' as const] : []),
]
}
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
return `更多订单操作:${String(row.inboundCode || '').trim() || '当前入仓单'}`
}
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
if (action === 'note') return '备注'
if (action === 'approve') return '审核'
return '测量'
}
function unifiedOrderRowActions(row: UnifiedOrderResource): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 500 },
...unifiedOrderSecondaryActions(row).map((action, index): RowActionItem => ({
key: action,
label: unifiedOrderActionLabel(action),
priority: 400 - index,
tone: action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral',
loading: action === 'approve' && approvalLoadingId.value === String(row.orderId),
})),
]
}
function handleUnifiedOrderRowAction(command: string, row: UnifiedOrderResource) {
if (command === 'detail') openDetail(row)
else runMoreAction(command, row)
}
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
if (command === 'note') openNoteDialog(row)
if (command === 'approve') openApproval(row)
if (command === 'measurement') openMeasurement(row)
}
async function printSelectedCartonLabels() {
if (!selectedCartonLabelOrders.value.length || printingCartonLabels.value) return
printingCartonLabels.value = true
try {
const blob = await downloadInboundCartonLabelsBatch(selectedCartonLabelOrders.value.map((row) => ({
tenantId: String(row.tenantId),
inboundId: String(row.inboundId),
})))
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `批量箱唛-${selectedCartonLabelOrders.value.length}单.pdf`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
const skipped = selectedOrders.value.length - selectedCartonLabelOrders.value.length
if (skipped > 0) ElMessage.warning(`箱唛已开始下载;已跳过 ${skipped} 个尚未生成入库单的订单`)
else ElMessage.success('批量箱唛已生成并开始下载')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '批量箱唛生成失败,请稍后重试').message)
} finally {
printingCartonLabels.value = false
}
}
function closeDetail() {
releaseCargoImagePreviews()
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
}
function returnToMeasurementReviews() {
if (window.history.length > 1) router.back()
else void router.push({ name: 'inbound-measurement-reviews' })
}
async function resolveBusinessDetailTarget() {
const inboundCode = businessInboundCode.value
const serial = ++detailAssociationRequestSerial
resolvedDetailTarget.value = null
detail.value = null
detailError.value = null
if (!inboundCode) {
detailError.value = { message: '入仓码缺失,无法定位对应订单' }
return
}
detailLoading.value = true
try {
const association = await getPlatformInboundCodeAssociation(inboundCode)
if (serial !== detailAssociationRequestSerial || inboundCode !== businessInboundCode.value) return
if (!association.orderId || !association.customerId) {
detailError.value = { message: `未找到入仓码 ${inboundCode} 对应的订单,请返回测量审核列表重新打开` }
return
}
resolvedDetailTarget.value = { orderId: String(association.orderId), tenantId: String(association.customerId) }
} catch (error) {
if (serial !== detailAssociationRequestSerial || inboundCode !== businessInboundCode.value) return
detailError.value = getApiErrorDetails(error, `入仓码 ${inboundCode} 的订单关联加载失败,可能无权查看或目标已不存在`)
} finally {
if (serial === detailAssociationRequestSerial && !resolvedDetailTarget.value) detailLoading.value = false
}
}
function exportFilterParameters() {
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
return {
tenantId: activeFilters.tenantId || undefined,
mainStatus: selectedStatus.value || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
}
}
async function exportOrders() {
if (exportSubmitting.value) return
const selectedOrderNos = selectedOrders.value.map((row) => row.orderNo?.trim() || '')
if (selectedOrderNos.some((orderNo) => !orderNo)) {
ElMessage.error('所选订单缺少订单号,请刷新列表后重试')
return
}
exportSubmitting.value = true
try {
const task = await createPlatformExportTask({
exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST',
format: 'XLSX',
taskName: selectedOrderNos.length ? `国内仓订单-已选${selectedOrderNos.length}票` : '国内仓订单-当前筛选',
parameters: selectedOrderNos.length
? { selectedOrderNos }
: exportFilterParameters(),
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '订单导出任务创建失败'))
} finally {
exportSubmitting.value = false
}
}
async function loadDetail(force = false) {
if (!orderId.value) return
const tenantId = detailTenantId.value
if (!tenantId) {
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
return
}
const requestKey = `${tenantId}:${orderId.value}`
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
const serial = ++detailRequestSerial
detailLoadingKey = requestKey
measurementRequestSerial += 1
measurementLoading.value = false
// 刷新详情时保留现有测量数据,避免请求期间列表被清空。
if (!force) measurementDetail.value = null
measurementError.value = null
measurementLoadedKey.value = ''
measurementSectionModes.customer = 'ACTUAL'
measurementSectionModes.outer = 'ACTUAL'
measurementSectionModes.actual = 'ACTUAL'
if (measurementChargeRulesLoadedKey.value !== tenantId) {
measurementChargeRules.value = []
measurementChargeRulesLoadedKey.value = ''
}
measurementDimensionConfig.value = null
measurementDimensionConfigLoadedKey.value = ''
measurementPreviewOpen.value = false
holdRequestSerial += 1
holdDetail.value = null
holdError.value = null
holdLoadedKey.value = ''
holdLoading.value = false
releaseMeasurementPreview()
releaseCargoImagePreviews()
detailLoading.value = true
detailError.value = null
try {
const response = await getPlatformUnifiedOrder(tenantId, orderId.value)
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = response
await loadCargoImagePreviews(response, serial)
if (activeDetailTab.value === 'other') await loadHoldDetail()
if (requestedDetailTab.value === 'measurement') {
activeDetailTab.value = 'measurement'
await loadMeasurementDetails()
}
} catch (error) {
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = null
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
} finally {
if (serial === detailRequestSerial) detailLoading.value = false
if (serial === detailRequestSerial && detailLoadingKey === requestKey) detailLoadingKey = ''
}
}
watch([page, pageSize], () => { void loadOrders() })
watch(businessInboundCode, (value) => {
detailAssociationRequestSerial += 1
resolvedDetailTarget.value = null
if (value) void resolveBusinessDetailTarget()
}, { immediate: true })
watch(detailRouteKey, (value) => {
if (value) {
activeDetailTab.value = requestedDetailTab.value
detail.value = null
detailError.value = null
void loadDetail()
} else if (!businessInboundCode.value) {
detailRequestSerial += 1
subsequentFulfillmentRequestSerial += 1
detail.value = null
detailError.value = null
detailLoading.value = false
}
}, { immediate: true })
watch(measurementPreviewOpen, (open) => {
if (!open) releaseMeasurementPreview()
})
onMounted(() => {
void loadCustomers()
void loadWarehouses()
if (!isDetailRoute.value) void loadOrders()
})
onBeforeUnmount(() => {
detailAssociationRequestSerial += 1
measurementRequestSerial += 1
holdRequestSerial += 1
releaseMeasurementPreview()
releaseCargoImagePreviews()
})
</script>
<template>
<AdminPage>
<section class="business-page unified-orders-page admin-density">
<AppDialog class="admin-density-dialog" v-model="feeConfirmOpen" title="编辑计费数据" confirm-text="确认并发送" :confirm-loading="confirmFeeSending || feeConfirmReferenceLoading" width="min(920px, 94vw)" :teleported="false" @confirm="executeConfirmAndSendFee">
<el-alert class="fee-confirm-price-alert" title="价格表单价仅作为参考;本次对客单价可人工修改,客户将一次确认计费量、单价和费用合计。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="fee-confirm-form">
<el-form-item label="入仓码"><el-input :model-value="detail?.summary.inboundCode || '—'" disabled /></el-form-item>
<el-form-item label="渠道"><el-input :model-value="detail?.summary.channelName || detail?.summary.channelCode || '—'" disabled /></el-form-item>
<el-form-item label="计费毛重"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" class="fee-confirm-number" placeholder="请输入计费毛重" /><span class="fee-confirm-unit">kg</span></div></el-form-item>
<el-form-item label="计费体积"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" class="fee-confirm-number" placeholder="请输入计费体积" /><span class="fee-confirm-unit">m³</span></div></el-form-item>
<el-form-item label="价格表参考单价"><div class="fee-confirm-number-wrap"><el-input-number :model-value="feeConfirmPriceMissing || feeConfirmReference?.unitPrice == null ? undefined : Number(feeConfirmReference.unitPrice)" :precision="4" :controls="false" disabled class="fee-confirm-number" :placeholder="feeConfirmPriceMissing ? '未命中' : '正在读取参考单价'" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item label="本次对客单价"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" class="fee-confirm-number" placeholder="请输入对客单价" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item v-if="feeConfirmPriceChanged" :label="feeConfirmPriceMissing ? '人工定价原因' : '单价调整原因'" class="fee-confirm-price-reason"><el-input v-model="feeConfirmPriceReason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请说明本次人工定价的原因" /></el-form-item>
</el-form>
<dl v-if="feeConfirmReference" class="fee-confirm-price-summary">
<div><dt>参考计费量</dt><dd>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</dd></div>
<div><dt>按当前单价预计合计</dt><dd>{{ feeConfirmEstimatedTotal == null ? '—' : feeConfirmMoney(feeConfirmEstimatedTotal, feeConfirmReference.currency) }}</dd></div>
</dl>
<h4 class="fee-confirm-reference-title">参考数据</h4>
<el-table :data="feeConfirmRows" border size="small">
<el-table-column prop="source" label="数据来源" />
<el-table-column prop="count" label="件数" />
<el-table-column label="总毛重(T.K.GW)" align="right"><template #default="{ row }">{{ row.weight == null ? '—' : formatNumber(row.weight, 2) }}</template></el-table-column>
<el-table-column label="总体积(m³)"><template #default="{ row }">{{ row.volume == null ? '—' : formatNumber(row.volume, 4) }}</template></el-table-column>
<el-table-column label="总材积重"><template #default="{ row }">{{ row.volume == null ? '—' : formatNumber(Number(row.volume) * 167, 2) }}</template></el-table-column>
</el-table>
</AppDialog>
<template v-if="isDetailRoute">
<DetailPageLayout ariaLabel="统一订单详情">
<header class="order-detail-header density-detail-header">
<div class="order-detail-heading density-detail-heading">
<el-button link class="back-button density-back" @click="fromMeasurementReview ? returnToMeasurementReviews() : closeDetail()"><ArrowLeft :size="16" />{{ fromMeasurementReview ? '返回测量审核列表' : '返回列表' }}</el-button>
<div class="order-title">
<h1 class="density-detail-title">{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
</div>
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
</div>
<div v-if="detail && canApprove(detail.summary)" class="detail-header-actions">
<el-button v-if="canApprove(detail.summary)" type="success" plain :loading="approvalLoadingId === String(detail.summary.orderId)" @click="openApproval(detail.summary)"><CircleCheck :size="15" />审核通过</el-button>
</div>
</header>
<LoadErrorState v-if="detailError" class="detail-load-error" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="businessInboundCode ? resolveBusinessDetailTarget() : loadDetail(true)">
<template #details><ApiErrorMeta :details="detailError" /></template>
<template v-if="fromMeasurementReview" #actions><el-button type="primary" plain :loading="detailLoading" @click="businessInboundCode ? resolveBusinessDetailTarget() : loadDetail(true)">重新加载</el-button><el-button @click="returnToMeasurementReviews">返回测量审核列表</el-button></template>
</LoadErrorState>
<div v-else v-loading="detailLoading" class="order-detail-content">
<template v-if="detail">
<dl class="detail-overview density-detail-overview" aria-label="订单概览">
<div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '未配置渠道' }}</dd></div>
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱 <small>({{ detail.order.cargoes.length }} 条货品)</small></dd></div>
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div>
<div><dt>实测体积</dt><dd>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div>
<div><dt>测量审核</dt><dd>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</dd></div>
<div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div>
</dl>
<div class="detail-tabs density-tabs" role="tablist" aria-label="订单详情模块">
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
</div>
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
<template v-if="activeDetailTab === 'basic'">
<dl class="order-info-grid density-fields">
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></dd></div>
<div><dt>内部订单状态</dt><dd>{{ orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus) }}</dd></div>
<div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div>
<div class="wide"><dt>收货仓地址</dt><dd>{{ detail.order.order?.receivingWarehouseAddress || '—' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div>
<div><dt>目的国家</dt><dd>{{ detail.order.order?.destinationCountryCode || '—' }}</dd></div>
<div class="wide"><dt>目的仓地址</dt><dd>{{ detail.summary.destinationAddress || '—' }}</dd></div>
<div><dt>目的仓邮编</dt><dd>{{ detail.summary.destinationPostalCode || '—' }}</dd></div>
<div><dt>目的仓联系人</dt><dd>{{ detail.summary.destinationContact || '—' }}</dd></div>
<div><dt>目的仓联系人电话</dt><dd>{{ detail.summary.destinationContactPhone || '—' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }} <el-tag v-if="detail.summary.channelChanged" size="small" type="warning">曾变更渠道</el-tag></dd></div>
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(detail.summary.businessType) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ feeReviewLabel(detail.summary.feeReviewStatus) }}</dd></div>
<div><dt>提货方式</dt><dd>{{ firstLegPickupMethodLabel(detail.order.order?.pickupMethod) }}</dd></div>
<div><dt>提货时间</dt><dd>{{ formatDate(detail.summary.pickupAt) }}</dd></div>
<div><dt>到仓时间</dt><dd>{{ detail.order.order?.receivingTime || '—' }}</dd></div>
<div><dt>货物到仓时间</dt><dd>{{ formatDate(detail.summary.expectedArrivalAt || detail.summary.inboundCompletedAt) }}</dd></div>
<div><dt>预计出运</dt><dd>{{ formatDate(detail.summary.expectedShipDate) }}</dd></div>
<div><dt>预计到达时间</dt><dd>{{ formatDate(detail.summary.expectedArrivalAt) }}</dd></div>
<div><dt>真实集装箱号</dt><dd>{{ detail.summary.transport?.containerNo || '—' }}</dd></div>
<div><dt>内部柜次号</dt><dd>{{ detail.summary.transport?.cabinetNo || '—' }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ detail.summary.transport?.vesselName || '—' }} / {{ detail.summary.transport?.voyageNo || '—' }}</dd></div>
<div><dt>开船时间</dt><dd>{{ formatEffectiveTransportTime(detail.summary.transport?.effectiveSailingAt, detail.summary.transport?.effectiveSailingAtEstimated) }}</dd></div>
<div><dt>到港时间</dt><dd>{{ formatEffectiveTransportTime(detail.summary.transport?.effectivePortArrivalAt, detail.summary.transport?.effectivePortArrivalAtEstimated) }}</dd></div>
<div><dt>配载状态</dt><dd>{{ unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(detail.order.order?.destinationType) }}</dd></div>
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}</dd></div>
<div><dt>商检验</dt><dd>{{ displayBoolean(detail.order.order?.customsInspection) }}</dd></div>
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱</dd></div>
<div><dt>客户申报毛重</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'grossWeightKg')) }} kg</dd></div>
<div><dt>客户申报净重</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'netWeightKg')) }} kg</dd></div>
<div><dt>客户申报总体积</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'volumeCbm'), 3) }} m³</dd></div>
<div><dt>计费毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.chargeableWeightKg) }} kg</dd></div>
<div><dt>计费体积</dt><dd>{{ formatNumber(detail.summary.chargeableVolumeCbm, 3) }} m³</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(detail.summary.volumetricWeightKg) }} kg</dd></div>
<div><dt>总材积量</dt><dd>{{ formatNumber(detail.summary.volumetricWeightKg) }}</dd></div>
<div><dt>库位位置</dt><dd>{{ detail.summary.locationCodes || '—' }}</dd></div>
<div><dt>创建时间</dt><dd>{{ formatDate(detail.summary.createdAt) }}</dd></div>
<div><dt>入库完成时间</dt><dd>{{ formatDate(detail.summary.inboundCompletedAt) }}</dd></div>
<div><dt>测量完成时间</dt><dd>{{ formatDate(detail.summary.measurementCompletedAt || detail.summary.measurement?.completedAt) }}</dd></div>
<div class="wide"><dt>提货联系人</dt><dd>{{ partyValue(detail.order.parties, 'SENDER', 'contactName') }} · {{ detail.summary.pickupContactPhone || partyValue(detail.order.parties, 'SENDER', 'phone') }} · {{ detail.summary.pickupContactEmail || partyValue(detail.order.parties, 'SENDER', 'email') }}</dd></div>
<div class="wide"><dt>提货地址</dt><dd>{{ detail.summary.pickupAddress || partyAddress(detail.order.parties, 'SENDER') }} · {{ detail.summary.pickupPostalCode || partyValue(detail.order.parties, 'SENDER', 'postalCode') }}</dd></div>
<div class="wide"><dt>目的地联系人</dt><dd>{{ partyValue(detail.order.parties, 'RECEIVER', 'contactName') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'phone') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}</dd></div>
<div><dt>目的仓联系人邮箱</dt><dd>{{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}</dd></div>
<div class="wide"><dt>目的地地址</dt><dd>{{ partyAddress(detail.order.parties, 'RECEIVER') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'postalCode') }}</dd></div>
<div class="wide"><dt>备注</dt><dd>{{ detail.order.order?.customerRemark || '—' }}</dd></div>
<div class="wide"><dt>关联文件</dt><dd>{{ detail.summary.associatedFiles?.join('、') || '—' }}</dd></div>
<div class="wide"><dt>唛头文件</dt><dd>{{ detail.summary.markFiles?.join('、') || '—' }}</dd></div>
<div class="wide"><dt>PC 端下单文件</dt><dd>{{ detail.summary.pcOrderFiles?.join('、') || '—' }}</dd></div>
<div><dt>快递公司</dt><dd>{{ detail.summary.expressCompany || detail.order.order?.expressCompany || '—' }}</dd></div>
<div><dt>快递单号</dt><dd>{{ detail.summary.expressTrackingNo || detail.order.order?.expressTrackingNo || '—' }}</dd></div>
<div class="wide"><dt>客服内部备注</dt><dd>{{ detail.summary.internalRemark || detail.summary.latestInternalNote || '—' }}</dd></div>
<div><dt>创建人</dt><dd>{{ detail.summary.createdBy || '—' }}</dd></div>
<div><dt>下一步</dt><dd>{{ canApprove(detail.summary) ? '审核订单' : nextActionBoundary(detail.summary) || '—' }}</dd></div>
</dl>
</template>
<template v-else-if="activeDetailTab === 'cargo'">
<div class="panel-title"><p>共 {{ detail.order.cargoes.length }} 条货品</p><el-button v-if="canEditDeclaration" type="primary" size="small" @click="declarationEditorOpen = true">编辑客户申报资料</el-button></div>
<div class="detail-table-wrap">
<el-table :data="detail.order.cargoes" border stripe empty-text="暂无货品信息">
<el-table-column prop="itemNo" label="#" width="64" />
<el-table-column label="报关HS_CODE" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'hsCode') }}</template></el-table-column>
<el-table-column label="货品图片" width="110"><template #default="{ row }"><el-image v-if="cargoImage(row as Record<string, unknown>)" :src="cargoImage(row as Record<string, unknown>)" :preview-src-list="[cargoImage(row as Record<string, unknown>)]" preview-teleported fit="contain" class="cargo-image" /><span v-else>—</span></template></el-table-column>
<el-table-column label="SKU NO." min-width="150"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'skuNo') }}</template></el-table-column>
<el-table-column label="中文品名" min-width="170"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'cargoName') }}</template></el-table-column>
<el-table-column label="英文品名" min-width="170"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'cargoNameEn') }}</template></el-table-column>
<el-table-column label="清关HS_CODE" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'customsHsCode') }}</template></el-table-column>
<el-table-column label="箱规长(cm)" align="right" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'lengthCm') }}</template></el-table-column>
<el-table-column label="箱规宽(cm)" align="right" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'widthCm') }}</template></el-table-column>
<el-table-column label="箱规高(cm)" align="right" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'heightCm') }}</template></el-table-column>
<el-table-column label="箱规周长(cm)" align="right" min-width="120"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'circumferenceCm') }}</template></el-table-column>
<el-table-column label="每箱产品数量" align="right" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'unitsPerPackage') }}</template></el-table-column>
<el-table-column label="箱数" align="right" width="90"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'packageCount') }}</template></el-table-column>
<el-table-column label="总毛重(T.KGW)" align="right" min-width="140"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'grossWeightKg') }}</template></el-table-column>
<el-table-column label="总净重(T.KNW)" align="right" min-width="140"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'netWeightKg') }}</template></el-table-column>
</el-table>
</div>
</template>
<template v-else-if="activeDetailTab === 'fees'">
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无入仓费用" :image-size="64" />
<template v-else>
<dl class="fee-overview" aria-label="费用汇总">
<div><dt>对客费用合计</dt><dd>{{ feeMoney(customerFeeTotal) }}</dd><small>已发布头程费用 + 已审核其他应收</small></div>
<div><dt>已审核其他应付</dt><dd>{{ feeMoney(otherFeeSummary.approvedPayable) }}</dd><small>单列展示,不计入对客费用</small></div>
</dl>
<InboundFeePanel
:key="`fee-${normalizedInboundId}-${feePanelRevision}`"
:customer-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
compact
display-only
@loaded="automaticFee = $event"
/>
<h3 class="fee-section-title fee-section-title--secondary">其他费用明细</h3>
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
:destination-type="detail.summary.destinationType"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true)"
/>
</template>
</template>
<template v-else-if="activeDetailTab === 'history'">
<div class="panel-title"><p>共 {{ detail.timeline.length }} 条记录</p></div>
<el-table :data="detail.timeline" class="history-table" border stripe empty-text="暂无状态历史" aria-label="状态历史">
<el-table-column label="时间" width="172"><template #default="{ row }">{{ formatDate(row.occurredAt) }}</template></el-table-column>
<el-table-column label="事件" min-width="180"><template #default="{ row }">{{ historyActionLabel(row.action || row.eventType) }}</template></el-table-column>
<el-table-column label="状态" width="166"><template #default="{ row }"><StatusBadge :status="statusLabel(row.mainStatus)" /></template></el-table-column>
<el-table-column label="操作人" min-width="160"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column>
<el-table-column label="备注" min-width="300"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
</el-table>
</template>
<template v-else-if="activeDetailTab === 'fulfillment'">
<div class="panel-title"><p>根单、已完成分片与当前余量</p></div>
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" :inbound-code="detail.summary.inboundCode" />
<section v-loading="subsequentFulfillmentLoading" class="subsequent-fulfillment" aria-label="后续履约数据">
<h3>后续履约数据</h3>
<LoadErrorState v-if="loadingAssociationError" title="装柜与PDA履约数据加载失败" :message="loadingAssociationError.message" @retry="loadSubsequentFulfillment"><template #details><ApiErrorMeta :details="loadingAssociationError" /></template></LoadErrorState>
<LoadErrorState v-if="overseasRelationshipError" title="尾程与库存履约数据加载失败" :message="overseasRelationshipError.message" @retry="loadSubsequentFulfillment"><template #details><ApiErrorMeta :details="overseasRelationshipError" /></template></LoadErrorState>
<dl v-if="hasLoadingAssociation" class="fulfillment-facts">
<div><dt>装柜计划</dt><dd>{{ loadingAssociation?.loadingPlanNo || '—' }}</dd></div>
<div><dt>柜号</dt><dd>{{ loadingAssociation?.containerNo || loadingAssociation?.cabinetNo || '—' }}</dd></div>
<div><dt>装柜状态</dt><dd>{{ loadingAssociation?.loadingStatus || '—' }}</dd></div>
<div><dt>PDA状态</dt><dd>{{ loadingAssociation?.pdaTaskStatus || '—' }}</dd></div>
</dl>
<dl v-if="hasOverseasRelationship" class="fulfillment-facts">
<div><dt>海外仓</dt><dd>{{ overseasRelationship?.overseasWarehouse?.warehouseName || '尚未分配海外仓' }}</dd></div>
<div><dt>海外收货状态</dt><dd>{{ overseasRelationship?.overseasReceivingStatus || '—' }}</dd></div>
<div class="wide"><dt>尾程订单</dt><dd>{{ overseasRelationship?.taillegOrders.map((item) => item.orderNo).join('、') || '—' }}</dd></div>
<div class="wide"><dt>入库计划</dt><dd>{{ overseasRelationship?.inboundPlans.map((item) => item.planNo).join('、') || '—' }}</dd></div>
<div class="wide"><dt>托盘码</dt><dd>{{ overseasRelationship?.palletCodes.join('、') || '—' }}</dd></div>
<div><dt>库存SKU</dt><dd>{{ overseasRelationship?.inventory.length || 0 }} 个</dd></div>
</dl>
<p v-if="!subsequentFulfillmentLoading && !loadingAssociationError && !overseasRelationshipError && !hasSubsequentFulfillment" class="fulfillment-empty">暂无后续履约数据</p>
</section>
</template>
<template v-else-if="activeDetailTab === 'dropship-print'">
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无托盘码可打印" :image-size="64" />
<el-alert v-else-if="!can('platform:firstleg:inbound:list')" title="当前账号缺少入库单查看权限,无法打印托盘码" type="warning" :closable="false" show-icon />
<DropshipPalletPrintPanel
v-else
:customer-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
:inbound-code="detail.summary.inboundCode"
/>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title">
<p>{{ detail.summary.measurement?.completedAt ? `测量完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p>
<div class="measurement-title-actions">
<el-button
v-if="can('firstleg:inbound:confirm-send-fee') && automaticFee?.feeStatus !== 'SENT' && !['LOADED', 'DELIVERED'].includes(detail.summary.mainStatus)"
type="primary"
:loading="confirmFeeSending"
title="确认并发送费用"
data-testid="confirm-send-fee-button"
@click="openFeeConfirmDialog"
><Send :size="15" />确认计费重发送</el-button>
</div>
</div>
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
<LoadErrorState v-else-if="measurementError" title="测量数据暂时无法加载" :message="measurementError.message" :loading="measurementLoading" @retry="loadMeasurementDetails">
<template #details><ApiErrorMeta :details="measurementError" /></template>
<template v-if="fromMeasurementReview" #actions><el-button type="primary" plain :loading="measurementLoading" @click="loadMeasurementDetails">重新加载</el-button><el-button @click="returnToMeasurementReviews">返回测量审核列表</el-button></template>
</LoadErrorState>
<template v-else>
<section class="measurement-section">
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span v-if="measurementCollapsed.customer" class="measurement-section-summary"><span>总件数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.customer" aria-label="客户下单数据计算方式" @change="updateMeasurementSectionMode('customer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
<div v-if="!measurementCollapsed.customer">
<div class="detail-table-wrap"><el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}</span></div>
</div>
</section>
<section class="measurement-section">
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span v-if="measurementCollapsed.outer" class="measurement-section-summary"><span>总件数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button></div>
<div v-if="!measurementCollapsed.outer">
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="outerMeasurementRows" border stripe empty-text="暂无外箱规数据"><el-table-column label="#" prop="itemNo" min-width="70" /><el-table-column v-for="column in outerColumns.filter((item) => item.key !== 'itemNo')" :key="column.key" :label="column.label" :prop="column.key" min-width="150"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}</span></div>
</div>
</section>
<div class="measurement-rule-selector">
<span class="measurement-rule-selector__label">箱规计算规则选择</span>
<el-tooltip :content="measurementStrategyLockedHint" :disabled="measurementStrategyEditable"><span><el-select class="measurement-rule-selector__control" :model-value="measurementSectionModes.outer" aria-label="外箱规数据计算方式" :loading="Boolean(measurementStrategySavingId)" :disabled="Boolean(measurementStrategySavingId) || !measurementStrategyEditable" @change="selectAndSaveOuterMeasurementMode($event)">
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
<el-option label="箱规尺寸" value="ADD" />
<el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" />
</el-select></span></el-tooltip>
<span class="measurement-rule-selector__hint">{{ measurementStrategyEditable ? '规则参数来源:客户管理' : measurementStrategyLockedHint }}</span>
</div>
<section class="measurement-section">
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span v-if="measurementCollapsed.actual" class="measurement-section-summary"><span>总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.actual" aria-label="实际测量数据计算方式" @change="updateMeasurementSectionMode('actual', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
<div v-if="!measurementCollapsed.actual">
<div v-loading="measurementLoading" class="detail-table-wrap">
<el-table :data="actualMeasurementRows" row-key="id" border stripe empty-text="暂无实际测量数据">
<el-table-column v-if="measurementStrategyEditable" label="操作" width="110" fixed="left">
<template #default="{ row }"><el-button type="primary" plain size="small" :loading="measurementOuterSavingId === String(row.id)" :disabled="Boolean(measurementOuterSavingId)" @click="saveWarehouseOuterDimensions(row as Record<string, unknown>)">保存外箱</el-button></template>
</el-table-column>
<el-table-column label="箱号" prop="boxCode" min-width="190" fixed="left" show-overflow-tooltip>
<template #default="{ row }"><strong class="measurement-box-code">{{ row.boxCode || '—' }}</strong></template>
</el-table-column>
<el-table-column label="#" prop="itemNo" min-width="70" fixed="left" />
<el-table-column label="仓库修改外箱(长)" width="190" align="center">
<template #default="{ row }"><el-input-number v-if="measurementStrategyEditable" v-model="row.outerLengthCm" class="measurement-outer-dimension-input" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /><template v-else>{{ formatNumber(row.outerLengthCm) }}</template></template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)" width="190" align="center">
<template #default="{ row }"><el-input-number v-if="measurementStrategyEditable" v-model="row.outerWidthCm" class="measurement-outer-dimension-input" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /><template v-else>{{ formatNumber(row.outerWidthCm) }}</template></template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)" width="190" align="center">
<template #default="{ row }"><el-input-number v-if="measurementStrategyEditable" v-model="row.outerHeightCm" class="measurement-outer-dimension-input" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /><template v-else>{{ formatNumber(row.outerHeightCm) }}</template></template>
</el-table-column>
<el-table-column v-for="column in actualMeasuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
</el-table-column>
<el-table-column label="尺寸图片" min-width="148">
<template #default="{ row }">
<MeasurementEvidenceThumbnail
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="row.intelligentImageFileId"
:alt="`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`"
empty-text="未绑定尺寸图片"
@open="openMeasurementEvidence(row.intelligentImageFileId, `尺寸图片 · ${row.boxCode || row.itemNo || '箱规编码待补充'}`)"
/>
</template>
</el-table-column>
</el-table>
</div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></div>
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
<div class="weight-evidence__heading">
<div><h4>称重图片与确认重量</h4><p>称重图片独立展示;重量值直接采用服务端确认结果。</p></div>
<dl><dt>实际总毛重</dt><dd>{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg</dd></dl>
</div>
<div v-if="measurementDetail?.weightResults.length" class="weight-evidence-grid">
<article v-for="weightResult in measurementDetail.weightResults" :key="weightResult.id" class="weight-evidence-card">
<MeasurementEvidenceThumbnail
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="weightResult.fileId"
:alt="`称重结果 ${weightResult.id} 的称重图片`"
empty-text="未绑定称重图片"
@open="openMeasurementEvidence(weightResult.fileId, `称重图片 · ${weightResult.palletType || '未记录类型'}`)"
/>
<dl>
<div><dt>托盘类型</dt><dd>{{ weightResult.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(weightResult.grossWeightKg) }} kg</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatNumber(weightResult.palletTareWeightKg) }} kg</dd></div>
<div><dt>货物重量</dt><dd>{{ formatNumber(weightResult.netWeightKg) }} kg</dd></div>
</dl>
</article>
</div>
<p v-else class="compact-empty">暂无称重图片与确认结果</p>
</section>
</div>
</section>
</template>
</template>
<template v-else>
<section class="hold-section" aria-label="订单扣货控制">
<div class="panel-title"><div><h2>扣货控制</h2><p>扣货状态独立于快递、箱子计费和内部备注维护</p></div><ShieldAlert :size="19" /></div>
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,扣货状态仅可查看" :image-size="56" />
<template v-else>
<div class="hold-summary">
<dl><div><dt>当前状态</dt><dd><el-tag :type="currentHoldFlag === true ? 'danger' : currentHoldFlag === false ? 'success' : 'info'" effect="plain">{{ currentHoldFlag === true ? '已扣货' : currentHoldFlag === false ? '未扣货' : '状态未知' }}</el-tag></dd></div><div><dt>当前原因</dt><dd>{{ currentHoldReason || '—' }}</dd></div></dl>
<div class="hold-actions"><el-button v-if="canHoldInbound" type="danger" :loading="holdSaving" @click="openHoldDialog('HOLD')"><ShieldAlert :size="15" />扣货</el-button><el-button v-if="canReleaseInbound" type="primary" plain :loading="holdSaving" @click="openHoldDialog('RELEASE')"><Undo2 :size="15" />解除扣货</el-button></div>
</div>
<el-alert v-if="!can('platform:firstleg:inbound:list')" title="当前账号只能查看订单返回的扣货状态,无法读取扣货历史" type="info" :closable="false" show-icon />
<LoadErrorState v-else-if="holdError" title="扣货状态与历史加载失败" :message="holdError.message" :loading="holdLoading" @retry="loadHoldDetail(true)"><template #details><ApiErrorMeta :details="holdError" /></template></LoadErrorState>
<template v-else>
<p v-if="holdDetail?.actionBlockReason && !canHoldInbound && !canReleaseInbound" class="hold-block-reason">{{ holdDetail.actionBlockReason }}</p>
<h3 class="hold-history-title">扣货历史</h3>
<div v-loading="holdLoading" class="hold-history-table"><el-table :data="holdDetail?.history || []" border stripe empty-text="暂无扣货历史"><el-table-column label="操作" width="110"><template #default="{ row }"><el-tag :type="row.action === 'HOLD' ? 'danger' : 'success'" effect="plain">{{ row.action === 'HOLD' ? '扣货' : '解除扣货' }}</el-tag></template></el-table-column><el-table-column prop="reason" label="原因" min-width="260" /><el-table-column label="凭证" width="150"><template #default="{ row }"><el-button v-if="row.evidenceFileId" link type="primary" :loading="holdDownloadingFileId === row.evidenceFileId" @click="downloadHoldEvidence(row.evidenceFileId)"><Download :size="14" />下载凭证</el-button><span v-else>—</span></template></el-table-column><el-table-column label="操作人" width="150"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column><el-table-column label="操作时间" width="180"><template #default="{ row }">{{ formatDate(row.operatedAt) }}</template></el-table-column></el-table></div>
</template>
</template>
</section>
<section class="other-notes-section" aria-label="内部备注">
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见,独立保存</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
<p v-if="detail.order.internalNotes.length === 0" class="compact-empty">暂无内部备注</p>
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
</section>
</template>
</section>
</template>
</div>
</DetailPageLayout>
</template>
<template v-else>
<ListPageLayout ariaLabel="统一订单列表">
<template #default>
<div id="unified-orders-results" role="tabpanel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-class-name="unifiedOrderRowClassName" row-key="orderId" border empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
<template #toolbar-start><h1>订单列表</h1></template>
<template #filters>
<div class="orders-query-panel integrated-query-group">
<div class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="selectedStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="selectedStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</div>
</template>
<template #notices><template v-if="loadError">
<LoadErrorState title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #before-results><div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div></template>
<template #toolbar-actions>
<el-button v-if="can('platform:order:import')" :disabled="loading" @click="openLegacyImport"><FileUp :size="16" />导入旧系统订单</el-button>
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalLoading || batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="openBatchApproval"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
<el-button type="primary" plain :loading="coLoadSaving" :disabled="loading || !canCreateCoLoadGroup" aria-label="合柜报关" @click="openCoLoadCreate"><Link2 :size="16" />合柜报关<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</div>
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }"><RowActions text-actions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #inboundCode="{ row }">
<div class="inbound-relation-cell order-code-cell">
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
<button v-if="coLoadGroup(row as UnifiedOrderResource)" type="button" class="coload-tag-trigger" :aria-label="`查看${coLoadGroupSummary(coLoadGroup(row as UnifiedOrderResource)!)}成员`" @click.stop="openCoLoadDetail(row as UnifiedOrderResource)"><el-tag size="small" :type="coLoadGroup(row as UnifiedOrderResource)?.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroupSummary(coLoadGroup(row as UnifiedOrderResource)!) }}</el-tag></button>
<el-button
v-if="(row as UnifiedOrderResource).relation"
class="order-relation-link"
link
type="primary"
:aria-label="`查看${orderRelationLabel(row as UnifiedOrderResource)}关联`"
@click="openOrderRelation(row as UnifiedOrderResource)"
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
</div>
</template>
<template #orderType="{ row }"><el-tag size="small" effect="light" type="info">{{ orderTypeLabel(row as UnifiedOrderResource) }}</el-tag></template>
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
<template #channel="{ row }"><div class="channel-cell"><LineClampText :content="(row as UnifiedOrderResource).channelName" /><el-tag v-if="(row as UnifiedOrderResource).channelChanged" size="small" type="warning">曾变更渠道</el-tag></div></template>
<template #estimatedVolume="{ row }">
<div class="planning-cell">
<el-input-number
v-if="canEditPlanning(row as UnifiedOrderResource)"
v-model="planningDraft(row as UnifiedOrderResource).estimatedVolumeCbm"
class="planning-volume-input"
:min="0.0001"
:precision="4"
:controls="false"
:disabled="planningDraft(row as UnifiedOrderResource).saving"
placeholder="填写预估体积"
:aria-label="`修改 ${(row as UnifiedOrderResource).inboundCode || '当前订单'} 预估体积`"
@change="savePlanning(row as UnifiedOrderResource)"
/>
<span v-else>{{ formatNumber((row as UnifiedOrderResource).estimatedVolumeCbm, 4) }}</span>
<small v-if="planningDraft(row as UnifiedOrderResource).saving">保存中…</small>
</div>
</template>
<template #expectedArrival="{ row }">
<div class="planning-cell">
<el-date-picker
style="width: 100%;"
v-if="canEditPlanning(row as UnifiedOrderResource)"
v-model="planningDraft(row as UnifiedOrderResource).expectedArrivalAt"
class="planning-arrival-input"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
format="YYYY-MM-DD HH:mm"
clearable
:disabled="planningDraft(row as UnifiedOrderResource).saving"
placeholder="选择预计到货时间"
:aria-label="`修改 ${(row as UnifiedOrderResource).inboundCode || '当前订单'} 预计到货时间`"
@change="savePlanning(row as UnifiedOrderResource)"
/>
<span v-else>{{ formatDate((row as UnifiedOrderResource).expectedArrivalAt) }}</span>
<small v-if="planningDraft(row as UnifiedOrderResource).saving">保存中…</small>
</div>
</template>
<template #field="{ row, column }"><LineClampText v-if="column.property === 'customerName'" :content="displayField(row as UnifiedOrderResource, column.property)" /><LineClampText v-else-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || unifiedOrderLoadingStatusLabel((row as UnifiedOrderResource).transport?.loadingStatus) }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
</DataTable></div>
</template>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="legacyImportOpen" title="导入旧系统订单" width="min(760px, calc(100vw - 28px))" confirm-text="开始导入" :confirm-loading="legacyImportSaving" :confirm-disabled="!legacyImportFile || legacyImportSaving || !!legacyImportResult" destroy-on-close @confirm="submitLegacyImport">
<div class="legacy-import-content">
<dl class="legacy-import-facts">
<div><dt>固定归属客户</dt><dd>IT测试公司</dd></div>
<div><dt>导入结果状态</dt><dd>待审核</dd></div>
</dl>
<el-upload :auto-upload="false" :limit="1" accept=".xlsx" :on-change="selectLegacyImportFile">
<el-button><FileUp :size="16" />选择 Excel 文件</el-button>
<template #tip><div class="el-upload__tip">仅支持旧系统“订单统计导出” .xlsx 文件,最多 200 条、5MB;入仓码保持不变。</div></template>
</el-upload>
<LoadErrorState v-if="legacyImportError" compact title="旧系统订单导入失败" :message="legacyImportError.message" :show-retry="false"><template #details><ApiErrorMeta :details="legacyImportError" /></template></LoadErrorState>
<div v-if="legacyImportResult" class="legacy-import-result">
<p>共 {{ legacyImportResult.total }} 条,成功 {{ legacyImportResult.succeeded }} 条,失败 {{ legacyImportResult.failed }} 条。</p>
<el-table v-if="legacyImportResult.failed" :data="legacyImportResult.items.filter((item) => !item.success)" border stripe max-height="280">
<el-table-column prop="excelRow" label="Excel 行" width="90" />
<el-table-column prop="legacyInboundCode" label="入仓码" min-width="160" />
<el-table-column prop="errorMessage" label="失败原因" min-width="300" />
</el-table>
</div>
</div>
</AppDialog>
<SmartLoadingDialog v-model="smartLoadingOpen" />
<AppDialog class="admin-density-dialog" v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
<el-alert title="请逐票核对入仓码、客户、中文品名、渠道和目的仓;审核通过后订单进入待测量。" type="info" :closable="false" show-icon />
<LoadErrorState v-if="batchApprovalError" compact title="批量审核预览加载失败" :message="batchApprovalError.message" :loading="batchApprovalLoading" @retry="openBatchApproval"><template #details><ApiErrorMeta :details="batchApprovalError" /></template></LoadErrorState>
<template v-else>
<div class="batch-approval-summary"><span>预览 {{ batchApprovalPreviewRows.length }} 票</span><strong>已勾选 {{ batchApprovalSelectedRows.length }} 票</strong></div>
<el-table ref="batchApprovalTableRef" class="batch-approval-table" :data="batchApprovalPreviewRows" row-key="orderId" border stripe max-height="520" empty-text="暂无可审核订单" @selection-change="handleBatchApprovalSelection">
<el-table-column type="selection" width="48" fixed="left" :selectable="(row: UnifiedOrderBatchApprovalPreviewItem) => row.canApprove" />
<el-table-column label="入仓码" min-width="160" show-overflow-tooltip><template #default="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template></el-table-column>
<el-table-column label="客户" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column label="中文品名" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ row.chineseGoodsNames.length ? row.chineseGoodsNames.join('、') : '—' }}</template></el-table-column>
<el-table-column label="渠道" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.channelName || row.channelCode || '—' }}</template></el-table-column>
<el-table-column label="目的仓" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template></el-table-column>
<el-table-column label="校验结果" min-width="180" show-overflow-tooltip><template #default="{ row }"><el-tag :type="row.canApprove ? 'success' : 'danger'" effect="light">{{ row.canApprove ? '可审核' : row.blockReason || '不可审核' }}</el-tag></template></el-table-column>
</el-table>
</template>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.inboundCode || '待生成入仓码' }}</span></div>
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="coLoadDetailOpen" title="同柜组成员" :show-confirm="false" cancel-text="关闭" width="min(720px, 94vw)" :loading="coLoadDetailLoading" :confirm-loading="coLoadCancelSaving">
<LoadErrorState v-if="coLoadDetailError" title="同柜组成员加载失败" :message="coLoadDetailError.message" :loading="coLoadDetailLoading" @retry="loadCoLoadDetail"><template #details><ApiErrorMeta :details="coLoadDetailError" /></template></LoadErrorState>
<template v-else-if="coLoadDetailSummary">
<div class="coload-detail-summary"><el-tag size="small" :type="coLoadDetailSummary.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroupSummary(coLoadDetailSummary) }}</el-tag><span>{{ coLoadDetailSummary.memberCount }} 个成员</span></div>
<el-alert v-if="coLoadDetailHasMissingInboundCodes" type="error" :closable="false" show-icon title="同柜组成员入仓码尚未返回,请刷新后重试" />
<div v-else-if="coLoadDetailMembers.length" class="coload-detail-scroll">
<table class="coload-detail-table">
<thead><tr><th>入仓码</th></tr></thead>
<tbody><tr v-for="member in coLoadDetailMembers" :key="member.inboundCode"><td><strong :title="member.inboundCode">{{ member.inboundCode }}</strong></td></tr></tbody>
</table>
</div>
<el-empty v-else description="暂无同柜组成员" :image-size="56" />
<div v-if="coLoadCancelError" class="coload-error" role="alert"><span>{{ coLoadCancelError.message }}</span><ApiErrorMeta :details="coLoadCancelError" /></div>
</template>
<template #footer="{ close }"><div class="app-dialog__footer"><el-button :disabled="coLoadCancelSaving" @click="close">关闭</el-button><el-button v-if="coLoadDetailSummary?.status === 'ACTIVE' && can('platform:firstleg:smart-loading:assign')" type="danger" :loading="coLoadCancelSaving" @click="cancelCoLoadGroup">解除同柜组</el-button></div></template>
</AppDialog>
</template>
<AppDrawer class="admin-density-drawer" v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
<OrderFulfillmentTreePanel
v-if="relationOrder"
:tenant-id="String(relationOrder.tenantId)"
:order-id="String(relationOrder.orderId)"
:inbound-code="relationOrder.inboundCode"
/>
</AppDrawer>
<AppDialog class="admin-density-dialog" v-model="noteOpen" title="编辑订单备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
<el-form label-position="top" class="order-remarks-form">
<el-form-item label="客户备注(只读)"><el-input :model-value="noteOrder?.customerRemark || '—'" disabled /></el-form-item>
<el-form-item label="内部备注"><el-input v-model="noteForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
<el-form-item label="仓库备注"><el-input v-model="noteForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
<el-form-item label="客服备注"><el-input v-model="noteForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB;服务端将校验文件真实内容。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
<dl v-if="approvalOrder" class="approval-facts">
<div><dt>入仓码</dt><dd>{{ approvalOrder.inboundCode || '—' }}</dd></div>
<div><dt>客户</dt><dd>{{ customerName(approvalOrder) }}</dd></div>
<div><dt>渠道</dt><dd>{{ approvalOrder.channelName || '—' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ approvalOrder.destinationWarehouseName || '—' }}</dd></div>
<div><dt>总箱数</dt><dd>{{ approvalOrder.totalPackageCount ?? '—' }} 箱</dd></div>
</dl>
<p class="approval-hint">确认订单资料无误后审核通过,订单将进入待测量阶段。</p>
</AppDialog>
<OrderDeclarationEditorDialog
v-if="detail"
v-model="declarationEditorOpen"
:detail="detail"
@updated="handleDeclarationUpdated"
/>
<AppDialog
class="admin-density-dialog"
v-model="measurementPreviewOpen"
:title="measurementPreviewTitle"
width="min(920px, calc(100vw - 28px))"
:loading="measurementPreviewLoading"
:show-footer="false"
destroy-on-close
>
<LoadErrorState v-if="measurementPreviewError" title="测量原图暂时无法读取" :message="measurementPreviewError.message">
<template #details><ApiErrorMeta :details="measurementPreviewError" /></template>
</LoadErrorState>
<img v-else-if="measurementPreviewUrl" class="measurement-original-preview" :src="measurementPreviewUrl" :alt="measurementPreviewTitle" />
<el-empty v-else-if="!measurementPreviewLoading" description="该记录未返回可预览的图片" :image-size="56" />
</AppDialog>
<InboundMeasurementDialog
class="admin-density-dialog"
v-if="measurementOrder?.inboundId"
v-model="measurementDialogOpen"
:customer-id="String(measurementOrder.tenantId)"
:inbound-id="String(measurementOrder.inboundId)"
:inbound-code="measurementOrder.inboundCode"
:open-warehouse-suggestion="openWarehouseSuggestionOnMeasurementLoad"
@updated="handleMeasurementUpdated"
@submitted="handleMeasurementSubmitted"
@closed="clearMeasurementOrder"
/>
</section>
</AdminPage>
</template>
<style scoped>
.detail-tabs { border: 1px solid var(--cc-border); border-block: 0; }
.panel-title > div:first-child { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-1, 4px) var(--admin-space-5, 12px); }
.compact-empty { margin: 0; padding: 14px var(--admin-space-4, 10px); color: var(--cc-muted); text-align: center; font-size: var(--admin-font-body, 13px); }
.approval-facts { margin: 0; border-top: 1px solid var(--cc-border); }
.approval-facts > div { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--cc-border); }
.approval-facts dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.approval-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: var(--admin-font-body, 13px); }
.approval-hint { margin: var(--admin-space-5, 12px) 0 0; color: #475467; font-size: var(--admin-font-body, 13px); }
.detail-panel :deep(.el-table) { font-size: var(--admin-font-body, 13px); --el-table-header-bg-color: var(--admin-label-bg, #f8fafc); }
.detail-panel :deep(.el-table__cell) { height: var(--admin-row-height, 36px); padding: var(--admin-space-1, 4px) 0; }
.detail-panel :deep(.el-table .cell) { line-height: 22px; overflow-wrap: anywhere; }
.detail-panel :deep(.el-table__empty-block) { min-height: 44px; }
.detail-panel :deep(.el-table__empty-text) { line-height: 44px; }
.history-table :deep(.cell) { white-space: normal; }
.measurement-section-title:has([aria-expanded="false"]) { border-bottom: 1px solid var(--cc-border); }
.fulfillment-facts + .fulfillment-facts { border-top: 0; }
@media (min-width: 961px) {
.table-card :deep(.data-table__pagination) { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); }
}
.unified-orders-page { min-width: 0; gap: var(--admin-space-5, 12px); --cc-list-table-font-size: 13px; --cc-list-query-font-size: 13px; --cc-list-action-font-size: 13px; }
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-6, 16px); margin-bottom: var(--admin-space-6, 16px); }
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { min-width: 0; }
.orders-query-toolbar { border: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.table-card { overflow: hidden; border-radius: var(--admin-radius, 4px); box-shadow: none; }
.coload-error { margin: var(--admin-space-3, 8px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--el-color-danger-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: var(--admin-font-caption, 12px); }
.coload-error :deep(.api-error-meta) { margin-top: 5px; }
.order-code-cell { display: grid; justify-items: start; gap: var(--admin-space-1, 4px); }
.coload-tag-trigger { margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer; }
.coload-tag-trigger:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; border-radius: 3px; }
.table-card :deep(.el-table__row.is-coload-group-row > .el-table__cell) { background: #fff6df; color: #7a4b00; }
.table-card :deep(.el-table__row.is-coload-group-row:hover > .el-table__cell) { background: #ffedbd; }
.table-card :deep(.el-table__row.is-coload-group-row > .el-table__cell:first-child) { box-shadow: inset 3px 0 0 #e6a23c; }
.coload-selection { display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px); margin-top: var(--admin-space-5, 12px); }
.coload-selection > span { padding: var(--admin-space-1, 4px) 7px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); background: var(--cc-surface-muted); font-size: var(--admin-font-caption, 12px); }
.coload-detail-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); margin-bottom: var(--admin-space-4, 10px); }
.coload-detail-scroll { overflow-x: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); }
.coload-detail-table { width: 100%; border-collapse: collapse; font-size: var(--admin-font-caption, 12px); }
.coload-detail-table th,.coload-detail-table td { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-bottom: 1px solid var(--cc-border); text-align: left; vertical-align: top; }
.coload-detail-table th { background: var(--cc-surface-muted); color: var(--cc-muted); font-weight: 600; }
.coload-detail-table tbody tr:last-child td { border-bottom: 0; }
.coload-detail-table td { min-width: 210px; }
.coload-detail-table td:last-child { min-width: 80px; white-space: nowrap; }
.coload-detail-table td strong,.coload-detail-table td span { display: block; overflow-wrap: anywhere; }
.coload-detail-table td span { color: var(--cc-muted); }
.coload-detail-table :deep(.el-button) { min-height: 24px; padding: 2px 0; }
.coload-toolbar-action { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-3, 8px); }
.subsequent-fulfillment { margin-top: var(--admin-space-5, 12px); padding-top: var(--admin-space-4, 10px); border-top: 1px solid var(--cc-border); }
.subsequent-fulfillment h3 { margin: 0 0 var(--admin-space-3, 8px); font-size: var(--admin-font-section, 14px); }
.fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); overflow: hidden; }
.batch-approval-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); margin: var(--admin-space-5, 12px) 0 var(--admin-space-3, 8px); color: var(--cc-muted); font-size: var(--admin-font-body, 13px); white-space: nowrap; }
.batch-approval-summary strong { color: var(--cc-text); }
.batch-approval-table :deep(.cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fulfillment-facts > div { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); min-width: 0; padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.fulfillment-facts > div.wide { grid-column: span 2; }
.fulfillment-facts dt { flex: 0 0 auto; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.fulfillment-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: var(--admin-font-body, 13px); }
.fulfillment-empty { margin: 0; padding: var(--admin-space-5, 12px); border: 1px dashed var(--cc-border); border-radius: 6px; color: var(--cc-muted); text-align: center; }
.row-actions { display: flex; align-items: center; gap: var(--admin-space-2, 6px); white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.table-card :deep(.data-table__system-cell .row-actions .el-dropdown) { margin-left: 0; }
.planning-cell { display: grid; min-width: 0; gap: var(--admin-space-1, 4px); }
.planning-cell > small { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.planning-volume-input,.planning-arrival-input { width: 100%; max-width: 100%; }
.planning-cell :deep(.el-input__wrapper) { min-height: var(--admin-control-height, 32px); padding-right: 9px; padding-left: 9px; }
.planning-cell :deep(.el-input__inner) { min-width: 0; text-align: left; }
.inbound-relation-cell { display: grid; min-width: 0; gap: 5px; }
.inbound-code-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.order-relation-link { width: fit-content; max-width: 100%; height: auto; justify-content: flex-start; padding: 0; font-size: var(--admin-font-caption, 12px); }
.order-relation-link :deep(span) { min-width: 0; white-space: normal; text-align: left; }
.primary-cell { display: grid; min-width: 0; gap: var(--admin-space-1, 4px); }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); overflow-wrap: anywhere; }
.order-detail-header { border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 4px 4px 0 0; }
.back-button { flex: 0 0 auto; padding: 0; }
.order-title { min-width: 0; }
.detail-header-actions { display: flex; flex: 0 0 auto; gap: var(--admin-space-3, 8px); }
.detail-header-actions :deep(.el-button + .el-button) { margin-left: 0; }
.detail-load-error { min-height: 280px; }
.order-detail-content { min-width: 0; }
.detail-overview { border: 1px solid var(--cc-border); border-top: 0; }
.detail-panel { min-width: 0; padding: var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: 0 0 4px 4px; background: var(--admin-surface, #fff); }
.panel-title { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); min-height: var(--admin-action-height, 28px); margin-bottom: var(--admin-space-3, 8px); }
.panel-title h2 { margin: 0; color: #344054; font-size: var(--admin-font-section, 14px); font-weight: 600; }
.panel-title p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); line-height: 1.5; }
.panel-title > svg { color: var(--cc-primary); }
.measurement-title-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
.measurement-title-actions :deep(.el-button) { margin-left: 0; }
.fee-confirm-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
.fee-confirm-price-alert { margin-bottom: 14px; }
.fee-confirm-number-wrap { position: relative; width: 100%; }
.fee-confirm-number { width: 100%; }
.fee-confirm-number :deep(.el-input__wrapper) { padding-right: 92px; }
.fee-confirm-unit { position: absolute; top: 50%; right: 12px; z-index: 1; transform: translateY(-50%); color: var(--cc-muted); pointer-events: none; }
.fee-confirm-reference-title { margin: var(--admin-space-3, 8px) 0 var(--admin-space-5, 12px); color: var(--cc-text); font-size: var(--admin-font-section, 14px); }
.fee-confirm-price-reason { grid-column: 1 / -1; }
.fee-confirm-price-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 var(--admin-space-6, 16px); border: 1px solid var(--cc-border); background: var(--admin-label-bg, #f8fafc); }
.fee-confirm-price-summary > div { min-width: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.fee-confirm-price-summary > div + div { border-left: 1px solid var(--cc-border); }
.fee-confirm-price-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.fee-confirm-price-summary dd { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 650; }
@media (max-width: 680px) { .fee-confirm-form { grid-template-columns: 1fr; } .fee-confirm-unit { right: 12px; } }
.fee-section-title { margin: var(--admin-space-5, 12px) 0 var(--admin-space-3, 8px); color: #344054; font-size: var(--admin-font-section, 14px); line-height: 1.4; }
.fee-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); }
.fee-overview > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1, 4px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); }
.fee-overview > div + div { border-left: 1px solid var(--cc-border); }
.fee-overview dt, .fee-overview small { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.fee-overview dd { margin: 0; font-size: var(--admin-font-title, 16px); font-weight: 600; font-variant-numeric: tabular-nums; }
.fee-overview small { margin-left: auto; }
.fee-section-title--secondary { margin-top: var(--admin-space-5, 12px); padding-top: var(--admin-space-4, 10px); border-top: 1px solid var(--cc-border); }
.order-info-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.order-info-grid > div.wide { grid-column: span 2; }
.detail-table-wrap { overflow-x: auto; }
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: var(--admin-label-bg, #f8fafc); }
.cargo-image { width: 40px; height: var(--admin-control-height, 32px); border: 1px solid var(--cc-border); border-radius: 2px; background: var(--admin-label-bg, #f8fafc); cursor: zoom-in; }
.measurement-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.measurement-section { margin-top: var(--admin-space-5, 12px); }
.measurement-section-title { display: flex; width: 100%; min-height: var(--admin-row-height, 36px); align-items: center; gap: var(--admin-space-5, 12px); padding: var(--admin-space-1, 4px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-bottom: 0; background: var(--admin-label-bg, #f8fafc); color: var(--cc-text); }
.measurement-section-title:hover { background: #f2f6fb; }
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 600; }
.measurement-section-summary { display: flex; min-width: 0; flex: 1; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-1, 4px) var(--admin-space-6, 16px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-section-toggle { display: flex; min-width: 0; min-height: var(--admin-action-height, 28px); flex: 1; align-items: center; gap: var(--admin-space-6, 16px); padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.measurement-section-toggle:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
.measurement-section-toggle > svg { flex: 0 0 auto; margin-left: auto; color: var(--cc-muted); }
.measurement-section-mode { flex: 0 0 190px; }
.measurement-section-title > .measurement-section-mode { display: none; }
.measurement-rule-selector { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4, 10px); margin: 0; padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-top: 0; background: var(--admin-surface, #fff); }
.measurement-rule-selector__label { color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 650; white-space: nowrap; }
.measurement-rule-selector__control { width: 270px; }
.measurement-rule-selector__hint { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-outer-dimension-input { width: 128px; max-width: 100%; }
.measurement-total { display: flex; min-height: var(--admin-control-height, 32px); flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-1, 4px) 20px; padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-top: 0; background: var(--admin-label-bg, #f8fafc); color: #475467; font-size: var(--admin-font-caption, 12px); font-variant-numeric: tabular-nums; }
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: var(--admin-font-body, 13px); }
.weight-evidence { margin-top: var(--admin-space-5, 12px); padding: var(--admin-space-4, 10px) 0 0; border-top: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.weight-evidence__heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); margin-bottom: var(--admin-space-3, 8px); }
.weight-evidence__heading h4 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section, 14px); }
.weight-evidence__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.weight-evidence__heading dl { margin: 0; text-align: right; }
.weight-evidence__heading dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.weight-evidence__heading dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 600; }
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--admin-space-3, 8px); }
.weight-evidence-card { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; min-width: 0; padding: var(--admin-space-3, 8px); border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.weight-evidence-card > dl { display: grid; gap: var(--admin-space-1, 4px); margin: 0; padding: 0 0 0 var(--admin-space-3, 8px); }
.weight-evidence-card > dl > div { display: flex; min-width: 0; justify-content: space-between; gap: var(--admin-space-5, 12px); }
.weight-evidence-card dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 650; text-align: right; overflow-wrap: anywhere; }
.measurement-original-preview { display: block; width: 100%; max-height: min(72vh, 760px); object-fit: contain; border-radius: 8px; background: #f2f4f7; }
.hold-section { padding-bottom: var(--admin-space-5, 12px); border-bottom: 1px solid var(--cc-border); }
.other-notes-section { padding-top: var(--admin-space-4, 10px); }
.hold-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.hold-summary dl { display: grid; min-width: 0; flex: 1; grid-template-columns: 210px minmax(280px, 1fr); gap: var(--admin-space-5, 12px); margin: 0; }
.hold-summary dl > div { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); min-width: 0; }
.hold-summary dt { flex: 0 0 auto; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.hold-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); overflow-wrap: anywhere; }
.hold-actions { display: flex; flex: 0 0 auto; gap: var(--admin-space-3, 8px); }
.legacy-import-content { display: grid; gap: var(--admin-space-5, 12px); }
.legacy-import-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); }
.legacy-import-facts > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); align-items: center; padding-block: var(--admin-space-2, 6px); }
.legacy-import-facts dt { padding: 8px 10px; color: var(--cc-text-muted); }
.legacy-import-facts dd { margin: 0; padding: 8px 10px; font-weight: 600; }
.legacy-import-result > p { margin: 0 0 var(--admin-space-3, 8px); }
.hold-actions :deep(.el-button) { margin-left: 0; }
.hold-block-reason { margin: var(--admin-space-5, 12px) 0 0; padding: 9px var(--admin-space-5, 12px); border: 1px solid var(--el-color-warning-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
.hold-history-title { margin: var(--admin-space-6, 16px) 0 var(--admin-space-3, 8px); color: #344054; font-size: var(--admin-font-section, 14px); }
.hold-history-table { min-width: 0; overflow-x: auto; }
.hold-command-form { margin-top: var(--admin-space-6, 16px); }
.hold-evidence-hint { margin: -var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.notes-list p { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); line-height: 1.55; overflow-wrap: anywhere; }
.notes-list li { padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.notes-list li:first-child { padding-top: 0; }
.notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
@media (max-width: 960px) { .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { flex-wrap: wrap; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
@media (max-width: 720px) { .measurement-section-title { align-items: stretch; flex-direction: column; } .measurement-section-toggle { align-items: flex-start; flex-wrap: wrap; } .measurement-section-toggle > svg { margin-left: auto; } .measurement-section-mode { width: 100%; flex-basis: auto; } }
@media (max-width: 420px) { .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
</style>
<style src="@/styles/adminDensity.css"></style>
<style src="../styles/orderDensity.css"></style>