diff --git a/AGENTS.md b/AGENTS.md index 78ada59..e36059e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 和幂等键 | diff --git a/src/modules/first-leg/views/QuickOrderView.test.mjs b/src/modules/first-leg/views/QuickOrderView.test.mjs index c806061..f9b5e1c 100644 --- a/src/modules/first-leg/views/QuickOrderView.test.mjs +++ b/src/modules/first-leg/views/QuickOrderView.test.mjs @@ -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, /确认绑定/) }) diff --git a/src/modules/first-leg/views/QuickOrderView.vue b/src/modules/first-leg/views/QuickOrderView.vue index c0f5244..92951ed 100644 --- a/src/modules/first-leg/views/QuickOrderView.vue +++ b/src/modules/first-leg/views/QuickOrderView.vue @@ -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(null) const channelOptions = ref([]) const destinationDialogOpen = ref(false) const destinationLoading = ref(false) +const privateAddresses = ref([]) +const privateAddressesError = ref('') const editingDestinationRow = ref() const draftResults = reactive([]) 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) 平台暂未维护可用目的仓,请联系平台管理员。