Files
admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue
T
2026-09-03 11:47:32 +08:00

316 lines
18 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, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import {
updatePlatformUnifiedOrderDeclaration,
type UnifiedOrderDeclarationCargo,
type UnifiedOrderDetailResource,
} from '@/modules/unified-orders/api/unifiedOrders'
const props = defineProps<{
modelValue: boolean
detail: UnifiedOrderDetailResource
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: []
}>()
type EditableCargo = UnifiedOrderDeclarationCargo & { key: string }
const saving = ref(false)
const submitError = ref<ApiErrorDetails | null>(null)
const channels = ref<OceanLogisticsChannel[]>([])
const channelsLoading = ref(false)
const channelsError = ref<ApiErrorDetails | null>(null)
let channelRequestSerial = 0
const cargoEditorOpen = ref(false)
const cargoEditorIndex = ref(-1)
const cargoDraft = ref<EditableCargo | null>(null)
const originalChannel = reactive({ code: '', name: '' })
const form = reactive({
expectedVersion: 0,
customerRemark: '',
expectedArrivalAt: '',
estimatedVolumeCbm: null as number | null,
channelCode: '',
channelName: '',
cargoes: [] as EditableCargo[],
})
function text(row: Record<string, unknown>, key: string) {
const value = row[key]
return value === null || value === undefined ? '' : String(value)
}
function number(row: Record<string, unknown>, key: string, fallback = 0) {
const parsed = Number(row[key])
return Number.isFinite(parsed) ? parsed : fallback
}
function optionalNumber(row: Record<string, unknown>, key: string) {
const value = row[key]
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function receivingWarehouseId() {
return String(props.detail.order.order?.receivingWarehouseId || props.detail.summary.receivingWarehouseId || '').trim()
}
function resetForm() {
const snapshot = props.detail.order.order
form.expectedVersion = Number(snapshot?.version ?? 0)
form.customerRemark = snapshot?.customerRemark || props.detail.summary.customerRemark || ''
form.expectedArrivalAt = props.detail.summary.expectedArrivalAt || ''
form.estimatedVolumeCbm = props.detail.summary.estimatedVolumeCbm ?? null
form.channelCode = snapshot?.channelCode || props.detail.summary.channelCode || ''
form.channelName = snapshot?.channelName || props.detail.summary.channelName || ''
originalChannel.code = form.channelCode.trim()
originalChannel.name = form.channelName.trim()
form.cargoes = props.detail.order.cargoes.map((source, index) => {
const row = source as Record<string, unknown>
const itemNo = number(row, 'itemNo', index + 1)
return {
key: `${itemNo}-${index}`,
itemNo,
cargoName: text(row, 'cargoName'), cargoNameEn: text(row, 'cargoNameEn'),
skuNo: text(row, 'skuNo'), model: text(row, 'model'),
packageCount: number(row, 'packageCount'), totalQuantity: number(row, 'totalQuantity'),
lengthCm: number(row, 'lengthCm'), widthCm: number(row, 'widthCm'), heightCm: number(row, 'heightCm'),
grossWeightKg: number(row, 'grossWeightKg'), volumeCbm: number(row, 'volumeCbm'),
netWeightKg: optionalNumber(row, 'netWeightKg'),
hsCode: text(row, 'hsCode'), customsHsCode: text(row, 'customsHsCode'),
unitsPerPackage: optionalNumber(row, 'unitsPerPackage'),
unitDeclaredValue: optionalNumber(row, 'unitDeclaredValue'), declaredValue: optionalNumber(row, 'declaredValue'),
currency: text(row, 'currency'), material: text(row, 'material'), usage: text(row, 'usage'),
brand: text(row, 'brand'), cargoRemark: text(row, 'cargoRemark') || text(row, 'remark'),
}
})
submitError.value = null
}
const selectedChannel = computed(() => channels.value.find((item) => item.channelCode === form.channelCode) || null)
async function loadChannels() {
const warehouseId = receivingWarehouseId()
const serial = ++channelRequestSerial
channels.value = []
channelsError.value = null
if (!warehouseId) {
channelsError.value = getApiErrorDetails(new Error('订单缺少国内收货仓,无法加载正式渠道候选'), '渠道候选加载失败')
return
}
channelsLoading.value = true
try {
const result = await listWarehouseLogisticsChannels(warehouseId)
if (serial !== channelRequestSerial) return
channels.value = result
const current = result.find((item) => item.channelCode === form.channelCode)
if (current) form.channelName = current.channelName
} catch (error) {
if (serial !== channelRequestSerial) return
channelsError.value = getApiErrorDetails(error, '正式渠道候选加载失败,请重试')
} finally {
if (serial === channelRequestSerial) channelsLoading.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
resetForm()
void loadChannels()
}, { immediate: true })
function selectChannel(channelCode: string) {
const channel = channels.value.find((item) => item.channelCode === channelCode)
form.channelCode = channel?.channelCode || ''
form.channelName = channel?.channelName || ''
}
function editCargo(index: number) {
cargoEditorIndex.value = index
cargoDraft.value = { ...form.cargoes[index]! }
cargoEditorOpen.value = true
}
function cargoValidation(cargo: EditableCargo) {
if (!cargo.cargoName.trim()) return `第 ${cargo.itemNo} 项中文品名不能为空`
if (cargo.packageCount < 1 || cargo.totalQuantity < 1) return `第 ${cargo.itemNo} 项箱数和总数量必须大于 0`
if ([cargo.lengthCm, cargo.widthCm, cargo.heightCm, cargo.grossWeightKg, cargo.volumeCbm].some((value) => !(value > 0))) {
return `第 ${cargo.itemNo} 项尺寸、毛重和体积必须大于 0`
}
return ''
}
function applyCargo() {
if (!cargoDraft.value || cargoEditorIndex.value < 0) return
const message = cargoValidation(cargoDraft.value)
if (message) { ElMessage.warning(message); return }
Object.assign(form.cargoes[cargoEditorIndex.value]!, cargoDraft.value)
cargoEditorOpen.value = false
}
function nullable(value: string | null | undefined) {
return value?.trim() || null
}
function validate() {
if (channelsError.value) return '正式渠道候选尚未加载成功,请重试'
if (!selectedChannel.value || selectedChannel.value.channelName !== form.channelName) return '请选择当前国内收货仓的正式渠道'
if (!form.cargoes.length) return '订单缺少货品明细,不能保存'
for (const cargo of form.cargoes) {
const message = cargoValidation(cargo)
if (message) return message
}
return ''
}
async function save() {
const validationMessage = validate()
if (validationMessage) { ElMessage.warning(validationMessage); return }
const channelChanged = form.channelCode.trim() !== originalChannel.code || form.channelName.trim() !== originalChannel.name
if (channelChanged) {
try {
await ElMessageBox.confirm(
'渠道变更后,系统会重新校验分柜、同柜及计费约束。原箱码继续有效,无需更换已打印箱唛;纸面上的旧渠道文字不会自动变化,请以系统当前渠道为准。订单将标记为“曾变更渠道”。确认继续吗?',
'确认变更渠道',
{ type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '返回检查' },
)
} catch { return }
}
const snapshot = props.detail.order.order
if (!snapshot?.version && snapshot?.version !== 0) { ElMessage.error('订单版本缺失,请刷新详情后重试'); return }
saving.value = true
submitError.value = null
try {
await updatePlatformUnifiedOrderDeclaration(String(props.detail.summary.tenantId), String(props.detail.summary.orderId), {
expectedVersion: form.expectedVersion,
customerRemark: nullable(form.customerRemark),
expectedArrivalAt: nullable(form.expectedArrivalAt),
estimatedVolumeCbm: form.estimatedVolumeCbm,
channelCode: form.channelCode.trim(),
channelName: form.channelName.trim(),
cargoes: form.cargoes.map(({ key: _key, ...cargo }) => ({
...cargo,
cargoName: cargo.cargoName.trim(), cargoNameEn: nullable(cargo.cargoNameEn),
skuNo: nullable(cargo.skuNo), model: nullable(cargo.model), hsCode: nullable(cargo.hsCode),
customsHsCode: nullable(cargo.customsHsCode), currency: nullable(cargo.currency)?.toUpperCase() || null,
material: nullable(cargo.material), usage: nullable(cargo.usage), brand: nullable(cargo.brand),
cargoRemark: nullable(cargo.cargoRemark),
})),
})
ElMessage.success(channelChanged
? '渠道已更新,订单已标记为“曾变更渠道”;原箱码继续有效,无需更换已打印箱唛'
: '客户申报资料已更新')
emit('update:modelValue', false)
emit('updated')
} catch (error) {
submitError.value = getApiErrorDetails(error, '客户申报资料保存失败,请核对权限、订单状态或刷新版本后重试')
} finally {
saving.value = false
}
}
</script>
<template>
<AppDialog
:model-value="modelValue"
title="编辑客户申报资料"
width="min(1180px, calc(100vw - 36px))"
confirm-text="保存申报资料"
:confirm-loading="saving"
:confirm-disabled="channelsLoading || Boolean(channelsError) || !selectedChannel"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@confirm="save"
>
<div class="declaration-editor">
<el-alert type="warning" :closable="false" title="仅修改客户原始申报资料;仓库/PDA 实测数据不会被覆盖。变更渠道不作废原箱码,订单会标记为“曾变更渠道”。国内收货仓和中转仓不可修改,如需变更请重新下单。" />
<LoadErrorState v-if="submitError" compact title="保存失败,当前填写内容已保留" :message="submitError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="submitError" /></template>
</LoadErrorState>
<LoadErrorState v-if="channelsError" compact title="渠道候选加载失败" :message="channelsError.message" @retry="loadChannels">
<template #details><ApiErrorMeta :details="channelsError" /></template>
</LoadErrorState>
<el-descriptions :column="4" border class="readonly-facts">
<el-descriptions-item label="入仓码">{{ detail.summary.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="国内收货仓">{{ detail.summary.receivingWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="目的仓/中转仓">{{ detail.summary.destinationWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ form.expectedVersion }}</el-descriptions-item>
</el-descriptions>
<el-form label-position="top" class="order-fields">
<el-row :gutter="14">
<el-col :span="7"><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></el-col>
<el-col :span="4"><el-form-item label="渠道编码"><el-input :model-value="form.channelCode" readonly aria-label="渠道编码" /></el-form-item></el-col>
<el-col :span="4"><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" /></el-form-item></el-col>
<el-col :span="5"><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" clearable /></el-form-item></el-col>
<el-col :span="4"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit /></el-form-item></el-col>
</el-row>
</el-form>
<h3>货品申报明细 <small>不允许新增、删除或调整明细序号</small></h3>
<el-table :data="form.cargoes" border stripe size="small" class="cargo-table">
<el-table-column label="操作" width="82" fixed="left"><template #default="{ $index }"><el-button link type="primary" @click="editCargo($index)">编辑</el-button></template></el-table-column>
<el-table-column prop="itemNo" label="项" width="58" align="center" />
<el-table-column label="货品" min-width="190"><template #default="{ row }"><strong class="cell-primary" :title="row.cargoName">{{ row.cargoName }}</strong><small :title="row.cargoNameEn || row.skuNo">{{ row.cargoNameEn || row.skuNo || '—' }}</small></template></el-table-column>
<el-table-column label="箱数 / 数量" width="118" align="right"><template #default="{ row }">{{ row.packageCount }} 箱 / {{ row.totalQuantity }}</template></el-table-column>
<el-table-column label="申报尺寸(cm)" width="148"><template #default="{ row }">{{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}</template></el-table-column>
<el-table-column label="毛重 / 净重(kg)" width="145"><template #default="{ row }">{{ row.grossWeightKg }} / {{ row.netWeightKg ?? '—' }}</template></el-table-column>
<el-table-column prop="volumeCbm" label="体积(m³)" width="100" align="right" />
<el-table-column label="申报金额" width="125" align="right"><template #default="{ row }">{{ row.currency || '—' }} {{ row.declaredValue ?? '—' }}</template></el-table-column>
<el-table-column label="材质 / 用途" min-width="170"><template #default="{ row }"><span :title="[row.material, row.usage].filter(Boolean).join(' / ')">{{ [row.material, row.usage].filter(Boolean).join(' / ') || '—' }}</span></template></el-table-column>
</el-table>
</div>
</AppDialog>
<AppDialog v-model="cargoEditorOpen" title="编辑货品申报" width="min(980px, calc(100vw - 36px))" confirm-text="应用本项" :close-on-click-modal="false" @confirm="applyCargo">
<div v-if="cargoDraft" class="cargo-editor-grid">
<label><span>中文品名 *</span><el-input v-model="cargoDraft.cargoName" maxlength="128" /></label>
<label><span>英文品名</span><el-input v-model="cargoDraft.cargoNameEn" maxlength="128" /></label>
<label><span>SKU NO.</span><el-input v-model="cargoDraft.skuNo" maxlength="64" /></label>
<label><span>型号</span><el-input v-model="cargoDraft.model" maxlength="64" /></label>
<label><span>箱数 *</span><el-input-number v-model="cargoDraft.packageCount" :min="1" :precision="0" controls-position="right" /></label>
<label><span>总数量 *</span><el-input-number v-model="cargoDraft.totalQuantity" :min="1" :precision="0" controls-position="right" /></label>
<label><span>每箱数量</span><el-input-number v-model="cargoDraft.unitsPerPackage" :min="1" :precision="0" controls-position="right" /></label>
<label><span>币种</span><el-input :model-value="cargoDraft.currency || '—'" readonly aria-label="币种" /></label>
<label><span>长(cm) *</span><el-input-number v-model="cargoDraft.lengthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>宽(cm) *</span><el-input-number v-model="cargoDraft.widthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>高(cm) *</span><el-input-number v-model="cargoDraft.heightCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>体积(m³) *</span><el-input-number v-model="cargoDraft.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></label>
<label><span>毛重(kg) *</span><el-input-number v-model="cargoDraft.grossWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>净重(kg)</span><el-input-number v-model="cargoDraft.netWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>单价</span><el-input-number v-model="cargoDraft.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>申报总价</span><el-input-number v-model="cargoDraft.declaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>报关 HS CODE</span><el-input v-model="cargoDraft.hsCode" maxlength="20" /></label>
<label><span>清关 HS CODE</span><el-input v-model="cargoDraft.customsHsCode" maxlength="20" /></label>
<label><span>材质</span><el-input v-model="cargoDraft.material" maxlength="128" /></label>
<label><span>用途</span><el-input v-model="cargoDraft.usage" maxlength="128" /></label>
<label><span>品牌</span><el-input v-model="cargoDraft.brand" maxlength="64" /></label>
<label class="wide"><span>货品备注</span><el-input v-model="cargoDraft.cargoRemark" maxlength="500" /></label>
</div>
</AppDialog>
</template>
<style scoped>
.declaration-editor { display: grid; gap: 14px; max-height: calc(100vh - 190px); overflow: auto; padding-right: 4px; }
.readonly-facts { margin-top: 2px; }
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
h3 { margin: 0; color: #344054; font-size: 15px; } h3 small { margin-left: 8px; color: #98a2b3; font-weight: 400; }
.cargo-table { width: 100%; }
.cargo-table small, .cargo-table .cell-primary { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cargo-table small { margin-top: 3px; color: #98a2b3; }
.cargo-editor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 14px; }
.cargo-editor-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-editor-grid label.wide { grid-column: span 3; }
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input), .order-fields :deep(.el-select) { width: 100%; }
@media (max-width: 900px) { .cargo-editor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cargo-editor-grid label.wide { grid-column: span 2; } }
</style>