This commit is contained in:
2026-09-04 10:51:28 +08:00
parent 213f4da651
commit ecec741a9d
3 changed files with 52 additions and 44 deletions
+2 -2
View File
@@ -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` 和幂等键 |
@@ -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, /确认绑定/)
})
+45 -41
View File
@@ -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" /></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>