Merge branch 'dev' of http://8.163.30.128:10082/html/client-web into dev
# Conflicts: # src/modules/overseas-warehouse/api/warehouseServices.ts # src/modules/overseas-warehouse/views/WarehouseServiceView.vue
This commit is contained in:
@@ -6,9 +6,17 @@ const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'ut
|
||||
const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('queries the merchant order inbound-billing endpoint without tenant parameters', () => {
|
||||
assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
|
||||
assert.match(source, /method: 'GET'/)
|
||||
assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
|
||||
const readSource = source.slice(source.indexOf('export async function getMerchantInboundBilling'), source.indexOf('function createFeeConfirmationKey'))
|
||||
assert.match(readSource, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
|
||||
assert.match(readSource, /method: 'GET'/)
|
||||
assert.doesNotMatch(readSource, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
|
||||
})
|
||||
|
||||
test('confirms the latest customer fee version with an idempotency key', () => {
|
||||
assert.match(source, /function confirmMerchantInboundFee/)
|
||||
assert.match(source, /\/inbound-billing\/confirm/)
|
||||
assert.match(source, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
|
||||
assert.match(source, /data: \{ expectedFeeVersion \}/)
|
||||
})
|
||||
|
||||
test('sanitizes the platform-shaped response into a merchant whitelist', () => {
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
|
||||
interface MerchantInboundBillingWireResponse {
|
||||
inbound: MerchantInboundResult
|
||||
measurementSummary: MerchantInboundBilling['measurementSummary']
|
||||
measurementItems: MerchantInboundBilling['measurementItems']
|
||||
billingBasis: MerchantInboundBilling['billingBasis']
|
||||
latestFee: MerchantInboundFeeSnapshot
|
||||
}
|
||||
|
||||
@@ -62,6 +65,8 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
||||
feeStatus: fee.feeStatus,
|
||||
inboundId: fee.inboundId,
|
||||
inboundCode: fee.inboundCode,
|
||||
measurementId: fee.measurementId == null ? null : String(fee.measurementId),
|
||||
measurementSource: fee.measurementSource,
|
||||
chargeRuleId: fee.chargeRuleId,
|
||||
ruleCode: fee.ruleCode,
|
||||
chargeRuleVersion: fee.chargeRuleVersion,
|
||||
@@ -98,6 +103,43 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
||||
confirmedAt: fee.confirmedAt,
|
||||
sentAt: fee.sentAt,
|
||||
nextProcessStatus: fee.nextProcessStatus,
|
||||
customerConfirmationStatus: fee.customerConfirmationStatus,
|
||||
customerConfirmedBy: fee.customerConfirmedBy == null ? null : String(fee.customerConfirmedBy),
|
||||
customerConfirmedByName: fee.customerConfirmedByName,
|
||||
customerConfirmedAt: fee.customerConfirmedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMeasurementSummary(item: MerchantInboundBilling['measurementSummary']): MerchantInboundBilling['measurementSummary'] {
|
||||
return {
|
||||
measurementId: item.measurementId == null ? null : String(item.measurementId),
|
||||
sourceType: item.sourceType,
|
||||
measurementStatus: item.measurementStatus,
|
||||
actualPackageCount: item.actualPackageCount,
|
||||
actualGrossWeightKg: item.actualGrossWeightKg,
|
||||
actualNetWeightKg: item.actualNetWeightKg,
|
||||
actualVolumeCbm: item.actualVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
actualGoodsName: item.actualGoodsName,
|
||||
calculationSource: item.calculationSource,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems'][number]): MerchantInboundBilling['measurementItems'][number] {
|
||||
return {
|
||||
id: String(item.id),
|
||||
itemNo: item.itemNo,
|
||||
boxSpecification: item.boxSpecification,
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitVolumeCbm: item.unitVolumeCbm,
|
||||
totalVolumeCbm: item.totalVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
|
||||
coefficientMode: item.coefficientMode,
|
||||
calculationRuleVersion: item.calculationRuleVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +150,9 @@ function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): M
|
||||
publicRemark: response.inbound.publicRemark,
|
||||
palletLocations: response.inbound.palletLocations.map(sanitizePalletLocation),
|
||||
},
|
||||
measurementSummary: sanitizeMeasurementSummary(response.measurementSummary),
|
||||
measurementItems: (response.measurementItems || []).map(sanitizeMeasurementItem),
|
||||
billingBasis: response.billingBasis,
|
||||
latestFee: sanitizeFee(response.latestFee),
|
||||
}
|
||||
}
|
||||
@@ -119,3 +164,19 @@ export async function getMerchantInboundBilling(orderId: string): Promise<Mercha
|
||||
})
|
||||
return sanitizeInboundBilling(response)
|
||||
}
|
||||
|
||||
function createFeeConfirmationKey(orderId: string, feeVersion: number) {
|
||||
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
return `inbound-fee-confirm-${orderId}-${feeVersion}-${randomPart}`
|
||||
}
|
||||
|
||||
export async function confirmMerchantInboundFee(orderId: string, expectedFeeVersion: number,
|
||||
idempotencyKey = createFeeConfirmationKey(orderId, expectedFeeVersion)) {
|
||||
const response = await apiRequest<MerchantInboundFeeSnapshot>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing/confirm`,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
data: { expectedFeeVersion },
|
||||
})
|
||||
return sanitizeFee(response)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const emit = defineEmits<{
|
||||
.editor-page-nav span { color: var(--muted); font-size: 12px; }
|
||||
.editor-page-body { padding: 26px 32px 34px; }
|
||||
.editor-page-footer { position: sticky; z-index: 3; bottom: 0; display: flex; justify-content: center; padding: 12px 24px; border-top: 1px solid #edf0f5; background: rgba(255, 255, 255, 0.96); box-shadow: 0 -6px 18px rgba(15, 31, 58, .06); backdrop-filter: blur(10px); }
|
||||
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: flex-start; }
|
||||
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; }
|
||||
.editor-page-footer :deep(.editor-actions > .el-button) { min-width: 132px; min-height: 42px; }
|
||||
.editor-page-footer :deep(.editor-actions > .el-button:last-child) { min-width: 172px; }
|
||||
:global(.first-leg-editor-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
|
||||
|
||||
@@ -10,17 +10,33 @@ test('renders loading, pending, isolated error, and published states', () => {
|
||||
assert.match(source, /role="alert"/)
|
||||
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
|
||||
assert.match(source, /role="status"/)
|
||||
assert.match(source, /待分柜 · 费用已发布/)
|
||||
assert.match(source, /费用已发布 · 待确认/)
|
||||
assert.match(source, /\$emit\('retry'\)/)
|
||||
})
|
||||
|
||||
test('shows the confirmed merchant inbound and fee fields without write actions', () => {
|
||||
test('supports a standalone dialog and keeps the unpushed billing-weight state visible', () => {
|
||||
assert.match(source, /dialogOnly\?: boolean/)
|
||||
assert.match(source, /v-if="!props\.dialogOnly"/)
|
||||
assert.match(source, /watch\(\(\) => props\.autoOpen/)
|
||||
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
|
||||
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
|
||||
assert.match(source, /平台推送计费重后,可在这里核对并确认/)
|
||||
assert.match(source, /@closed="emit\('closed'\)"/)
|
||||
})
|
||||
|
||||
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
|
||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||
assert.match(source, new RegExp(label))
|
||||
}
|
||||
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
||||
assert.match(source, /formatConversionPrecision\(/)
|
||||
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
|
||||
assert.match(source, /确认费用/)
|
||||
assert.match(source, /二次确认费用/)
|
||||
assert.match(source, /确认后该费用将锁定,平台不能再修改/)
|
||||
assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/)
|
||||
assert.match(source, /confirmMerchantInboundFee/)
|
||||
assert.match(source, /measurementItems/)
|
||||
assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
|
||||
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
|
||||
assert.match(source, /latestFee\.ruleCode/)
|
||||
assert.match(source, /latestFee\.rateCode/)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import { confirmMerchantInboundFee } from '@/modules/first-leg/api/inboundBilling'
|
||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||
import {
|
||||
formatConversionPrecision,
|
||||
@@ -17,24 +20,74 @@ const props = withDefaults(defineProps<{
|
||||
unavailable?: boolean
|
||||
unavailableMessage?: string
|
||||
error?: string
|
||||
autoOpen?: boolean
|
||||
dialogOnly?: boolean
|
||||
canConfirm?: boolean
|
||||
}>(), {
|
||||
data: null,
|
||||
loading: false,
|
||||
unavailable: false,
|
||||
unavailableMessage: '入库及费用结果尚未发布',
|
||||
unavailableMessage: '还未测量推送',
|
||||
error: '',
|
||||
autoOpen: false,
|
||||
dialogOnly: false,
|
||||
canConfirm: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
confirmed: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const feeDialogOpen = ref(false)
|
||||
const confirming = ref(false)
|
||||
watch(() => props.autoOpen, (autoOpen) => {
|
||||
if (autoOpen) feeDialogOpen.value = true
|
||||
}, { immediate: true })
|
||||
|
||||
async function confirmFee() {
|
||||
const fee = props.data?.latestFee
|
||||
const orderId = props.data?.inbound.summary.orderId
|
||||
if (!fee || !orderId || !props.canConfirm || confirming.value) return
|
||||
if (fee.customerConfirmationStatus === 'CONFIRMED') {
|
||||
ElMessage.info('当前费用已经确认,无需重复操作')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`请再次确认费用单 ${fee.feeNo},合计 ${formatInboundMoney(fee.totalAmount, fee.currency)}。确认后该费用将锁定,平台不能再修改。`,
|
||||
'二次确认费用',
|
||||
{ type: 'warning', confirmButtonText: '确认费用无误', cancelButtonText: '返回核对' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
confirming.value = true
|
||||
try {
|
||||
await confirmMerchantInboundFee(orderId, fee.feeVersion)
|
||||
ElMessage.success('费用确认成功,当前费用已锁定')
|
||||
feeDialogOpen.value = false
|
||||
emit('confirmed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '费用确认失败,请刷新后重试')
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
|
||||
<section v-if="!props.dialogOnly" class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
|
||||
<header class="inbound-billing-heading">
|
||||
<h3 id="inbound-billing-title">入库与费用</h3>
|
||||
<el-tag v-if="props.data" type="success" effect="light">待分柜 · 费用已发布</el-tag>
|
||||
<div v-if="props.data" class="inbound-billing-actions">
|
||||
<el-tag :type="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? 'success' : 'warning'" effect="light">
|
||||
{{ props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? '客户已确认 · 费用已锁定' : '费用已发布 · 待确认' }}
|
||||
</el-tag>
|
||||
<el-button v-if="props.data.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" @click="feeDialogOpen = true">确认费用</el-button>
|
||||
<el-button v-else plain @click="feeDialogOpen = true">查看费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
|
||||
@@ -55,7 +108,7 @@ defineEmits<{
|
||||
<Clock3 :size="22" />
|
||||
<div>
|
||||
<strong>{{ props.unavailableMessage }}</strong>
|
||||
<span>客服完成测量审核、入库并发布费用后,将在这里展示最终结果。</span>
|
||||
<span>平台完成测量审核并确认推送费用后,将在这里展示测量与计费结果。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +121,7 @@ defineEmits<{
|
||||
</dl>
|
||||
|
||||
<div class="inbound-result-section">
|
||||
<h4>实测结果</h4>
|
||||
<div class="inbound-section-title"><h4>测量数据</h4><el-tag size="small" type="warning" effect="light">后台推送</el-tag></div>
|
||||
<dl class="inbound-measurements">
|
||||
<div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div>
|
||||
<div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div>
|
||||
@@ -77,6 +130,17 @@ defineEmits<{
|
||||
<div><dt>材积重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.volumeWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>测量完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.measureCompletedAt) }}</dd></div>
|
||||
</dl>
|
||||
<el-table v-if="props.data.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
|
||||
<el-table-column type="index" label="#" width="54" />
|
||||
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
|
||||
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
|
||||
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
|
||||
<el-table-column prop="packageCount" label="箱数" min-width="76" />
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="inbound-result-section">
|
||||
@@ -138,12 +202,65 @@ defineEmits<{
|
||||
<p>{{ props.data.inbound.publicRemark }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body @closed="emit('closed')">
|
||||
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
|
||||
<el-skeleton :rows="4" animated />
|
||||
<span class="sr-only">正在加载计费重</span>
|
||||
</div>
|
||||
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
|
||||
<TriangleAlert :size="22" />
|
||||
<div><strong>计费重加载失败</strong><span>{{ props.error }}</span></div>
|
||||
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
|
||||
</div>
|
||||
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
|
||||
<Clock3 :size="22" />
|
||||
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<dl class="inbound-measurements fee-dialog-summary">
|
||||
<div><dt>计费总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
|
||||
<div><dt>计费总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>外箱规总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
||||
</dl>
|
||||
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
|
||||
<el-table-column type="index" label="#" width="54" />
|
||||
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
|
||||
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
|
||||
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
|
||||
<el-table-column prop="packageCount" label="箱数" min-width="76" />
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column prop="calculationRuleVersion" label="箱规规则" min-width="180" />
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'"
|
||||
class="fee-confirmed-alert"
|
||||
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-else class="fee-confirmed-alert" title="请核对全部计费数据。点击确认后还会进行一次确认,完成后费用将锁定。" type="warning" :closable="false" show-icon />
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="feeDialogOpen = false">关闭</el-button>
|
||||
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认费用</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-billing-panel { margin-top: 22px; }
|
||||
.inbound-billing-heading, .inbound-section-title, .fee-total { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.inbound-billing-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.inbound-billing-heading h3, .inbound-result-section h4 { margin: 0; color: var(--navy); }
|
||||
.inbound-billing-heading h3 { font-size: 15px; }
|
||||
.inbound-result-section h4 { font-size: 13px; }
|
||||
@@ -166,6 +283,9 @@ defineEmits<{
|
||||
.inbound-section-title > span { color: var(--muted); font-size: 10px; text-align: right; }
|
||||
.inbound-measurements { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; background: #f6f8f8; }
|
||||
.inbound-measurements > div { min-width: 0; padding: 12px; }
|
||||
.measurement-detail-table { margin-top: 12px; }
|
||||
.fee-dialog-summary { margin-top: 0; }
|
||||
.fee-confirmed-alert { margin-top: 14px; }
|
||||
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
|
||||
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
.pallet-location-list article > svg { color: var(--primary); }
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useFirstLegOrdersStore } from './store/firstLegOrders'
|
||||
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
|
||||
export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface MerchantInboundFeeSnapshot {
|
||||
feeStatus: MerchantInboundFeeStatus
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
measurementId?: string | null
|
||||
measurementSource?: string | null
|
||||
chargeRuleId: string
|
||||
ruleCode: string
|
||||
chargeRuleVersion: number
|
||||
@@ -98,9 +100,55 @@ export interface MerchantInboundFeeSnapshot {
|
||||
confirmedAt: string | null
|
||||
sentAt: string | null
|
||||
nextProcessStatus: MerchantInboundProcessStatus | null
|
||||
customerConfirmationStatus: 'PENDING_CONFIRMATION' | 'CONFIRMED'
|
||||
customerConfirmedBy: string | null
|
||||
customerConfirmedByName: string | null
|
||||
customerConfirmedAt: string | null
|
||||
}
|
||||
|
||||
export interface MerchantMeasurementItem {
|
||||
id: string
|
||||
itemNo: string | null
|
||||
boxSpecification: string | null
|
||||
packageCount: number | null
|
||||
lengthCm: number | null
|
||||
widthCm: number | null
|
||||
heightCm: number | null
|
||||
unitVolumeCbm: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
chargeRuleId: string | null
|
||||
coefficientMode: 'ADD' | 'MULTIPLY' | null
|
||||
calculationRuleVersion: string | null
|
||||
}
|
||||
|
||||
export interface MerchantBillingBasis {
|
||||
chargeMode: string
|
||||
chargeUnit: string
|
||||
chargeableBasis: string
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
convertedQuantity: number
|
||||
chargeableQuantity: number
|
||||
chargeableWeightKg: number | null
|
||||
chargeableVolumeCbm: number | null
|
||||
}
|
||||
|
||||
export interface MerchantInboundBilling {
|
||||
inbound: MerchantInboundResult
|
||||
measurementSummary: {
|
||||
measurementId: string | null
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
calculationSource: 'OUTER' | string | null
|
||||
}
|
||||
measurementItems: MerchantMeasurementItem[]
|
||||
billingBasis: MerchantBillingBasis
|
||||
latestFee: MerchantInboundFeeSnapshot
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ test('opens order creation as a dedicated protected page while keeping draft edi
|
||||
assert.match(editorShellSource, /width="94vw"/)
|
||||
assert.doesNotMatch(editorShellSource, /<el-drawer/)
|
||||
assert.match(editorShellSource, /返回订单列表/)
|
||||
assert.match(editorShellSource, /\.editor-page-footer :deep\(\.editor-actions\) \{[^}]*justify-content: center;/)
|
||||
})
|
||||
|
||||
test('exposes real list, draft, submit, and detail workflow entries without cancellation', () => {
|
||||
@@ -330,6 +331,7 @@ test('collects the backend-required first-leg fields', () => {
|
||||
|
||||
test('distills order creation into the confirmed prototype sections with server pricing', () => {
|
||||
const cargoTableSource = source.slice(source.indexOf('<table class="cargo-edit-table">'), source.indexOf('</table>', source.indexOf('<table class="cargo-edit-table">')))
|
||||
const cargoToolbarSource = source.slice(source.indexOf('<div class="cargo-table-toolbar">'), source.indexOf('<div class="cargo-table-wrap">', source.indexOf('<div class="cargo-table-toolbar">')))
|
||||
assert.match(source, /<h3>目的地信息<\/h3>/)
|
||||
assert.match(source, /<h3>客户下单<\/h3>/)
|
||||
assert.match(source, /<h3>货物明细<\/h3>/)
|
||||
@@ -339,12 +341,20 @@ test('distills order creation into the confirmed prototype sections with server
|
||||
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
|
||||
assert.match(source, /<table class="cargo-edit-table">/)
|
||||
assert.match(source, /auth\.hasPermission\('order:first-leg:create'\)[\s\S]*批量导入订单/)
|
||||
assert.doesNotMatch(source, /create-batch-import-entry|需要一次创建多条订单|页面上方“Excel 批量导入”/)
|
||||
assert.doesNotMatch(source, /create-batch-import-entry/)
|
||||
assert.match(cargoToolbarSource, /isCreatePage && !routeDraftId && canCreateOrder/)
|
||||
assert.match(cargoToolbarSource, /downloadBatchTemplate[\s\S]*下载 Excel 模板/)
|
||||
assert.match(cargoToolbarSource, /openBatchImport[\s\S]*Excel 批量导入/)
|
||||
assert.match(cargoToolbarSource, /Excel 导入用于一次创建多条订单草稿/)
|
||||
assert.match(source, /导入成功的数据只创建草稿,不会直接提交审核/)
|
||||
assert.match(source, /下载正式模板[\s\S]*选择 Excel/)
|
||||
assert.match(source, /仅支持 \.xlsx,文件不超过 5MB、最多 50 行/)
|
||||
assert.match(source, /cargo\.woodenProduct(true\/false)[\s\S]*customsInspection 必须填写 true/)
|
||||
assert.match(source, /直接选择模板内的中文下拉项,不需要填写英文代码或内部编号/)
|
||||
assert.match(source, /选择木制品时,模板会要求同步选择需要商检/)
|
||||
assert.match(source, /下载 Excel 错误报告/)
|
||||
assert.match(source, /\^excel-row-\(\\d\+\)\$[\s\S]*item\.index \+ 3/)
|
||||
assert.match(source, /Excel 第 \{\{ batchImportExcelRow\(item\) \}\} 行/)
|
||||
assert.doesNotMatch(source, /未填写行标识/)
|
||||
assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/)
|
||||
assert.match(source, /添加行[\s\S]*删除所选行/)
|
||||
assert.doesNotMatch(source, /货物明细导入模板\.xls/)
|
||||
@@ -481,8 +491,9 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
|
||||
assert.match(source, /<InboundBillingPanel/)
|
||||
assert.match(source, /@retry="retryInboundBilling"/)
|
||||
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
|
||||
assert.match(source, /inboundBillingUnavailableMessage\.value = error\.message \|\| '入库及费用结果尚未发布'/)
|
||||
assert.match(source, /error\.message \|\| unavailableMessage/)
|
||||
assert.match(source, /order\.status === 'DRAFT'/)
|
||||
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
|
||||
assert.match(source, /订单提交后可查看入库结果/)
|
||||
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
|
||||
})
|
||||
@@ -492,11 +503,28 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
|
||||
assert.doesNotMatch(templateSource, /租户|tenantId|所属企业|企业筛选/)
|
||||
})
|
||||
|
||||
test('discards stale inbound billing responses when the drawer closes or the user changes orders', () => {
|
||||
test('opens billing-weight confirmation independently from order details', () => {
|
||||
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
|
||||
assert.match(handler, /feeConfirmationOpen\.value = true/)
|
||||
assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
|
||||
assert.doesNotMatch(handler, /openDetail\(/)
|
||||
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
|
||||
assert.match(source, /unavailable-message="未推送计费重"/)
|
||||
})
|
||||
|
||||
test('shows the standalone billing-weight entry at every order node', () => {
|
||||
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
|
||||
const entries = templateSource.match(/<el-button link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
|
||||
assert.equal(entries.length, 2)
|
||||
assert.doesNotMatch(templateSource, /v-if="[^"]*status === 'WAIT_SPLIT'[^"]*"[^>]*>确认计费重/)
|
||||
})
|
||||
|
||||
test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
|
||||
assert.match(source, /let inboundBillingRequestSequence = 0/)
|
||||
assert.match(source, /const sequence = \+\+inboundBillingRequestSequence/)
|
||||
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*detailOpen\.value[\s\S]*detailId\.value === orderId/)
|
||||
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*inboundBillingOrderId\.value === orderId[\s\S]*detailOpen\.value \|\| feeConfirmationOpen\.value/)
|
||||
assert.match(source, /watch\(detailOpen,[\s\S]*resetInboundBillingState\(\)/)
|
||||
assert.match(source, /watch\(feeConfirmationOpen,[\s\S]*resetInboundBillingState\(\)/)
|
||||
})
|
||||
|
||||
test('protects unsaved drafts and exposes recoverable load failures', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -102,7 +102,11 @@ test('binds exactly one destination warehouse through a destination dialog', ()
|
||||
assert.match(source, /availableDestinationWarehouses\.value\.find/)
|
||||
assert.match(source, /destinationWarehouseId: warehouse\.id/)
|
||||
assert.match(source, /destinationWarehouseId: ''/)
|
||||
assert.match(source, /const required = \[destinationForm\.privateName, destinationForm\.countryCode\]/)
|
||||
assert.match(source, /getMerchantOrderAddresses\('PRIVATE_DESTINATION'\)/)
|
||||
assert.match(source, /v-model="destinationForm\.privateAddressId"/)
|
||||
assert.match(source, /privateDestinationAddressId: String\(address\.id\)/)
|
||||
assert.match(source, /privateDestinationAddressId: row\.destinationType === 'PRIVATE' \? row\.privateDestinationAddressId : ''/)
|
||||
assert.match(source, /hasCompletePrivateContact\(row\)/)
|
||||
assert.doesNotMatch(source, /v-model="destinationForm\.privateName"/)
|
||||
assert.match(source, /确认绑定/)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,9 @@ import { MapPin, Plus, Send, Trash2 } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/store/auth'
|
||||
import {
|
||||
getMerchantInboundCodePolicy,
|
||||
getMerchantOrderAddresses,
|
||||
getMerchantPricingChannels,
|
||||
type CustomerOrderAddress,
|
||||
type InboundCodePolicy,
|
||||
type MerchantPricingChannel,
|
||||
} from '@/modules/first-leg/api/firstLeg'
|
||||
@@ -32,6 +34,7 @@ interface QuickOrderRow {
|
||||
destinationWarehouseName: string
|
||||
destinationCountryCode: string
|
||||
amazonWarehouseCode: string
|
||||
privateDestinationAddressId: string
|
||||
consigneeContactName: string
|
||||
consigneePhone: string
|
||||
consigneeCity: string
|
||||
@@ -64,6 +67,8 @@ const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
|
||||
const channelOptions = ref<QuickChannelOption[]>([])
|
||||
const destinationDialogOpen = ref(false)
|
||||
const destinationLoading = ref(false)
|
||||
const privateAddresses = ref<CustomerOrderAddress[]>([])
|
||||
const privateAddressesError = ref('')
|
||||
const editingDestinationRow = ref<QuickOrderRow>()
|
||||
const draftResults = reactive<QuickDraftResult[]>([])
|
||||
const resultDialogOpen = ref(false)
|
||||
@@ -71,13 +76,7 @@ const activeBatchKey = ref('')
|
||||
const destinationForm = reactive({
|
||||
type: 'FBA' as DestinationType,
|
||||
warehouseId: '',
|
||||
privateName: '',
|
||||
countryCode: 'US',
|
||||
contactName: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
address: '',
|
||||
postalCode: '',
|
||||
privateAddressId: '',
|
||||
})
|
||||
let rowSequence = 0
|
||||
let productSequence = 0
|
||||
@@ -101,6 +100,7 @@ function createRow(): QuickOrderRow {
|
||||
destinationWarehouseName: '',
|
||||
destinationCountryCode: '',
|
||||
amazonWarehouseCode: '',
|
||||
privateDestinationAddressId: '',
|
||||
consigneeContactName: '',
|
||||
consigneePhone: '',
|
||||
consigneeCity: '',
|
||||
@@ -149,6 +149,10 @@ function selectedChannel(row: QuickOrderRow) {
|
||||
return channelOptions.value.find((channel) => channelKey(channel.warehouseId, channel.channelCode) === row.channelKey)
|
||||
}
|
||||
|
||||
function addressLabel(address: CustomerOrderAddress) {
|
||||
return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}`
|
||||
}
|
||||
|
||||
function createConsigneeSnapshot(row: QuickOrderRow) {
|
||||
return {
|
||||
id: '',
|
||||
@@ -187,7 +191,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
|
||||
shipperPartySnapshot: null,
|
||||
shipper: '',
|
||||
consigneePartyId: '',
|
||||
privateDestinationAddressId: '',
|
||||
privateDestinationAddressId: row.destinationType === 'PRIVATE' ? row.privateDestinationAddressId : '',
|
||||
consigneePartySnapshot: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
|
||||
? createConsigneeSnapshot(row) : null,
|
||||
consignee: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
|
||||
@@ -231,21 +235,24 @@ async function openDestinationDialog(row: QuickOrderRow, typeValue: string | num
|
||||
Object.assign(destinationForm, {
|
||||
type,
|
||||
warehouseId: row.destinationType === type ? row.destinationWarehouseId : '',
|
||||
privateName: row.destinationType === type ? row.destinationWarehouseName : '',
|
||||
countryCode: row.destinationType === type ? row.destinationCountryCode || 'US' : 'US',
|
||||
contactName: row.destinationType === type ? row.consigneeContactName : '',
|
||||
phone: row.destinationType === type ? row.consigneePhone : '',
|
||||
city: row.destinationType === type ? row.consigneeCity : '',
|
||||
address: row.destinationType === type ? row.consigneeAddress : '',
|
||||
postalCode: row.destinationType === type ? row.consigneePostalCode : '',
|
||||
privateAddressId: row.destinationType === type ? row.privateDestinationAddressId : '',
|
||||
})
|
||||
destinationDialogOpen.value = true
|
||||
if (type === 'PRIVATE') return
|
||||
destinationLoading.value = true
|
||||
try {
|
||||
await store.loadWarehouseType(type)
|
||||
if (type === 'PRIVATE') {
|
||||
privateAddressesError.value = ''
|
||||
privateAddresses.value = await getMerchantOrderAddresses('PRIVATE_DESTINATION')
|
||||
} else {
|
||||
await store.loadWarehouseType(type)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '目的仓加载失败,请稍后重试')
|
||||
const message = error instanceof Error ? error.message : '目的仓加载失败,请稍后重试'
|
||||
if (type === 'PRIVATE') {
|
||||
privateAddresses.value = []
|
||||
privateAddressesError.value = message
|
||||
}
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
destinationLoading.value = false
|
||||
}
|
||||
@@ -266,6 +273,7 @@ function bindDestination() {
|
||||
destinationWarehouseName: warehouse.name,
|
||||
destinationCountryCode: warehouse.countryCode,
|
||||
amazonWarehouseCode: destinationForm.type === 'FBA' ? warehouse.code : '',
|
||||
privateDestinationAddressId: '',
|
||||
consigneeContactName: '',
|
||||
consigneePhone: '',
|
||||
consigneeCity: '',
|
||||
@@ -273,26 +281,23 @@ function bindDestination() {
|
||||
consigneePostalCode: '',
|
||||
})
|
||||
} else {
|
||||
const required = [destinationForm.privateName, destinationForm.countryCode]
|
||||
if (required.some((value) => !value.trim())) {
|
||||
ElMessage.warning('请填写私人仓名称和目的国家')
|
||||
return
|
||||
}
|
||||
if (!/^[A-Za-z]{2}$/.test(destinationForm.countryCode.trim())) {
|
||||
ElMessage.warning('目的国家请输入两位英文代码,例如 US')
|
||||
const address = privateAddresses.value.find((item) => String(item.id) === destinationForm.privateAddressId)
|
||||
if (!address) {
|
||||
ElMessage.warning('请选择企业已保存的私人仓地址')
|
||||
return
|
||||
}
|
||||
Object.assign(row, {
|
||||
destinationType: 'PRIVATE',
|
||||
destinationWarehouseId: '',
|
||||
destinationWarehouseName: destinationForm.privateName.trim(),
|
||||
destinationCountryCode: destinationForm.countryCode.trim().toUpperCase(),
|
||||
destinationWarehouseName: address.addressName,
|
||||
destinationCountryCode: address.countryCode,
|
||||
amazonWarehouseCode: '',
|
||||
consigneeContactName: destinationForm.contactName.trim(),
|
||||
consigneePhone: destinationForm.phone.trim(),
|
||||
consigneeCity: destinationForm.city.trim(),
|
||||
consigneeAddress: destinationForm.address.trim(),
|
||||
consigneePostalCode: destinationForm.postalCode.trim(),
|
||||
privateDestinationAddressId: String(address.id),
|
||||
consigneeContactName: address.contactName,
|
||||
consigneePhone: address.phone,
|
||||
consigneeCity: address.city,
|
||||
consigneeAddress: address.addressLine,
|
||||
consigneePostalCode: address.postalCode ?? '',
|
||||
})
|
||||
}
|
||||
destinationDialogOpen.value = false
|
||||
@@ -347,6 +352,7 @@ async function submitQuickOrder() {
|
||||
return
|
||||
}
|
||||
const incompleteIndex = rows.findIndex((row) => !row.channelKey || !row.destinationWarehouseName
|
||||
|| (row.destinationType === 'PRIVATE' && !row.privateDestinationAddressId)
|
||||
|| row.products.some((product) => !product.sku.trim() || product.packageCount < 1))
|
||||
if (incompleteIndex >= 0) {
|
||||
ElMessage.warning(`请完善第 ${incompleteIndex + 1} 行的渠道、目的仓、产品 SKU 和箱数`)
|
||||
@@ -481,7 +487,7 @@ onMounted(loadChannels)
|
||||
<td class="products-cell">
|
||||
<div v-for="(product, productIndex) in row.products" :key="product.id" class="product-fields">
|
||||
<el-input v-model="product.sku" maxlength="64" placeholder="产品 SKU" />
|
||||
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" />
|
||||
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" placeholder="请输入箱数" />
|
||||
<el-button v-if="row.products.length > 1" text type="danger" aria-label="删除产品" @click="removeProduct(row, productIndex)"><Trash2 :size="15" /></el-button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -513,15 +519,13 @@ onMounted(loadChannels)
|
||||
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="私人地址名称" required><el-input v-model="destinationForm.privateName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" /></el-form-item>
|
||||
<div class="destination-private-grid">
|
||||
<el-form-item label="目的国家" required><el-input v-model="destinationForm.countryCode" maxlength="2" placeholder="US" /></el-form-item>
|
||||
<el-form-item label="联系人" required><el-input v-model="destinationForm.contactName" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="destinationForm.phone" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="城市" required><el-input v-model="destinationForm.city" maxlength="64" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="详细地址" required><el-input v-model="destinationForm.address" maxlength="256" /></el-form-item>
|
||||
<el-form-item label="邮编"><el-input v-model="destinationForm.postalCode" maxlength="20" /></el-form-item>
|
||||
<el-form-item label="私人仓地址" required>
|
||||
<el-select v-model="destinationForm.privateAddressId" filterable :loading="destinationLoading" placeholder="请选择企业已保存的私人仓地址">
|
||||
<el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" />
|
||||
</el-select>
|
||||
<small v-if="privateAddressesError">私人仓地址加载失败,请关闭后重试。</small>
|
||||
<small v-else-if="!destinationLoading && privateAddresses.length === 0">暂无已保存的私人仓地址,请先在普通下单页面维护。</small>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
|
||||
@@ -25,3 +25,14 @@ test('keeps internal IDs only as non-rendered routing, selection, and request va
|
||||
assert.match(overseas, /:value="candidate\.orderId"/)
|
||||
assert.match(firstLeg, /detailId\.value = order\.id/)
|
||||
})
|
||||
|
||||
test('keeps overseas warehouse operations first and renders readable list values', () => {
|
||||
assert.match(overseas, /columns: \['操作', '贴标申请单号'/)
|
||||
assert.match(overseas, /columns: \['操作', '转运单号'/)
|
||||
assert.match(overseas, /columns: \['操作', '废弃申请单号'/)
|
||||
assert.match(overseas, /columns: \['操作', '退货换标单号'/)
|
||||
assert.match(overseas, /function statusLabel\(value: string\)/)
|
||||
assert.match(overseas, /function deliveryMethodLabel\(value: string\)/)
|
||||
assert.match(overseas, /function formatDateTime\(value: string\)/)
|
||||
assert.match(overseas, /<td><button class="warehouse-link"[^>]*>.*?查看<\/button><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user