From 1305ae46697daa5df11bcae20c95d4be21c12418 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 19:34:37 +0800 Subject: [PATCH] feat(client): align account and order foundations --- .../account/utils/accountProfile.test.mjs | 12 +- src/modules/account/utils/accountProfile.ts | 21 +- .../account/views/AccountProfileView.test.mjs | 16 +- .../account/views/AccountProfileView.vue | 96 ++++- src/modules/auth/api/auth.ts | 4 +- src/modules/auth/store/auth.ts | 9 +- src/modules/auth/views/AuthViews.test.mjs | 20 +- src/modules/auth/views/LoginView.vue | 31 +- src/modules/auth/views/RegisterView.vue | 10 +- src/modules/first-leg/api/firstLeg.ts | 71 +++- .../first-leg/store/firstLegOrders.test.mjs | 13 +- src/modules/first-leg/store/firstLegOrders.ts | 39 +- src/modules/first-leg/types/firstLegOrder.ts | 5 +- .../first-leg/utils/firstLegOrder.test.mjs | 11 + src/modules/first-leg/utils/firstLegOrder.ts | 6 + .../views/FirstLegOrdersView.test.mjs | 19 +- .../first-leg/views/FirstLegOrdersView.vue | 384 +++++++++++------- .../first-leg/views/QuickOrderView.vue | 4 +- .../api/merchantIam.test.mjs | 13 +- .../tenant-management/api/merchantIam.ts | 32 +- src/modules/tenant-management/public.ts | 2 + .../views/CompanyProfileView.vue | 37 +- .../views/TenantAdministrationViews.test.mjs | 5 +- 23 files changed, 602 insertions(+), 258 deletions(-) diff --git a/src/modules/account/utils/accountProfile.test.mjs b/src/modules/account/utils/accountProfile.test.mjs index 3b5d62c..85fe484 100644 --- a/src/modules/account/utils/accountProfile.test.mjs +++ b/src/modules/account/utils/accountProfile.test.mjs @@ -3,20 +3,26 @@ import test from 'node:test' import { getPasswordStrength, validateEditableAccountProfile, + validateLoginIdentifierChange, validatePasswordChange, } from './accountProfile.ts' test('accepts a complete editable account profile', () => { assert.deepEqual(validateEditableAccountProfile({ realName: '企业管理员', - phone: '13800006601', email: 'admin@example.com', }), []) }) test('reports invalid profile fields together', () => { - const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' }) - assert.equal(errors.length, 3) + const errors = validateEditableAccountProfile({ realName: '', email: 'bad' }) + assert.equal(errors.length, 2) +}) + +test('validates the frozen username, mainland mobile and current-password rules', () => { + assert.deepEqual(validateLoginIdentifierChange({ username: 'Merchant.Admin', phone: '13800006601', currentPassword: 'Current123' }), []) + assert.equal(validateLoginIdentifierChange({ username: '1bad', phone: '10086', currentPassword: '' }).length, 3) + assert.match(validateLoginIdentifierChange({ username: 'Merchant.Admin', phone: '11111111111', currentPassword: 'Current123' }).join('|'), /有效的中国大陆手机号/) }) test('validates password length, equality and confirmation', () => { diff --git a/src/modules/account/utils/accountProfile.ts b/src/modules/account/utils/accountProfile.ts index 5405125..a9413c8 100644 --- a/src/modules/account/utils/accountProfile.ts +++ b/src/modules/account/utils/accountProfile.ts @@ -1,9 +1,14 @@ export interface EditableAccountProfile { realName: string - phone: string email: string } +export interface LoginIdentifierChangePayload { + username: string + phone: string + currentPassword: string +} + export interface PasswordChangePayload { currentPassword: string newPassword: string @@ -11,20 +16,30 @@ export interface PasswordChangePayload { } const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/ +const MAINLAND_MOBILE_PATTERN = /^1[3-9]\d{9}$/ export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] { const errors: string[] = [] const realName = profile.realName.trim() - const phone = profile.phone.trim() const email = profile.email.trim() if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符') - if (phone.length > 32) errors.push('联系电话不能超过 32 个字符') if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址') return errors } +export function validateLoginIdentifierChange(payload: LoginIdentifierChangePayload): string[] { + const errors: string[] = [] + if (!USERNAME_PATTERN.test(payload.username.trim())) { + errors.push('用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成') + } + if (!MAINLAND_MOBILE_PATTERN.test(payload.phone.trim())) errors.push('请输入有效的中国大陆手机号') + if (!payload.currentPassword) errors.push('请输入当前密码') + return errors +} + export function validatePasswordChange(payload: PasswordChangePayload): string[] { const errors: string[] = [] diff --git a/src/modules/account/views/AccountProfileView.test.mjs b/src/modules/account/views/AccountProfileView.test.mjs index d4327b8..959f702 100644 --- a/src/modules/account/views/AccountProfileView.test.mjs +++ b/src/modules/account/views/AccountProfileView.test.mjs @@ -6,6 +6,7 @@ const routerSource = fs.readFileSync(new URL('../routes.ts', import.meta.url), ' const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8') const authSource = fs.readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8') const authApiSource = fs.readFileSync(new URL('../../auth/api/auth.ts', import.meta.url), 'utf8') +const iamApiSource = fs.readFileSync(new URL('../../tenant-management/api/merchantIam.ts', import.meta.url), 'utf8') test('registers the account profile route and navigation entry', () => { assert.match(routerSource, /path: 'account\/profile'[\s\S]*?component: \(\) => import\('\.\/views\/AccountProfileView\.vue'\)/) @@ -21,12 +22,25 @@ test('implements profile view, edit, save and cancel states', () => { assert.match(viewSource, /function startEditing\(\)/) assert.match(viewSource, /function cancelEditing\(\)/) assert.match(viewSource, /async function saveProfile\(\)/) - assert.match(viewSource, /账号标识暂不支持在客户端修改/) + assert.match(viewSource, /请在“登录标识”中修改/) assert.match(viewSource, /企业归属由平台审核关系确定/) assert.match(authSource, /async function savePersonalProfile\(/) assert.match(viewSource, /await auth\.savePersonalProfile/) }) +test('changes username and mobile with current-password verification then clears every local session', () => { + assert.match(viewSource, /name="identifiers"/) + assert.match(viewSource, /validateLoginIdentifierChange/) + assert.match(viewSource, /await updateMerchantLoginIdentifiers/) + assert.match(iamApiSource, /url: '\/merchant\/v1\/iam\/profile\/login-identifiers'/) + assert.match(iamApiSource, /data: payload/) + assert.match(viewSource, /error\.code === 'A0409'/) + assert.match(viewSource, /error\.code === 'A0401'/) + assert.match(viewSource, /auth\.clearSession\(\)/) + assert.match(viewSource, /reason: 'identifiers-updated'/) + assert.doesNotMatch(viewSource, /auth\.logout\(\)[\s\S]*identifiers-updated/) +}) + test('implements password validation, strength feedback and double-submit protection', () => { assert.match(viewSource, /async function changePassword\(\)/) assert.match(viewSource, /validatePasswordChange/) diff --git a/src/modules/account/views/AccountProfileView.vue b/src/modules/account/views/AccountProfileView.vue index e678bd7..a495a2f 100644 --- a/src/modules/account/views/AccountProfileView.vue +++ b/src/modules/account/views/AccountProfileView.vue @@ -13,14 +13,18 @@ import { X, } from '@lucide/vue' import { changeCurrentPassword, useAuthStore } from '@/modules/auth/public' +import { ApiError } from '@/api/http' +import { updateMerchantLoginIdentifiers } from '@/modules/tenant-management/public' import { getPasswordStrength, validateEditableAccountProfile, + validateLoginIdentifierChange, validatePasswordChange, + type LoginIdentifierChangePayload, type PasswordChangePayload, } from '@/modules/account/utils/accountProfile' -type AccountTab = 'profile' | 'password' +type AccountTab = 'profile' | 'identifiers' | 'password' interface ProfileForm { username: string @@ -35,9 +39,12 @@ const router = useRouter() const activeTab = ref('profile') const editing = ref(false) const profileSaving = ref(false) +const identifiersSaving = ref(false) +const identifiersError = ref('') const passwordSaving = ref(false) const passwordChangedAt = ref('') const profileFormRef = ref() +const identifiersFormRef = ref() const passwordFormRef = ref() const profileForm = reactive({ ...auth.profile }) @@ -46,20 +53,38 @@ const passwordForm = reactive({ newPassword: '', confirmPassword: '', }) +const identifiersForm = reactive({ + username: auth.profile.username, + phone: auth.profile.phone, + currentPassword: '', +}) const profileRules: FormRules = { realName: [ { required: true, message: '请输入真实姓名', trigger: 'blur' }, { min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' }, ], - phone: [ - { max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' }, - ], email: [ { type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }, ], } +const identifierRules: FormRules = { + username: [{ + required: true, + pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/, + message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成', + trigger: 'blur', + }], + phone: [{ + required: true, + pattern: /^1[3-9]\d{9}$/, + message: '请输入有效的中国大陆手机号', + trigger: 'blur', + }], + currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }], +} + const passwordRules: FormRules = { currentPassword: [{ required: true, @@ -129,7 +154,6 @@ async function saveProfile() { try { await auth.savePersonalProfile({ realName: profileForm.realName.trim(), - phone: profileForm.phone.trim(), email: profileForm.email.trim(), }) syncProfileForm() @@ -142,6 +166,42 @@ async function saveProfile() { } } +function resetIdentifiersForm() { + Object.assign(identifiersForm, { + username: auth.profile.username, + phone: auth.profile.phone, + currentPassword: '', + }) + identifiersError.value = '' + identifiersFormRef.value?.clearValidate() +} + +async function changeLoginIdentifiers() { + if (identifiersSaving.value) return + identifiersError.value = '' + const valid = await identifiersFormRef.value?.validate().catch(() => false) + if (!valid || validateLoginIdentifierChange(identifiersForm).length > 0) return + identifiersSaving.value = true + const username = identifiersForm.username.trim() + try { + await updateMerchantLoginIdentifiers({ + username, + phone: identifiersForm.phone.trim(), + currentPassword: identifiersForm.currentPassword, + }) + auth.clearSession() + await router.replace({ path: '/login', query: { account: username, reason: 'identifiers-updated' } }) + } catch (error) { + identifiersForm.currentPassword = '' + if (error instanceof ApiError && error.code === 'A0409') identifiersError.value = '用户名或手机号已被使用,请更换后重试' + else if (error instanceof ApiError && error.code === 'A0401') identifiersError.value = '当前密码错误' + else if (error instanceof ApiError && error.code === 'A0403') identifiersError.value = '当前会话不允许修改登录标识,请退出代登录后重试' + else identifiersError.value = error instanceof Error ? error.message : '登录标识修改失败,请重试' + } finally { + identifiersSaving.value = false + } +} + function resetPasswordForm() { passwordForm.currentPassword = '' passwordForm.newPassword = '' @@ -190,12 +250,9 @@ async function changePassword() { class="account-form-grid" @submit.prevent="saveProfile" > - - - 账号标识暂不支持在客户端修改 - + 请在“登录标识”中修改 - + 请在“登录标识”中修改 @@ -216,6 +273,20 @@ async function changePassword() { + + +
+ + + + + + +
重置保存并重新登录
+
+
+
+ @@ -280,6 +351,10 @@ async function changePassword() { .account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; } .account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; } .field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; } +.identifier-layout { max-width: 620px; padding-top: 22px; } +.identifier-form { display: grid; gap: 2px; } +.identifier-form > :deep(.el-alert) { margin-bottom: 16px; } +.identifier-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 18px; border-top: 1px solid var(--border); } .password-layout { display: grid; grid-template-columns: minmax(0, 440px) minmax(260px, 1fr); align-items: start; max-width: 980px; gap: clamp(32px, 6vw, 72px); padding-top: 22px; } .password-form { max-width: 440px; } .password-form :deep(.el-form-item) { margin-bottom: 18px; } @@ -316,5 +391,6 @@ async function changePassword() { .account-info-grid div { min-height: 82px; } .password-actions { width: 100%; } .password-actions .el-button { min-height: 44px; flex: 1; } + .identifier-actions .el-button { min-height: 44px; flex: 1; } } diff --git a/src/modules/auth/api/auth.ts b/src/modules/auth/api/auth.ts index f0fc43e..564968a 100644 --- a/src/modules/auth/api/auth.ts +++ b/src/modules/auth/api/auth.ts @@ -27,8 +27,8 @@ export interface ImpersonationExchangeResult { expiresIn: number } -export function loginMerchant(username: string, password: string) { - return apiRequest({ method: 'POST', url: '/auth/login', data: { username, password } }) +export function loginMerchant(loginId: string, password: string) { + return apiRequest({ method: 'POST', url: '/auth/login', data: { loginId, password } }) } export function registerMerchant(payload: RegisterMerchantRequest) { diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index ef6bc45..574c1f4 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -149,8 +149,8 @@ export const useAuthStore = defineStore('client-auth', () => { sessionRestored.value = true } - async function login(username: string, password: string, remember = false) { - const tokens = await loginMerchant(username, password) + async function login(loginId: string, password: string, remember = false) { + const tokens = await loginMerchant(loginId, password) const decoded = decodeMerchantToken(tokens.accessToken) persistTokenPair(tokens, remember) token.value = tokens.accessToken @@ -210,10 +210,9 @@ export const useAuthStore = defineStore('client-auth', () => { if (user.value) user.value = { ...user.value, name: next.realName } } - async function savePersonalProfile(next: Pick) { + async function savePersonalProfile(next: Pick) { const updated = await updateMerchantPersonalProfile({ displayName: next.realName.trim(), - phone: next.phone.trim(), email: next.email.trim(), }) updateProfile({ @@ -237,5 +236,5 @@ export const useAuthStore = defineStore('client-auth', () => { if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession() }) - return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile } + return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, clearSession, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile } }) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index f635cd4..00167c9 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -30,9 +30,12 @@ test('groups the full registration contract without adding out-of-scope sign-in assert.doesNotMatch(loginSource + registerSource, /微信登录|企业微信登录|短信验证码|邮箱验证码|lang-switcher/) }) -test('matches the backend username login contract', () => { - assert.match(loginSource, /label="用户名"/) - assert.match(loginSource, /\^\[A-Za-z0-9_\]/) +test('matches the frozen username-or-mobile login contract', () => { + assert.match(loginSource, /label="用户名或手机号"/) + assert.match(loginSource, /const usernamePattern = \/\^\[A-Za-z\]/) + assert.match(loginSource, /const mainlandMobilePattern = \/\^1\[3-9\]\\d\{9\}\$\//) + assert.match(authApiSource, /data: \{ loginId, password \}/) + assert.doesNotMatch(authApiSource, /data: \{ username, password \}/) assert.match(loginSource, /typeof route\.query\.account === 'string'/) assert.doesNotMatch(loginSource, /developmentHint|demo-tip|merchant_admin|联调账号|本地后端/) assert.match(loginSource, /form\.remember/) @@ -59,9 +62,9 @@ test('shows immediate login after registration and separates company review from assert.match(registerSource, /企业账号注册成功/) assert.match(registerSource, /无需等待注册审核/) assert.match(registerSource, /提交订单前还需完成企业审核/) - assert.match(registerSource, /营业执照和法人身份证/) - assert.match(registerSource, /货代资质证书、银行开户许可证可选填/) - assert.match(registerSource, /其他资质可选填/) + assert.match(registerSource, /首次登录需上传营业执照/) + assert.match(registerSource, /法人身份证、货代资质证书和银行开户许可证可选填/) + assert.match(registerSource, /法人身份证及其他资质可选填/) assert.match(registerSource, /立即登录/) }) @@ -113,6 +116,11 @@ test('shows the backend credential error on login', () => { assert.doesNotMatch(loginSource, /登录状态已失效,请重新登录/) }) +test('shows a forced re-login notice after login identifiers change', () => { + assert.match(loginSource, /route\.query\.reason === 'identifiers-updated'/) + assert.match(loginSource, /所有会话均已退出,请重新登录/) +}) + test('keeps a valid local session during temporary context and refresh outages', () => { assert.match(authStoreSource, /function isExpiredSessionError/) assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/) diff --git a/src/modules/auth/views/LoginView.vue b/src/modules/auth/views/LoginView.vue index ba3cda6..f10f820 100644 --- a/src/modules/auth/views/LoginView.vue +++ b/src/modules/auth/views/LoginView.vue @@ -18,6 +18,7 @@ const auth = useAuthStore() const formRef = ref() const loading = ref(false) const impersonationFailed = route.query.impersonation === 'failed' +const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated' const form = reactive({ account: typeof route.query.account === 'string' ? route.query.account @@ -25,12 +26,23 @@ const form = reactive({ password: '', remember: false, }) -const accountPattern = /^[A-Za-z0-9_]{4,64}$/ +const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/ +const mainlandMobilePattern = /^1[3-9]\d{9}$/ + +function validLoginId(value: string) { + return usernamePattern.test(value) || mainlandMobilePattern.test(value) +} const rules: FormRules = { account: [ - { required: true, message: '请输入用户名', trigger: 'blur' }, - { pattern: accountPattern, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' }, + { required: true, message: '请输入用户名或手机号', trigger: 'blur' }, + { + validator: (_rule, value: string, callback) => { + if (!value || validLoginId(value.trim())) callback() + else callback(new Error('请输入有效用户名或中国大陆手机号')) + }, + trigger: 'blur', + }, ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, @@ -76,7 +88,7 @@ function showResetHint() {
-

注册成功后账号可立即登录;首次登录需上传营业执照和法人身份证,货代资质证书、银行开户许可证可选填。提交平台审核通过后才能提交订单。

+

注册成功后账号可立即登录;首次登录需上传营业执照,法人身份证、货代资质证书和银行开户许可证可选填。提交平台审核通过后才能提交订单。

注册企业账号 @@ -153,7 +153,7 @@ async function submit() {

企业账号注册成功

{{ form.username }} 现在可以直接登录客户工作台,无需等待注册审核。

企业名称
{{ form.companyName }}
登录账号
{{ form.username }}
注册时间
{{ submittedAt }}
账号状态
可登录
-
提交订单前还需完成企业审核登录后请在“企业资料”上传营业执照和法人身份证并提交审核,其他资质可选填。审核期间仍可维护资料、成员和订单草稿。
+
提交订单前还需完成企业审核登录后请在“企业资料”上传营业执照并提交审核;法人身份证及其他资质可选填。审核期间仍可维护资料、成员和订单草稿。
立即登录
diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index f11a035..308fd1b 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -8,6 +8,8 @@ export type ApiContainerMode = 'FULL_CONTAINER' | 'LCL' export type ApiDestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE' export type ApiPricingTaxMode = 'TAX_INCLUDED' | 'TAX_EXCLUDED' export type ApiPriceModel = 'FBA_WAREHOUSE_RATE' | 'REGION_WEIGHT_TIER' +export type CustomerOrderAddressType = 'PICKUP' | 'PRIVATE_DESTINATION' +export type MerchantBatchImportMode = 'ATOMIC' | 'PARTIAL' export interface PageResult { records: T[] @@ -91,6 +93,7 @@ export interface FirstLegOrderCargoPayload { declaredValue?: number currency?: string dangerousGoods?: boolean + woodenProduct: boolean cargoRemark?: string skuNo?: string upcNo?: string @@ -129,8 +132,8 @@ export interface FirstLegOrderSaveRequest { destinationWarehouseName: string expectedShipDate?: string customerRemark?: string - sender?: FirstLegOrderPartyPayload - receiver?: FirstLegOrderPartyPayload + senderAddressId?: number | string + privateDestinationAddressId?: number | string cargoes: FirstLegOrderCargoPayload[] pickupMethod: ApiPickupMethod receivingWarehouseId: number | string @@ -142,13 +145,33 @@ export interface FirstLegOrderSaveRequest { pricingTaxMode: ApiPricingTaxMode taxMode: ApiTaxMode containerMode?: ApiContainerMode - customsInspection: boolean + customsInspection: boolean | null destinationType: ApiDestinationType amazonWarehouseCode?: string shipmentId?: string referenceId?: string } +export interface CustomerOrderAddressSaveRequest { + addressType: CustomerOrderAddressType + addressName: string + companyName: string + contactName: string + phone: string + email?: string + countryCode: string + province?: string + city: string + district?: string + addressLine: string + postalCode?: string + defaultAddress: boolean +} + +export interface CustomerOrderAddress extends CustomerOrderAddressSaveRequest { + id: number | string +} + export interface InboundCodePolicy { mode: 'AUTO' | 'LEGACY_MANUAL' manualEntryRequired: boolean @@ -177,6 +200,14 @@ export interface MerchantBatchDraftResponse { items: MerchantBatchDraftResult[] } +export interface MerchantBatchImportResponse { + batchNo: string + mode: MerchantBatchImportMode + sourceFileId: number | string + errorReportFileId: number | string | null + result: MerchantBatchDraftResponse +} + export interface MerchantFirstLegOrder { id?: number | string orderId?: number | string @@ -203,7 +234,7 @@ export interface MerchantFirstLegOrder { carrierName?: string | null taxMode: ApiTaxMode containerMode: ApiContainerMode | null - customsInspection: boolean + customsInspection: boolean | null destinationType: ApiDestinationType amazonWarehouseCode: string | null shipmentId: string | null @@ -453,6 +484,38 @@ export function getMerchantInboundCodePolicy() { return apiRequest({ method: 'GET', url: '/merchant/v1/orders/first-leg/inbound-code-policy' }) } +export function getMerchantOrderAddresses(addressType?: CustomerOrderAddressType) { + return apiRequest({ method: 'GET', url: '/merchant/v1/order-addresses', params: { addressType } }) +} + +export function createMerchantOrderAddress(payload: CustomerOrderAddressSaveRequest) { + return apiRequest({ method: 'POST', url: '/merchant/v1/order-addresses', data: payload }) +} + +export function updateMerchantOrderAddress(id: number | string, payload: CustomerOrderAddressSaveRequest) { + return apiRequest({ method: 'PUT', url: `/merchant/v1/order-addresses/${id}`, data: payload }) +} + +export function disableMerchantOrderAddress(id: number | string) { + return apiRequest({ method: 'DELETE', url: `/merchant/v1/order-addresses/${id}` }) +} + +export function downloadMerchantFirstLegBatchImportTemplate() { + return apiBlobRequest({ method: 'GET', url: '/merchant/v1/orders/first-leg/batch-imports/template' }) +} + +export function importMerchantFirstLegOrders(file: File, mode: MerchantBatchImportMode, idempotencyKey: string) { + const data = new FormData() + data.append('file', file) + return apiRequest({ + method: 'POST', + url: '/merchant/v1/orders/first-leg/batch-imports', + params: { mode }, + headers: { 'Idempotency-Key': idempotencyKey }, + data, + }) +} + export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) { return apiRequest({ method: 'POST', diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index db004c8..a1557d4 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -29,6 +29,12 @@ test('declares the documented merchant warehouse and first-leg endpoints', () => assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/) assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/inbound-code-policy'/) assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch'/) + assert.match(apiSource, /url: '\/merchant\/v1\/order-addresses'/) + assert.match(apiSource, /`\/merchant\/v1\/order-addresses\/\$\{id\}`/) + assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch-imports\/template'/) + assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch-imports'/) + assert.match(apiSource, /params: \{ mode \}/) + assert.match(apiSource, /data\.append\('file', file\)/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/) assert.match(apiSource, /`\/merchant\/v1\/warehouses\/\$\{id\}`/) @@ -134,13 +140,14 @@ test('sends backend-required channel, pricing, customs, destination and cargo fi assert.doesNotMatch(apiSource, /interface FirstLegOrderSaveRequest \{[^}]*customerId\??:/s) assert.match(storeSource, /customerName: customerName\.trim\(\)/) assert.match(storeSource, /destinationWarehouseId: draft\.destinationType === 'PRIVATE' \? undefined : draft\.destinationWarehouseId/) - assert.match(storeSource, /draft\.pickupMethod === 'PICKUP'.*draft\.shipperPartySnapshot/s) - assert.match(storeSource, /draft\.destinationType === 'PRIVATE'.*draft\.consigneePartySnapshot/s) + assert.match(storeSource, /senderAddressId: draft\.pickupMethod === 'PICKUP' \? draft\.senderAddressId : undefined/) + assert.match(storeSource, /privateDestinationAddressId: draft\.destinationType === 'PRIVATE' \? draft\.privateDestinationAddressId : undefined/) + assert.doesNotMatch(storeSource, /sender: partyPayload|receiver: partyPayload/) for (const field of [ 'customsHsCode', 'lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'totalQuantity', 'netWeightKg', 'unitDeclaredValue', 'material', 'usage', 'model', 'brand', 'productLengthCm', 'productWidthCm', 'productHeightCm', 'shipmentId', 'referenceId', - 'productImageFileId', + 'productImageFileId', 'woodenProduct', ]) { assert.match(storeSource, new RegExp(`${field}: item\\.`)) } diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 8fd10d9..18f022f 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -11,7 +11,6 @@ import { submitMerchantFirstLegOrder, updateMerchantFirstLegOrder, type ApiFirstLegStatus, - type FirstLegOrderPartyPayload, type FirstLegOrderSaveRequest, type MerchantFirstLegOrder, type MerchantFirstLegOrderRow, @@ -40,10 +39,6 @@ export type ClientWarehouse = PlatformReceivingWarehouse & { countryCode: string } -const countryCodes: Readonly> = { - 中国: 'CN', 美国: 'US', 德国: 'DE', 英国: 'GB', 加拿大: 'CA', 日本: 'JP', 澳大利亚: 'AU', -} - function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] { if (status === 'DRAFT') return 'DRAFT' if (status === 'SUBMITTED') return 'PENDING_REVIEW' @@ -73,35 +68,7 @@ function apiTransport(draft: FirstLegOrderDraft): FirstLegOrderSaveRequest['tran return 'SEA' } -function partyPayload(snapshot: FirstLegPartySnapshot): FirstLegOrderPartyPayload { - return { - companyName: snapshot.name, - contactName: snapshot.contactName, - phone: snapshot.phone, - email: snapshot.email || undefined, - countryCode: countryCodes[snapshot.countryRegion] ?? snapshot.countryRegion.toUpperCase().slice(0, 2), - province: snapshot.province || undefined, - city: snapshot.city, - addressLine: snapshot.address, - postalCode: snapshot.postalCode || undefined, - } -} - function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, customerName: string): FirstLegOrderSaveRequest { - const sender = draft.pickupMethod === 'PICKUP' && draft.shipperPartySnapshot - ? partyPayload({ - ...draft.shipperPartySnapshot, - name: draft.shipperPartySnapshot.name || customerName.trim() || '客户', - countryRegion: draft.shipperPartySnapshot.countryRegion || 'CN', - }) - : undefined - const receiver = draft.destinationType === 'PRIVATE' && draft.consigneePartySnapshot - ? partyPayload({ - ...draft.consigneePartySnapshot, - name: draft.destinationWarehouseName, - countryRegion: draft.destinationCountryCode.trim().toUpperCase(), - }) - : undefined return { inboundCode: draft.inboundCode?.trim() || undefined, customerName: customerName.trim(), @@ -112,8 +79,8 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust destinationWarehouseName: draft.destinationWarehouseName, expectedShipDate: draft.expectedDepartureDate || undefined, customerRemark: draft.customerRemark || undefined, - sender, - receiver, + senderAddressId: draft.pickupMethod === 'PICKUP' ? draft.senderAddressId : undefined, + privateDestinationAddressId: draft.destinationType === 'PRIVATE' ? draft.privateDestinationAddressId : undefined, cargoes: draft.items.map((item) => ({ cargoName: item.cargoNameCn, cargoNameEn: item.cargoNameEn || undefined, @@ -129,6 +96,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust declaredValue: item.declaredValue || undefined, currency: item.currency || undefined, dangerousGoods: item.dangerousGoods, + woodenProduct: item.woodenProduct, cargoRemark: item.remark || undefined, skuNo: item.sku || undefined, upcNo: item.upc || undefined, @@ -329,6 +297,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar volumeCbm: item.volumeCbm, remark: item.cargoRemark ?? '', packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0, currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false, + woodenProduct: item.woodenProduct ?? false, netWeightKg: item.netWeightKg ?? 0, unitDeclaredValue: item.unitDeclaredValue ?? 0, material: item.material ?? '', usage: item.usage ?? item.productUsage ?? '', model: item.model ?? '', brand: item.brand ?? '', productLengthCm: item.productLengthCm ?? 0, productWidthCm: item.productWidthCm ?? 0, diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index b3ee077..8f85463 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -45,6 +45,7 @@ export interface FirstLegCargoItem { declaredValue: number currency: string dangerousGoods: boolean + woodenProduct: boolean } export interface FirstLegOrderTimelineItem { @@ -142,7 +143,7 @@ export interface FirstLegOrder { carrierId: string carrierName: string taxMode: 'BUY_DECLARATION' | 'TAX_REFUND' - customsInspection: boolean + customsInspection: boolean | null destinationType: 'FBA' | 'OVERSEAS' | 'PRIVATE' amazonWarehouseCode: string shipmentId: string @@ -185,4 +186,6 @@ export type FirstLegOrderDraft = Omit, 'transportMode'> & { transportMode: FirstLegTransportMode inboundCode?: string + senderAddressId: string + privateDestinationAddressId: string } diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index 8f37853..79470be 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -140,6 +140,17 @@ test('requires public destination ids while keeping private addresses detached f assert.ok(validateFirstLegOrderDraft(privateDraft).includes('私人地址不能引用平台公共仓库')) }) +test('requires customs inspection when any cargo is a wooden product', () => { + const draft = validDraft() + draft.items[0].woodenProduct = true + draft.customsInspection = null + assert.ok(validateFirstLegOrderDraft(draft).includes('木制品必须选择商检')) + draft.customsInspection = false + assert.ok(validateFirstLegOrderDraft(draft).includes('木制品必须选择商检')) + draft.customsInspection = true + assert.ok(!validateFirstLegOrderDraft(draft).includes('木制品必须选择商检')) +}) + test('summarizes cargo values from line items', () => { const first = validDraft().items[0] const second = { ...first, id: 'cargo-2', pieces: 40, packageCount: 4, grossWeightKg: 300, volumeCbm: 1.25 } diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index 02cf989..0c8ffd9 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -84,6 +84,7 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI declaredValue: 0, currency: 'USD', dangerousGoods: false, + woodenProduct: false, } } @@ -99,12 +100,14 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) { if (!['TAX_INCLUDED', 'TAX_EXCLUDED'].includes(draft.pricingTaxMode)) errors.push('请选择报价包税方式') if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓') if (draft.pickupMethod === 'PICKUP') { + if (!draft.senderAddressId) errors.push('请选择已保存的提货地址') if (!draft.shipperPartySnapshot?.contactName.trim()) errors.push('请填写提货联系人') if (!draft.shipperPartySnapshot?.phone.trim()) errors.push('请填写提货联系电话') if (!draft.shipperPartySnapshot?.city.trim()) errors.push('请填写提货城市') if (!draft.shipperPartySnapshot?.address.trim()) errors.push('请填写提货地址') } if (draft.destinationType === 'PRIVATE') { + if (!draft.privateDestinationAddressId) errors.push('请选择已保存的私人仓地址') if (!draft.consigneePartySnapshot?.name.trim()) errors.push('请填写私人地址名称') if (!draft.consigneePartySnapshot?.contactName.trim()) errors.push('请填写收件人姓名') if (!draft.consigneePartySnapshot?.phone.trim()) errors.push('请填写收件人电话') @@ -130,6 +133,9 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) { if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`) if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`) }) + if (draft.items.some((item) => item.woodenProduct) && draft.customsInspection !== true) { + errors.push('木制品必须选择商检') + } return errors } diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 9040d98..1053f4c 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -279,15 +279,20 @@ test('requires an enabled platform receiving warehouse in the order form', () => assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/) }) -test('selects platform destinations by type and caches private addresses per user', () => { +test('selects platform destinations and uses customer-scoped server address books', () => { assert.match(source, /warehouse\.warehouseType === form\.destinationType/) assert.match(source, /await store\.loadWarehouseType\(type\)/) assert.match(source, /await loadDestinationWarehouses\(form\.destinationType\)/) assert.match(source, /:loading="destinationWarehousesLoading"/) assert.match(source, /v-model="form\.destinationWarehouseId"/) assert.match(source, /form\.amazonWarehouseCode = form\.destinationType === 'FBA' \? warehouse\.code : ''/) - assert.match(source, /cc:first-leg:private-addresses:\$\{auth\.user\?\.id/) - assert.match(source, /localStorage\.setItem\(privateAddressStorageKey\(\)/) + assert.match(source, /getMerchantOrderAddresses\(\)/) + assert.match(source, /createMerchantOrderAddress\(payload\)/) + assert.match(source, /updateMerchantOrderAddress\(form\.(?:senderAddressId|privateDestinationAddressId), payload\)/) + assert.match(source, /disableMerchantOrderAddress\(id\)/) + assert.match(source, /v-model="form\.senderAddressId"/) + assert.match(source, /v-model="form\.privateDestinationAddressId"/) + assert.doesNotMatch(source, /localStorage|cc:first-leg:private-addresses/) assert.match(source, /私人地址不能引用平台公共仓库|form\.destinationWarehouseId = ''/) }) @@ -318,6 +323,8 @@ test('collects the backend-required first-leg fields', () => { assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`)) } assert.match(source, /form\.channelName = channel\?\.channelName/) + assert.match(source, /v-model="item\.woodenProduct"/) + assert.match(source, /货物包含木制品,提交前必须选择“是”/) }) test('distills order creation into the confirmed prototype sections with server pricing', () => { @@ -330,7 +337,11 @@ test('distills order creation into the confirmed prototype sections with server assert.match(source, /class="segmented-options"/) assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/) assert.match(source, //) - assert.match(source, /上传 Excel[\s\S]*下载模板[\s\S]*添加行[\s\S]*删除所选行/) + assert.match(source, /批量导入订单/) + assert.match(source, /下载正式模板[\s\S]*选择 Excel/) + assert.match(source, /仅支持 \.xlsx,文件不超过 5MB、最多 50 行/) + assert.match(source, /添加行[\s\S]*删除所选行/) + assert.doesNotMatch(source, /货物明细导入模板\.xls/) for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) { assert.ok(cargoTableSource.includes(heading)) } diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index dc5e0ad..7baf695 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -16,11 +16,22 @@ import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBillin import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs' import { estimateMerchantFirstLegPrice, + createIdempotencyKey, + createMerchantOrderAddress, + disableMerchantOrderAddress, + downloadMerchantFirstLegBatchImportTemplate, downloadMerchantOrderCartonLabelsBatch, downloadMerchantProductImage, getMerchantInboundCodePolicy, + getMerchantOrderAddresses, getMerchantPricingChannels, + importMerchantFirstLegOrders, + updateMerchantOrderAddress, uploadMerchantProductImage, + type CustomerOrderAddress, + type CustomerOrderAddressSaveRequest, + type MerchantBatchImportMode, + type MerchantBatchImportResponse, type MerchantPriceEstimate, type MerchantPriceEstimateRequest, type MerchantPricingChannel, @@ -83,18 +94,14 @@ const priceEstimate = ref(null) const priceEstimateLoading = ref(false) const priceEstimateError = ref('') const selectedCargoIds = ref([]) -const cargoImportInput = ref() - -const cargoTemplateColumns = [ - ['SKU NO.', 'sku'], ['中文品名', 'cargoNameCn'], ['英文品名', 'cargoNameEn'], ['报关 HS CODE', 'hsCode'], ['清关 HS CODE', 'customsHsCode'], - ['包装类型', 'packageType'], ['箱规长 cm', 'lengthCm'], ['箱规宽 cm', 'widthCm'], ['箱规高 cm', 'heightCm'], ['每箱数量', 'unitsPerPackage'], - ['箱数', 'packageCount'], ['总毛重', 'grossWeightKg'], ['总体积', 'volumeCbm'], ['总数量', 'pieces'], ['总净重', 'netWeightKg'], - ['清关申报单价 USD', 'unitDeclaredValue'], ['清关申报金额 USD', 'declaredValue'], ['材质', 'material'], ['用途', 'usage'], ['型号', 'model'], - ['品牌', 'brand'], ['产品长 cm', 'productLengthCm'], ['产品宽 cm', 'productWidthCm'], ['产品高 cm', 'productHeightCm'], ['危险品', 'dangerousGoods'], - ['货件编号', 'shipmentId'], ['跟踪编号', 'referenceId'], ['UPC NO.', 'upc'], ['备注', 'remark'], -] as const - -const cargoNumberKeys = new Set(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm']) +const batchImportInput = ref() +const batchImportOpen = ref(false) +const batchImportMode = ref('PARTIAL') +const batchImportFile = ref(null) +const batchImportIdempotencyKey = ref('') +const batchImportSubmitting = ref(false) +const batchTemplateDownloading = ref(false) +const batchImportResult = ref(null) const fixedOrderColumns: DataTableColumn[] = [ { key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true }, { key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, @@ -207,35 +214,13 @@ let channelRequestSequence = 0 let estimateRequestSequence = 0 let estimateTimer: ReturnType | undefined -interface CachedPrivateAddress { - id: string - label: string - consigneePartyId: string - consigneePartySnapshot: NonNullable - consignee: string - destination: string - destinationCountryCode: string - destinationWarehouseName: string -} +const orderAddresses = ref([]) +const orderAddressesLoading = ref(false) +const pickupAddresses = computed(() => orderAddresses.value.filter((item) => item.addressType === 'PICKUP')) +const privateAddresses = computed(() => orderAddresses.value.filter((item) => item.addressType === 'PRIVATE_DESTINATION')) -const privateAddresses = ref([]) -const selectedPrivateAddressId = ref('') - -function privateAddressStorageKey() { - return `cc:first-leg:private-addresses:${auth.user?.id ?? 'anonymous'}` -} - -function loadPrivateAddresses() { - try { - const value = JSON.parse(localStorage.getItem(privateAddressStorageKey()) ?? '[]') as unknown - privateAddresses.value = Array.isArray(value) ? value.filter((item): item is CachedPrivateAddress => Boolean(item && typeof item === 'object' && 'id' in item && 'consigneePartySnapshot' in item)) : [] - } catch { - privateAddresses.value = [] - } -} - -function persistPrivateAddresses() { - localStorage.setItem(privateAddressStorageKey(), JSON.stringify(privateAddresses.value)) +function addressLabel(address: CustomerOrderAddress) { + return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}` } const orderFormRules: FormRules = { @@ -352,9 +337,11 @@ function emptyDraft(): FirstLegOrderDraft { receivingWarehouseId: '', expectedDepartureDate: '', shipperPartyId: '', + senderAddressId: '', shipperPartySnapshot: null, shipper: '', consigneePartyId: '', + privateDestinationAddressId: '', consigneePartySnapshot: null, consignee: '', priority: 'NORMAL', @@ -368,7 +355,7 @@ function emptyDraft(): FirstLegOrderDraft { channelName: '', pricingTaxMode: 'TAX_INCLUDED', taxMode: 'BUY_DECLARATION', - customsInspection: false, + customsInspection: null, destinationType: 'FBA', amazonWarehouseCode: '', shipmentId: '', @@ -566,11 +553,13 @@ async function loadPageData() { const orderPageRequest = isCreatePage.value ? store.loadWarehouseType('DOMESTIC', true) : store.load({ includeDrafts: true }) - const [ordersResult, companyResult, destinationResult, policyResult] = await Promise.allSettled([ + const addressRequest = isCreatePage.value ? loadOrderAddresses() : Promise.resolve() + const [ordersResult, companyResult, destinationResult, policyResult, addressResult] = await Promise.allSettled([ orderPageRequest, companyRequest, destinationRequest, getMerchantInboundCodePolicy(), + addressRequest, ]) if (policyResult.status === 'fulfilled') inboundCodePolicy.value = policyResult.value else { @@ -586,6 +575,7 @@ async function loadPageData() { if (store.warehouseLoadError) supportingDataErrors.value.push(`公共仓库加载失败:${store.warehouseLoadError}`) if (!isCreatePage.value && store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`) if (destinationResult.status === 'rejected') supportingDataErrors.value.push(`目的仓加载失败:${errorMessage(destinationResult.reason)}`) + if (addressResult.status === 'rejected') supportingDataErrors.value.push(`企业地址簿加载失败:${errorMessage(addressResult.reason)}`) await autofillWarehouseSelections() if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.value) supportingDataLoading.value = false @@ -625,7 +615,6 @@ function getOrderCargoSummary(order: FirstLegOrder) { } onMounted(() => { - loadPrivateAddresses() void loadPageData() }) @@ -745,9 +734,11 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft { receivingWarehouseId: order.receivingWarehouseId, expectedDepartureDate: order.expectedDepartureDate, shipperPartyId: order.shipperPartyId, + senderAddressId: '', shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null, shipper: order.shipper, consigneePartyId: order.consigneePartyId, + privateDestinationAddressId: '', consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null, consignee: order.consignee, priority: order.priority, @@ -939,7 +930,7 @@ async function changeDestinationType() { form.destinationWarehouseName = '' form.destination = '' form.amazonWarehouseCode = '' - selectedPrivateAddressId.value = '' + form.privateDestinationAddressId = '' form.consigneePartyId = '' form.consigneePartySnapshot = form.destinationType === 'PRIVATE' ? createEmptyPartySnapshot('CONSIGNEE') : null form.consignee = '' @@ -958,6 +949,7 @@ function changePickupMethod() { if (form.pickupMethod === 'PICKUP') { form.shipperPartySnapshot ??= createPickupPartySnapshot() } else { + form.senderAddressId = '' form.shipperPartyId = '' form.shipperPartySnapshot = null form.shipper = '' @@ -974,54 +966,129 @@ function syncPrivateDestinationName(value: string) { } function selectPrivateAddress(id: string) { - const address = privateAddresses.value.find((item) => item.id === id) + const address = privateAddresses.value.find((item) => String(item.id) === id) if (!address) return - form.consigneePartyId = address.consigneePartyId - form.consigneePartySnapshot = { ...address.consigneePartySnapshot } - form.consignee = address.consignee - form.destination = address.destination - form.destinationCountryCode = address.destinationCountryCode + form.privateDestinationAddressId = String(address.id) + form.consigneePartyId = '' + form.consigneePartySnapshot = addressSnapshot(address, 'CONSIGNEE') + form.consignee = address.companyName || address.addressName + form.destination = address.addressName + form.destinationCountryCode = address.countryCode form.destinationWarehouseId = '' - form.destinationWarehouseName = address.destinationWarehouseName + form.destinationWarehouseName = address.addressName } -function savePrivateAddress() { - if (!form.consigneePartySnapshot || !form.destinationWarehouseName.trim() || !form.destinationCountryCode.trim() - || !form.consigneePartySnapshot.contactName.trim() || !form.consigneePartySnapshot.phone.trim() - || !form.consigneePartySnapshot.city.trim() || !form.consigneePartySnapshot.address.trim()) { - ElMessage.warning('请先完整填写私人地址和收件资料') +function addressSnapshot(address: CustomerOrderAddress, role: FirstLegPartyRole): FirstLegPartySnapshot { + return { + id: '', + code: '', + name: address.companyName || address.addressName, + role, + contactName: address.contactName, + phone: address.phone, + email: address.email ?? '', + countryRegion: address.countryCode, + province: address.province ?? '', + city: address.city, + postalCode: address.postalCode ?? '', + address: address.addressLine, + } +} + +async function loadOrderAddresses() { + orderAddressesLoading.value = true + try { + orderAddresses.value = await getMerchantOrderAddresses() + } finally { + orderAddressesLoading.value = false + } +} + +function selectPickupAddress(id: string) { + const address = pickupAddresses.value.find((item) => String(item.id) === id) + if (!address) return + form.senderAddressId = String(address.id) + form.shipperPartyId = '' + form.shipperPartySnapshot = addressSnapshot(address, 'SHIPPER') + form.shipper = address.companyName || address.addressName + form.origin = address.city +} + +function addressPayload(addressType: CustomerOrderAddress['addressType'], snapshot: FirstLegPartySnapshot, addressName: string): CustomerOrderAddressSaveRequest { + const payload: CustomerOrderAddressSaveRequest = { + addressType, + addressName: addressName.trim(), + companyName: snapshot.name.trim() || addressName.trim(), + contactName: snapshot.contactName.trim(), + phone: snapshot.phone.trim(), + countryCode: snapshot.countryRegion.trim().toUpperCase(), + city: snapshot.city.trim(), + addressLine: snapshot.address.trim(), + defaultAddress: false, + } + if (snapshot.email.trim()) payload.email = snapshot.email.trim() + if (snapshot.province.trim()) payload.province = snapshot.province.trim() + if (snapshot.postalCode.trim()) payload.postalCode = snapshot.postalCode.trim() + return payload +} + +function addressComplete(snapshot: FirstLegPartySnapshot | null, addressName: string) { + return Boolean(snapshot && addressName.trim() && snapshot.contactName.trim() && snapshot.phone.trim() + && /^[A-Za-z]{2}$/.test(snapshot.countryRegion.trim()) && snapshot.city.trim() && snapshot.address.trim()) +} + +async function savePickupAddress() { + const snapshot = form.shipperPartySnapshot + const addressName = snapshot?.name.trim() || `${snapshot?.city.trim() || '国内'}提货地址` + if (!addressComplete(snapshot, addressName)) { + ElMessage.warning('请先完整填写提货地址和联系人资料') return } - const snapshot = { ...form.consigneePartySnapshot } - snapshot.name = form.destinationWarehouseName.trim() - snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase() - const existing = privateAddresses.value.find((item) => item.consigneePartySnapshot.address === snapshot.address) - const partyId = existing?.consigneePartyId || snapshot.id || `private-party-${Date.now()}` - snapshot.id = partyId - const saved: CachedPrivateAddress = { - id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `private-${Date.now()}`, - label: `${form.destinationWarehouseName.trim()} · ${snapshot.address}`, - consigneePartyId: partyId, - consigneePartySnapshot: snapshot, - consignee: snapshot.name, - destination: form.destination, - destinationCountryCode: form.destinationCountryCode.trim().toUpperCase(), - destinationWarehouseName: form.destinationWarehouseName.trim(), + try { + const payload = addressPayload('PICKUP', snapshot!, addressName) + if (form.senderAddressId) await updateMerchantOrderAddress(form.senderAddressId, payload) + else form.senderAddressId = String(await createMerchantOrderAddress(payload)) + await loadOrderAddresses() + selectPickupAddress(form.senderAddressId) + ElMessage.success('企业提货地址已保存') + } catch (error) { + ElMessage.error(errorMessage(error)) } - privateAddresses.value = existing - ? privateAddresses.value.map((item) => item.id === existing.id ? saved : item) - : [saved, ...privateAddresses.value] - selectedPrivateAddressId.value = saved.id - persistPrivateAddresses() - ElMessage.success(existing ? '私人地址已更新' : '私人地址已保存到当前账号') } -function removePrivateAddress() { - if (!selectedPrivateAddressId.value) return - privateAddresses.value = privateAddresses.value.filter((item) => item.id !== selectedPrivateAddressId.value) - selectedPrivateAddressId.value = '' - persistPrivateAddresses() - ElMessage.success('已从当前账号的私人地址中移除') +async function savePrivateAddress() { + const snapshot = form.consigneePartySnapshot + const addressName = form.destinationWarehouseName.trim() + if (snapshot) snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase() + if (!addressComplete(snapshot, addressName)) { + ElMessage.warning('请先完整填写私人仓地址和收件资料') + return + } + snapshot!.name = addressName + try { + const payload = addressPayload('PRIVATE_DESTINATION', snapshot!, addressName) + if (form.privateDestinationAddressId) await updateMerchantOrderAddress(form.privateDestinationAddressId, payload) + else form.privateDestinationAddressId = String(await createMerchantOrderAddress(payload)) + await loadOrderAddresses() + selectPrivateAddress(form.privateDestinationAddressId) + ElMessage.success('企业私人仓地址已保存') + } catch (error) { + ElMessage.error(errorMessage(error)) + } +} + +async function removeOrderAddress(type: CustomerOrderAddress['addressType']) { + const id = type === 'PICKUP' ? form.senderAddressId : form.privateDestinationAddressId + if (!id) return + try { + await disableMerchantOrderAddress(id) + if (type === 'PICKUP') form.senderAddressId = '' + else form.privateDestinationAddressId = '' + await loadOrderAddresses() + ElMessage.success('地址已从企业地址簿停用') + } catch (error) { + ElMessage.error(errorMessage(error)) + } } async function loadSelectedWarehouse(id: string | number | boolean | undefined) { @@ -1073,7 +1140,7 @@ async function openEdit(order: FirstLegOrder) { if (openingEditor.value || !canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return openingEditor.value = true try { - const detailedOrder = await store.loadDetail(order.id) + const [detailedOrder] = await Promise.all([store.loadDetail(order.id), loadOrderAddresses()]) const draft = draftFromOrder(detailedOrder) if (draft.destinationType !== 'PRIVATE') await loadDestinationWarehouses(draft.destinationType) const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId) @@ -1395,68 +1462,63 @@ function removeSelectedCargoItems() { resetEditorValidation() } -function downloadCargoTemplate() { - const headers = cargoTemplateColumns.map(([label]) => ``).join('') - const example = cargoTemplateColumns.map(([label]) => { - if (label === '包装类型') return '' - if (label === '危险品') return '' - if (['每箱数量', '箱数', '总数量'].includes(label)) return '' - return '' - }).join('') - const html = `
${label}CARTON否1
${headers}${example}
` - const url = URL.createObjectURL(new Blob(['\ufeff', html], { type: 'application/vnd.ms-excel;charset=utf-8' })) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = '货物明细导入模板.xls' - anchor.click() - URL.revokeObjectURL(url) +function openBatchImport() { + batchImportMode.value = 'PARTIAL' + batchImportFile.value = null + batchImportIdempotencyKey.value = '' + batchImportResult.value = null + batchImportOpen.value = true } -function parseCargoTemplateRows(text: string) { - const documentNode = new DOMParser().parseFromString(text, 'text/html') - const tableRows = [...documentNode.querySelectorAll('table tr')] - if (tableRows.length < 2) return [] - const headers = [...tableRows[0].querySelectorAll('th,td')].map((cell) => cell.textContent?.trim() || '') - return tableRows.slice(1).map((row) => { - const values = [...row.querySelectorAll('td')].map((cell) => cell.textContent?.trim() || '') - return { headers, values } - }).filter(({ values }) => values.some(Boolean)) -} - -async function importCargoTemplate(event: Event) { +function selectBatchImportFile(event: Event) { const input = event.target as HTMLInputElement const file = input.files?.[0] input.value = '' if (!file) return - if (!file.name.toLowerCase().endsWith('.xls')) { - ElMessage.error('请上传由“下载模板”生成的 .xls 文件') + if (!file.name.toLowerCase().endsWith('.xlsx')) { + ElMessage.error('仅支持 .xlsx 格式的订单导入文件') + return + } + if (file.size > 5 * 1024 * 1024) { + ElMessage.error('订单导入文件不能超过 5MB') + return + } + batchImportFile.value = file + batchImportIdempotencyKey.value = createIdempotencyKey('first-leg-batch-import') + batchImportResult.value = null +} + +async function downloadBatchTemplate() { + try { + batchTemplateDownloading.value = true + saveBlob(await downloadMerchantFirstLegBatchImportTemplate(), '头程订单批量导入模板.xlsx') + } catch (error) { + ElMessage.error(errorMessage(error)) + } finally { + batchTemplateDownloading.value = false + } +} + +async function submitBatchImport() { + if (!batchImportFile.value) { + ElMessage.warning('请先选择 .xlsx 订单导入文件') return } try { - const parsedRows = parseCargoTemplateRows(await file.text()) - if (!parsedRows.length) throw new Error('模板中没有可导入的货物数据') - const imported = parsedRows.map(({ headers, values }) => { - const item = createEmptyCargoItem(`imported-cargo-${++cargoSequence}`) - for (const [columnLabel, itemKey] of cargoTemplateColumns) { - const value = values[headers.indexOf(columnLabel)] ?? '' - if (itemKey === 'dangerousGoods') item.dangerousGoods = ['是', 'true', '1', 'yes'].includes(value.toLowerCase()) - else if (cargoNumberKeys.has(itemKey)) (item as unknown as Record)[itemKey] = Number(value) || 0 - else (item as unknown as Record)[itemKey] = value - } - item.packageType = item.packageType || 'CARTON' - item.unitsPerPackage = item.unitsPerPackage || 1 - item.packageCount = item.packageCount || 1 - item.pieces = item.pieces || 1 - return item - }) - const shouldReplaceDefaultRow = form.items.length === 1 && !form.items[0].cargoNameCn && !form.items[0].sku - if (shouldReplaceDefaultRow) form.items.splice(0, 1, ...imported) - else form.items.push(...imported) - selectedCargoIds.value = [] - resetEditorValidation() - ElMessage.success(`已导入 ${imported.length} 行货物明细`) + batchImportSubmitting.value = true + batchImportResult.value = await importMerchantFirstLegOrders( + batchImportFile.value, + batchImportMode.value, + batchImportIdempotencyKey.value, + ) + const result = batchImportResult.value.result + if (result.succeeded) await store.load({ includeDrafts: true }) + if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`) + else ElMessage.success(`已成功导入 ${result.succeeded} 条订单草稿`) } catch (error) { - ElMessage.error(error instanceof Error ? error.message : '货物明细导入失败') + ElMessage.error(errorMessage(error)) + } finally { + batchImportSubmitting.value = false } } @@ -1634,6 +1696,7 @@ async function searchOrders(model: QueryModel) { 恢复默认 + 批量导入订单 创建订单 @@ -1751,6 +1814,9 @@ async function searchOrders(model: QueryModel) {
快递自送我司提货卡车自送