feat(admin): edit domestic order declarations

This commit is contained in:
2026-09-02 10:24:07 +08:00
parent 132d0c22d7
commit bbb9c40c50
8 changed files with 432 additions and 5 deletions
@@ -0,0 +1,75 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders'
import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue'
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderDeclaration: vi.fn() }))
const detail = {
summary: {
orderId: '90001', tenantId: '208', inboundCode: 'RK-001', mainStatus: 'WAIT_MEASURE',
receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓',
channelCode: 'OLD', channelName: '原渠道', channelChanged: false,
expectedArrivalAt: '2026-09-06T10:00:00', customerRemark: '原备注',
},
order: {
order: { version: 7, channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' },
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }],
parties: [], internalNotes: [], statusHistory: [],
},
timeline: [],
} as never
function createWrapper() {
return mount(OrderDeclarationEditorDialog, {
props: { modelValue: true, detail },
global: {
plugins: [ElementPlus],
stubs: {
AppDialog: {
props: ['modelValue', 'confirmLoading'], emits: ['confirm', 'update:modelValue'],
template: '<section v-if="modelValue"><slot /><button data-testid="save" @click="$emit(\'confirm\')">保存</button></section>',
},
LoadErrorState: { props: ['title', 'message'], template: '<div class="load-error">{{ title }} {{ message }}<slot name="details" /></div>' },
ApiErrorMeta: true,
},
},
})
}
describe('OrderDeclarationEditorDialog', () => {
beforeEach(() => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
})
it('requires a second confirmation when the channel changes and keeps inbound/warehouse facts read-only', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never)
const wrapper = createWrapper()
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
expect(wrapper.text()).toContain('RK-001')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('NEW')
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('现有入仓码和箱唛不会改变'), '确认变更渠道', expect.any(Object))
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW' }))
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
})
it('preserves the editor and entered values when the versioned update fails', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockRejectedValue(new Error('订单版本已变化,请刷新详情后重试'))
const wrapper = createWrapper()
const inputs = wrapper.findAll('input')
await inputs[1]!.setValue('保留填写的渠道名')
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(wrapper.find('.load-error').exists()).toBe(true)
expect((inputs[1]!.element as HTMLInputElement).value).toBe('保留填写的渠道名')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { 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 {
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 originalChannel = reactive({ code: '', name: '' })
const form = reactive({
expectedVersion: 0,
customerRemark: '',
expectedArrivalAt: '',
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 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.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
}
watch(() => props.modelValue, (open) => { if (open) resetForm() }, { immediate: true })
function nullable(value: string | null | undefined) {
return value?.trim() || null
}
function validate() {
if (!form.channelCode.trim() || !form.channelName.trim()) return '渠道编码和渠道名称均不能为空'
if (!form.cargoes.length) return '订单缺少货品明细,不能保存'
for (const cargo of form.cargoes) {
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 ''
}
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),
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('客户申报资料已更新')
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(1500px, calc(100vw - 36px))"
confirm-text="保存申报资料"
:confirm-loading="saving"
: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>
<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="6"><el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item></el-col>
<el-col :span="6"><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="6"><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>
<div class="cargo-list">
<section v-for="cargo in form.cargoes" :key="cargo.key" class="cargo-card">
<header>第 {{ cargo.itemNo }} 项</header>
<div class="cargo-grid">
<label><span>中文品名 *</span><el-input v-model="cargo.cargoName" maxlength="128" /></label>
<label><span>英文品名</span><el-input v-model="cargo.cargoNameEn" maxlength="128" /></label>
<label><span>SKU NO.</span><el-input v-model="cargo.skuNo" maxlength="64" /></label>
<label><span>型号</span><el-input v-model="cargo.model" maxlength="64" /></label>
<label><span>箱数 *</span><el-input-number v-model="cargo.packageCount" :min="1" :precision="0" controls-position="right" /></label>
<label><span>总数量 *</span><el-input-number v-model="cargo.totalQuantity" :min="1" :precision="0" controls-position="right" /></label>
<label><span>长(cm) *</span><el-input-number v-model="cargo.lengthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>宽(cm) *</span><el-input-number v-model="cargo.widthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>高(cm) *</span><el-input-number v-model="cargo.heightCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>毛重(kg) *</span><el-input-number v-model="cargo.grossWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>净重(kg)</span><el-input-number v-model="cargo.netWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>体积(m³) *</span><el-input-number v-model="cargo.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></label>
<label><span>报关 HS CODE</span><el-input v-model="cargo.hsCode" maxlength="20" /></label>
<label><span>清关 HS CODE</span><el-input v-model="cargo.customsHsCode" maxlength="20" /></label>
<label><span>每箱数量</span><el-input-number v-model="cargo.unitsPerPackage" :min="1" :precision="0" controls-position="right" /></label>
<label><span>单价</span><el-input-number v-model="cargo.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>申报总价</span><el-input-number v-model="cargo.declaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>币种</span><el-input v-model="cargo.currency" maxlength="3" placeholder="USD" /></label>
<label><span>材质</span><el-input v-model="cargo.material" maxlength="128" /></label>
<label><span>用途</span><el-input v-model="cargo.usage" maxlength="128" /></label>
<label><span>品牌</span><el-input v-model="cargo.brand" maxlength="64" /></label>
<label class="wide"><span>货品备注</span><el-input v-model="cargo.cargoRemark" maxlength="500" /></label>
</div>
</section>
</div>
</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-list { display: grid; gap: 12px; }
.cargo-card { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.cargo-card > header { padding: 9px 12px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: #475467; font-weight: 600; }
.cargo-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px 12px; padding: 12px; }
.cargo-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-grid label.wide { grid-column: span 2; }
.cargo-grid :deep(.el-input-number), .cargo-grid :deep(.el-input) { width: 100%; }
@media (max-width: 1200px) { .cargo-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
</style>