Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts: # src/modules/first-leg/views/QuickOrderView.vue
This commit is contained in:
@@ -43,11 +43,11 @@
|
||||
|
||||
| 业务 | 模块入口 | 联查模块或服务端 |
|
||||
|---|---|---|
|
||||
| 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` |
|
||||
| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常旋转的国内/海外 Token 会话统一持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
|
||||
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
|
||||
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
|
||||
| 头程下单、中文批量导入、入仓码关系、箱唛与履约进度 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
|
||||
| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,入库费用 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。客户确认当前 `SENT` 费用时二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
|
||||
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
|
||||
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
||||
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
|
||||
|
||||
@@ -14,6 +14,16 @@ test('renders loading, pending, isolated error, and published states', () => {
|
||||
assert.match(source, /\$emit\('retry'\)/)
|
||||
})
|
||||
|
||||
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))
|
||||
|
||||
@@ -21,6 +21,7 @@ const props = withDefaults(defineProps<{
|
||||
unavailableMessage?: string
|
||||
error?: string
|
||||
autoOpen?: boolean
|
||||
dialogOnly?: boolean
|
||||
canConfirm?: boolean
|
||||
}>(), {
|
||||
data: null,
|
||||
@@ -29,18 +30,20 @@ const props = withDefaults(defineProps<{
|
||||
unavailableMessage: '还未测量推送',
|
||||
error: '',
|
||||
autoOpen: false,
|
||||
dialogOnly: false,
|
||||
canConfirm: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
confirmed: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const feeDialogOpen = ref(false)
|
||||
const confirming = ref(false)
|
||||
watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
||||
if (autoOpen && data) feeDialogOpen.value = true
|
||||
watch(() => props.autoOpen, (autoOpen) => {
|
||||
if (autoOpen) feeDialogOpen.value = true
|
||||
}, { immediate: true })
|
||||
|
||||
async function confirmFee() {
|
||||
@@ -75,7 +78,7 @@ async function confirmFee() {
|
||||
</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>
|
||||
<div v-if="props.data" class="inbound-billing-actions">
|
||||
@@ -200,8 +203,24 @@ async function confirmFee() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body>
|
||||
<dl v-if="props.data" class="inbound-measurements fee-dialog-summary">
|
||||
</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>
|
||||
@@ -222,7 +241,7 @@ async function confirmFee() {
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
v-if="props.data?.latestFee.customerConfirmationStatus === 'CONFIRMED'"
|
||||
v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'"
|
||||
class="fee-confirmed-alert"
|
||||
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
|
||||
type="success"
|
||||
@@ -230,12 +249,12 @@ async function confirmFee() {
|
||||
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>
|
||||
</section>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -33,7 +33,7 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
|
||||
assert.match(source, /function openCreate\(\)/)
|
||||
assert.match(source, /async function saveDraft\(\)/)
|
||||
assert.match(source, /function submitOrder\(\)/)
|
||||
assert.match(source, /async function openDetail\(order: FirstLegOrder, showFeeDialog = false\)/)
|
||||
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[/)
|
||||
assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\{ includeDrafts: true \}\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/)
|
||||
@@ -491,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/)
|
||||
})
|
||||
@@ -502,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', () => {
|
||||
|
||||
@@ -200,8 +200,9 @@ const inboundBilling = ref<MerchantInboundBilling | null>(null)
|
||||
const inboundBillingLoading = ref(false)
|
||||
const inboundBillingUnavailable = ref(false)
|
||||
const inboundBillingUnavailableMessage = ref('还未测量推送')
|
||||
const openFeeDialogWithDetail = ref(false)
|
||||
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)
|
||||
@@ -650,12 +651,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
|
||||
@@ -1195,8 +1200,8 @@ async function openEdit(order: FirstLegOrder) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(order: FirstLegOrder, showFeeDialog = false) {
|
||||
openFeeDialogWithDetail.value = showFeeDialog
|
||||
async function openDetail(order: FirstLegOrder) {
|
||||
feeConfirmationOpen.value = false
|
||||
detailId.value = order.id
|
||||
detailCargoPage.value = 1
|
||||
detailOpen.value = true
|
||||
@@ -1216,7 +1221,11 @@ async function openDetail(order: FirstLegOrder, showFeeDialog = false) {
|
||||
}
|
||||
|
||||
function openFeeConfirmation(order: FirstLegOrder) {
|
||||
void openDetail(order, true)
|
||||
detailOpen.value = false
|
||||
resetInboundBillingState()
|
||||
inboundBillingUnavailableMessage.value = '未推送计费重'
|
||||
feeConfirmationOpen.value = true
|
||||
void loadInboundBilling(order, '未推送计费重')
|
||||
}
|
||||
|
||||
async function retryDetail() {
|
||||
@@ -1226,6 +1235,7 @@ async function retryDetail() {
|
||||
|
||||
function resetInboundBillingState() {
|
||||
inboundBillingRequestSequence += 1
|
||||
inboundBillingOrderId.value = ''
|
||||
inboundBilling.value = null
|
||||
inboundBillingLoading.value = false
|
||||
inboundBillingUnavailable.value = false
|
||||
@@ -1235,21 +1245,24 @@ function resetInboundBillingState() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1262,7 +1275,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 : '入库与费用加载失败'
|
||||
}
|
||||
@@ -1272,8 +1287,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() {
|
||||
@@ -1782,7 +1801,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 v-if="(row as FirstLegOrder).status === 'WAIT_SPLIT'" link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</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>
|
||||
@@ -1848,7 +1867,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 v-if="order.status === 'WAIT_SPLIT'" link type="primary" @click="openFeeConfirmation(order)">确认计费重</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>
|
||||
@@ -2156,19 +2175,32 @@ async function searchOrders(model: QueryModel) {
|
||||
<OrderProgressPanel :order="detailOrder" />
|
||||
<InboundBillingPanel
|
||||
:data="inboundBilling"
|
||||
:auto-open="openFeeDialogWithDetail"
|
||||
:loading="inboundBillingLoading"
|
||||
:unavailable="inboundBillingUnavailable"
|
||||
:unavailable-message="inboundBillingUnavailableMessage"
|
||||
:error="inboundBillingError"
|
||||
:can-confirm="canConfirmInboundFee"
|
||||
@retry="retryInboundBilling"
|
||||
@confirmed="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 和箱数`)
|
||||
@@ -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" placeholder="请输入收件人姓名" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="destinationForm.phone" maxlength="32" placeholder="请输入收件人联系电话" /></el-form-item>
|
||||
<el-form-item label="城市" required><el-input v-model="destinationForm.city" maxlength="64" placeholder="请输入收件城市" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="详细地址" required><el-input v-model="destinationForm.address" maxlength="256" placeholder="请输入收件详细地址" /></el-form-item>
|
||||
<el-form-item label="邮编"><el-input v-model="destinationForm.postalCode" maxlength="20" placeholder="请输入邮编(选填)" /></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>
|
||||
|
||||
Reference in New Issue
Block a user