316 lines
18 KiB
Vue
316 lines
18 KiB
Vue
<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, 'productUsage'),
|
||
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>
|