# Conflicts:
#	src/modules/overseas-warehouse/api/warehouseServices.ts
#	src/modules/overseas-warehouse/views/WarehouseServiceView.vue
This commit is contained in:
2026-09-06 15:59:14 +08:00
60 changed files with 2109 additions and 200 deletions
@@ -72,6 +72,7 @@ const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
@@ -198,8 +199,10 @@ const detailCargoPageSize = ref(5)
const inboundBilling = ref<MerchantInboundBilling | null>(null)
const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('')
const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false)
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
@@ -649,12 +652,16 @@ watch(isCreatePage, (isCreate, wasCreate) => {
}, { immediate: true })
watch(detailOpen, (open) => {
if (!open) {
if (!open && !feeConfirmationOpen.value) {
resetInboundBillingState()
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
})
watch(feeConfirmationOpen, (open) => {
if (!open && !detailOpen.value) resetInboundBillingState()
})
onBeforeUnmount(() => {
resetInboundBillingState()
channelRequestSequence += 1
@@ -1197,6 +1204,7 @@ async function openEdit(order: FirstLegOrder) {
}
async function openDetail(order: FirstLegOrder) {
feeConfirmationOpen.value = false
detailId.value = order.id
detailCargoPage.value = 1
detailOpen.value = true
@@ -1215,6 +1223,14 @@ async function openDetail(order: FirstLegOrder) {
}
}
function openFeeConfirmation(order: FirstLegOrder) {
detailOpen.value = false
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
feeConfirmationOpen.value = true
void loadInboundBilling(order, '未推送计费重')
}
async function retryDetail() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await openDetail(order)
@@ -1222,30 +1238,34 @@ async function retryDetail() {
function resetInboundBillingState() {
inboundBillingRequestSequence += 1
inboundBillingOrderId.value = ''
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = '还未测量推送'
inboundBillingError.value = ''
}
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
return sequence === inboundBillingRequestSequence
&& detailOpen.value
&& detailId.value === orderId
&& inboundBillingOrderId.value === orderId
&& (detailOpen.value || feeConfirmationOpen.value)
}
async function loadInboundBilling(order: FirstLegOrder) {
async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '还未测量推送') {
const sequence = ++inboundBillingRequestSequence
inboundBillingOrderId.value = order.id
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = unavailableMessage
inboundBillingError.value = ''
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = '订单提交后可查看入库结果'
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
: '订单提交后可查看入库结果'
return
}
@@ -1258,7 +1278,9 @@ async function loadInboundBilling(order: FirstLegOrder) {
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = error.message || '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
: error.message || unavailableMessage
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
}
@@ -1268,8 +1290,12 @@ async function loadInboundBilling(order: FirstLegOrder) {
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
}
function handleInboundFeeConfirmed() {
if (!feeConfirmationOpen.value) void retryInboundBilling()
}
async function confirmDiscardChanges() {
@@ -1491,6 +1517,11 @@ function openBatchImport() {
batchImportOpen.value = true
}
function batchImportExcelRow(item: { index: number; clientRowId: string }) {
const matched = /^excel-row-(\d+)$/.exec(item.clientRowId)
return matched ? Number(matched[1]) : item.index + 3
}
function selectBatchImportFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -1773,6 +1804,7 @@ async function searchOrders(model: QueryModel) {
<template #actions="{ row }">
<div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
</div>
@@ -1838,6 +1870,7 @@ async function searchOrders(model: QueryModel) {
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
<div>
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
<el-button link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
</div>
@@ -1928,7 +1961,12 @@ async function searchOrders(model: QueryModel) {
<section class="order-form-section prototype-section cargo-section">
<div class="order-section-heading cargo-table-heading"><div><h3>货物明细</h3></div></div>
<div class="cargo-table-toolbar">
<span class="cargo-table-hint">可逐行维护货物明细;批量订单请返回订单列表使用正式 Excel 导入。</span>
<div v-if="isCreatePage && !routeDraftId && canCreateOrder" class="cargo-import-actions">
<el-button class="cargo-download-button" :loading="batchTemplateDownloading" @click="downloadBatchTemplate"><Download :size="16" />下载 Excel 模板</el-button>
<el-button class="cargo-upload-button" type="primary" plain @click="openBatchImport"><Upload :size="16" />Excel 批量导入</el-button>
<span class="cargo-table-hint">Excel 导入用于一次创建多条订单草稿。</span>
</div>
<span v-else class="cargo-table-hint">可逐行维护货物明细。</span>
<div class="cargo-table-actions"><el-button @click="addCargoItem"><Plus :size="16" />添加行</el-button><el-button type="danger" plain :disabled="!selectedCargoIds.length" @click="removeSelectedCargoItems"><Trash2 :size="15" />删除所选行</el-button></div>
</div>
<div class="cargo-table-wrap">
@@ -1947,10 +1985,10 @@ async function searchOrders(model: QueryModel) {
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
<td><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td>
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" placeholder="申报总价" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></div></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody>
</table>
@@ -1998,7 +2036,7 @@ async function searchOrders(model: QueryModel) {
<el-dialog v-model="batchImportOpen" title="Excel 批量创建订单草稿" width="680px" destroy-on-close append-to-body>
<div class="batch-import-dialog">
<el-alert title="使用服务端正式模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;导入成功的数据只创建草稿,不会直接提交审核。模板包含 cargo.woodenProduct(true/false);任一货物填写 true 时,customsInspection 必须填写 true。" type="info" :closable="false" show-icon />
<el-alert title="使用中文模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;请直接选择模板内的中文下拉项,不需要填写英文代码或内部编号。导入成功的数据只创建草稿,不会直接提交审核;选择木制品时,模板会要求同步选择需要商检。" type="info" :closable="false" show-icon />
<el-alert v-if="batchImportError" title="批量导入处理失败" :description="batchImportError" type="error" :closable="false" show-icon />
<div class="batch-import-actions">
<input ref="batchImportInput" class="cargo-import-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="selectBatchImportFile" />
@@ -2017,8 +2055,8 @@ async function searchOrders(model: QueryModel) {
<el-button type="danger" plain :loading="batchErrorReportDownloading" @click="downloadBatchErrorReport"><Download :size="16" />下载 Excel 错误报告</el-button>
</div>
<ul v-if="batchImportResult.result.failed">
<li v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="`${item.index}-${item.clientRowId}`">
第 {{ item.index + 1 }} 行({{ item.clientRowId || '未填写行标识' }}):{{ item.errorMessage || item.errorCode || '导入失败' }}
<li v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="item.index">
Excel 第 {{ batchImportExcelRow(item) }} 行:{{ item.errorMessage || item.errorCode || '导入失败' }}
</li>
</ul>
</section>
@@ -2145,12 +2183,28 @@ async function searchOrders(model: QueryModel) {
:unavailable="inboundBillingUnavailable"
:unavailable-message="inboundBillingUnavailableMessage"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
/>
</template>
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-dialog>
<InboundBillingPanel
v-if="feeConfirmationOpen"
dialog-only
auto-open
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
unavailable-message="未推送计费重"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false"
/>
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"