This commit is contained in:
2026-08-07 14:47:00 +08:00
parent 54fe05d864
commit 0bd872fdbf
12 changed files with 461 additions and 36 deletions
+22
View File
@@ -100,6 +100,25 @@ export const useAuthStore = defineStore('client-auth', () => {
sessionRestored.value = true
}
function applyStoredSession() {
const storedToken = getAccessToken()
if (!storedToken) {
token.value = ''
payload.value = null
user.value = null
permissionCodes.value = []
profile.value = { ...emptyProfile }
sessionRestored.value = true
return
}
try {
applyToken(storedToken)
sessionRestored.value = false
} catch {
clearSession()
}
}
if (token.value) {
try {
applyToken(token.value)
@@ -201,6 +220,9 @@ export const useAuthStore = defineStore('client-auth', () => {
}
window.addEventListener('cc-client-session-expired', clearSession)
window.addEventListener('storage', (event) => {
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, updateProfile, savePersonalProfile }
})
+59
View File
@@ -6,6 +6,8 @@ export type ApiPickupMethod = 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
export type ApiTaxMode = 'BUY_DECLARATION' | 'TAX_REFUND'
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 interface PageResult<T> {
records: T[]
@@ -114,6 +116,7 @@ export interface FirstLegOrderSaveRequest {
receivingTime?: string | null
channelCode: string
channelName: string
pricingTaxMode: ApiPricingTaxMode
taxMode: ApiTaxMode
containerMode?: ApiContainerMode
customsInspection: boolean
@@ -143,6 +146,7 @@ export interface MerchantFirstLegOrder {
receivingTime?: string | null
channelCode: string
channelName: string
pricingTaxMode?: ApiPricingTaxMode | null
carrierId?: number | string | null
carrierName?: string | null
taxMode: ApiTaxMode
@@ -200,6 +204,45 @@ export interface FirstLegOrderQuery {
size?: number
}
export interface MerchantPricingChannel {
priceBookVersionId: number | string
channelCode: string
channelName: string
priceModel: ApiPriceModel
pricingTaxModes: ApiPricingTaxMode[]
estimatedDepartureDate: string | null
estimatedDepartureText: string | null
latestWarehouseEntry: string | null
referenceTransit: string | null
surchargeDescription: string | null
remark: string | null
}
export interface MerchantPriceEstimateRequest {
originWarehouseId: number | string
channelCode: string
destinationCode?: string
destinationType: Exclude<ApiDestinationType, 'PRIVATE'>
destinationWarehouseId?: number | string
destinationName?: string
pricingTaxMode: ApiPricingTaxMode
actualWeightKg: number
actualVolumeCbm: number
}
export interface MerchantPriceEstimate {
priceBookVersionId: number | string
channelCode: string
priceModel: ApiPriceModel
pricingTaxMode: ApiPricingTaxMode
chargeableQuantity: number
chargeUnit: string
unitPrice: number
estimatedAmount: number
currency: string
disclaimer: string
}
export function getMerchantWarehouses(params: MerchantWarehouseQuery = {}) {
return apiRequest<PageResult<MerchantWarehouse>>({
method: 'GET',
@@ -212,6 +255,22 @@ export function getMerchantWarehouseDetail(id: number | string) {
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
}
export function getMerchantPricingChannels(originWarehouseId: number | string) {
return apiRequest<MerchantPricingChannel[]>({
method: 'GET',
url: '/merchant/v1/first-leg/pricing/channels',
params: { originWarehouseId },
})
}
export function estimateMerchantFirstLegPrice(payload: MerchantPriceEstimateRequest) {
return apiRequest<MerchantPriceEstimate>({
method: 'POST',
url: '/merchant/v1/first-leg/pricing/estimate',
data: payload,
})
}
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
}
@@ -12,6 +12,9 @@ test('declares the documented merchant warehouse and first-leg endpoints', () =>
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\}`/)
assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/pricing\/channels'/)
assert.match(apiSource, /params: \{ originWarehouseId \}/)
assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/pricing\/estimate'/)
})
test('maps transport and status fields instead of treating container mode as transport mode', () => {
@@ -75,8 +78,8 @@ test('maps C02 rows by orderId and leaves absent transport details unknown', ()
assert.match(storeSource, /summary\?\.inboundId == null \? '' : String\(summary\.inboundId\)/)
})
test('sends backend-required channel, customs, destination and cargo fields', () => {
for (const field of ['pickupMethod', 'channelCode', 'channelName', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) {
test('sends backend-required channel, pricing, customs, destination and cargo fields', () => {
for (const field of ['pickupMethod', 'channelCode', 'channelName', 'pricingTaxMode', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) {
assert.match(storeSource, new RegExp(field))
}
assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/)
@@ -124,6 +124,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
receivingWarehouseName: warehouse.name,
channelCode: draft.channelCode,
channelName: draft.channelName,
pricingTaxMode: draft.pricingTaxMode,
taxMode: draft.taxMode,
containerMode: draft.transportMode === 'FCL' ? 'FULL_CONTAINER' : draft.transportMode === 'LCL' ? 'LCL' : undefined,
customsInspection: draft.customsInspection,
@@ -281,7 +282,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
destinationWarehouseId: order.destinationWarehouseId ? String(order.destinationWarehouseId) : '',
destinationWarehouseName: order.destinationWarehouseName, pickupMethod: order.pickupMethod, channelCode: order.channelCode,
channelName: order.channelName, carrierId: order.carrierId == null ? '' : String(order.carrierId), carrierName: order.carrierName ?? '',
taxMode: order.taxMode, customsInspection: order.customsInspection,
pricingTaxMode: order.pricingTaxMode ?? 'TAX_INCLUDED', taxMode: order.taxMode, customsInspection: order.customsInspection,
destinationType: order.destinationType, amazonWarehouseCode: order.amazonWarehouseCode ?? '', shipmentId: order.shipmentId ?? '',
receivingTime: order.receivingTime ?? '', submittedAt: order.submittedAt ?? '', completedAt: order.completedAt ?? '',
}
@@ -301,7 +302,7 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo
createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '',
destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId),
destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '',
channelName: summary.channelName ?? '', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false,
channelName: summary.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false,
destinationType: 'PRIVATE', amazonWarehouseCode: '', shipmentId: '',
receivingTime: '', submittedAt: '', completedAt: '',
}
@@ -109,6 +109,7 @@ export interface FirstLegOrder {
pickupMethod: 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
channelCode: string
channelName: string
pricingTaxMode: 'TAX_INCLUDED' | 'TAX_EXCLUDED'
carrierId: string
carrierName: string
taxMode: 'BUY_DECLARATION' | 'TAX_REFUND'
@@ -146,6 +147,7 @@ export type FirstLegOrderDraft = Omit<Pick<
| 'pickupMethod'
| 'channelCode'
| 'channelName'
| 'pricingTaxMode'
| 'taxMode'
| 'customsInspection'
| 'destinationType'
@@ -46,6 +46,7 @@ function validDraft() {
pickupMethod: 'SELF_EXPRESS',
channelCode: 'SEA-US-FBA',
channelName: '美国海运渠道',
pricingTaxMode: 'TAX_INCLUDED',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
destinationType: 'FBA',
@@ -77,6 +77,7 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (draft.destinationType !== 'PRIVATE' && !draft.destinationWarehouseId) errors.push('请选择平台维护的目的仓')
if (draft.destinationType === 'PRIVATE' && draft.destinationWarehouseId) errors.push('私人地址不能引用平台公共仓库')
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
if (!['TAX_INCLUDED', 'TAX_EXCLUDED'].includes(draft.pricingTaxMode)) errors.push('请选择报价包税方式')
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
if (draft.pickupMethod === 'PICKUP') {
if (!draft.shipperPartySnapshot?.contactName.trim()) errors.push('请填写提货联系人')
@@ -201,13 +201,13 @@ test('auto-fills trusted warehouse and company data instead of asking customers
})
test('collects the backend-required first-leg fields', () => {
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) {
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'pricingTaxMode', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) {
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
}
assert.match(source, /form\.channelName = channel\?\.name/)
assert.match(source, /form\.channelName = channel\?\.channelName/)
})
test('distills order creation into the confirmed prototype sections without fake pricing', () => {
test('distills order creation into the confirmed prototype sections with server pricing', () => {
assert.match(source, /<h3>目的地信息<\/h3>/)
assert.match(source, /<h3>发货信息<\/h3>/)
assert.match(source, /<h3>货物明细<\/h3>/)
@@ -217,10 +217,51 @@ test('distills order creation into the confirmed prototype sections without fake
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
assert.match(source, /class="cargo-primary-grid"/)
assert.match(source, /title="更多申报信息"/)
assert.doesNotMatch(source, /费用预估|查看最新报价|cost-estimate/)
assert.match(source, /参考运费/)
assert.match(source, /仅供参考,最终以实际结算为准/)
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
})
test('loads only active server channels for the selected domestic warehouse', () => {
assert.match(source, /getMerchantPricingChannels\(warehouseId\)/)
assert.match(source, /const channelOptions = ref<MerchantPricingChannel\[\]>\(\[\]\)/)
assert.match(source, /channelOptions\.value = \[\]/)
assert.match(source, /form\.channelCode = ''/)
assert.match(source, /sequence !== channelRequestSequence \|\| form\.receivingWarehouseId !== warehouseId/)
assert.match(source, /channel\.pricingTaxModes\.includes\(form\.pricingTaxMode\)/)
assert.match(source, /availablePricingTaxModes/)
assert.match(source, /:loading="channelLoading"/)
assert.match(source, /:disabled="!form\.receivingWarehouseId \|\| channelLoading"/)
assert.match(source, /当前收货仓暂无可用渠道/)
assert.match(source, /渠道加载失败/)
assert.match(source, /@click="retryPricingChannels"/)
assert.doesNotMatch(source, /SEA-US-GENERAL|SEA-US-SENSITIVE|SEA-US-EXPRESS|美线 FBA 直送/)
assert.doesNotMatch(source, /channelOptions\.some\(\(channel\) => channel\.code/)
})
test('estimates by warehouse, destination, tax mode, weight and volume without blocking saves', () => {
assert.match(source, /estimateMerchantFirstLegPrice\(request\)/)
assert.match(source, /originWarehouseId: form\.receivingWarehouseId/)
assert.match(source, /destinationCode: form\.destinationType === 'FBA' \? form\.amazonWarehouseCode : undefined/)
assert.match(source, /destinationWarehouseId: form\.destinationType === 'OVERSEAS'/)
assert.match(source, /pricingTaxMode: form\.pricingTaxMode/)
assert.match(source, /actualWeightKg: summary\.grossWeightKg/)
assert.match(source, /actualVolumeCbm: summary\.volumeCbm/)
assert.match(source, /setTimeout\(\(\) => void loadPriceEstimate\(\), 300\)/)
assert.match(source, /sequence === estimateRequestSequence/)
assert.match(source, /您仍可正常保存订单草稿/)
assert.match(source, /v-for="mode in availablePricingTaxModes"/)
assert.match(source, /pricingTaxModeLabel\(mode\)/)
assert.doesNotMatch(source, /当前价格表不提供不包税报价|历史草稿的不包税设置/)
const saveSource = source.slice(source.indexOf('async function saveDraft()'), source.indexOf('async function submitOrder()'))
assert.doesNotMatch(saveSource, /loadPriceEstimate|priceEstimateError|estimateMerchantFirstLegPrice/)
})
test('keeps the reference estimate readable on narrow screens', () => {
assert.match(source, /\.price-estimate__result \{ grid-template-columns: 1fr; \}/)
assert.match(source, /\.price-estimate__result > small \{ grid-column: auto; \}/)
})
test('shows field-level validation and concise guidance for required order inputs', () => {
assert.match(source, /ref="orderFormRef"[^>]*:model="form" :rules="orderFormRules"/)
assert.match(source, /prop="shipperPartySnapshot\.contactName" required/)
@@ -256,10 +297,10 @@ test('shows the full customer-safe order detail without platform-only controls',
for (const heading of ['订单与运输', '收发货信息', '关键业务摘要', '履约快递信息', '货物明细']) {
assert.match(source, new RegExp(`<h3>${heading}<\\/h3>`))
}
for (const field of ['贸易条款', '发货方式', '报关方式', '运输渠道', '承运商', '收货要求', '目的仓类型', '海关查验', '提交时间', '完成时间', 'SKU / UPC', 'HS 编码', '申报价值', '危险品']) {
for (const field of ['贸易条款', '发货方式', '报关方式', '运输渠道', '收货要求', '目的仓类型', '海关查验', '提交时间', '完成时间', 'SKU / UPC', 'HS 编码', '申报价值', '危险品']) {
assert.match(source, new RegExp(field))
}
assert.match(source, /detailOrder\.carrierName \|\| '待平台指派'/)
assert.doesNotMatch(detailSource, /承运商|待平台指派/)
assert.match(source, /detailOrder\.customerRemark/)
assert.doesNotMatch(detailSource, /内部备注|审核通过|添加备注/)
})
@@ -317,6 +358,7 @@ test('loads complete order detail before editing and rejects an unavailable hist
assert.match(source, /const detailedOrder = await store\.loadDetail\(order\.id\)/)
assert.match(source, /draftFromOrder\(detailedOrder\)/)
assert.match(source, /draft\.receivingWarehouseId = ''/)
assert.match(source, /draft\.channelCode = ''/)
assert.match(source, /原平台收货仓当前不可用,请重新选择可用收货仓/)
assert.match(source, /原目的仓当前不可用,请重新选择平台维护的目的仓/)
})
@@ -11,6 +11,13 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import {
estimateMerchantFirstLegPrice,
getMerchantPricingChannels,
type MerchantPriceEstimate,
type MerchantPriceEstimateRequest,
type MerchantPricingChannel,
} from '@/modules/first-leg/api/firstLeg'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
import { useAuthStore } from '@/modules/auth/public'
@@ -48,12 +55,12 @@ const queryFields: QueryField[] = [
{ key: 'status', type: 'select', placeholder: '全部状态', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '订单状态' } },
{ key: 'createdRange', type: 'daterange', placeholder: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
]
const channelOptions = [
{ code: 'SEA-US-GENERAL', name: '美线普货专线' },
{ code: 'SEA-US-SENSITIVE', name: '美线敏感货专线' },
{ code: 'SEA-US-EXPRESS', name: '美线快线' },
{ code: 'SEA-US-FBA', name: '美线 FBA 直送' },
]
const channelOptions = ref<MerchantPricingChannel[]>([])
const channelLoading = ref(false)
const channelError = ref('')
const priceEstimate = ref<MerchantPriceEstimate | null>(null)
const priceEstimateLoading = ref(false)
const priceEstimateError = ref('')
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单', minWidth: 190, slotName: 'order' },
@@ -86,6 +93,9 @@ const pageDataLoading = computed(() => supportingDataLoading.value || (!isCreate
const editorBaseline = ref('')
let cargoSequence = 0
let inboundBillingRequestSequence = 0
let channelRequestSequence = 0
let estimateRequestSequence = 0
let estimateTimer: ReturnType<typeof setTimeout> | undefined
interface CachedPrivateAddress {
id: string
@@ -127,8 +137,9 @@ const orderFormRules: FormRules = {
{ pattern: /^[A-Za-z]{2}$/, message: '请输入两位英文字母,例如 US', trigger: 'blur' },
],
destinationWarehouseName: [{ required: true, message: '请输入目的仓名称', trigger: 'blur' }],
channelCode: [{ required: true, message: '请输入渠道编码', trigger: 'blur' }],
channelCode: [{ required: true, message: '请选择运输渠道', trigger: 'change' }],
channelName: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
pricingTaxMode: [{ required: true, message: '请选择报价包税方式', trigger: 'change' }],
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
taxMode: [{ required: true, message: '请选择报关模式', trigger: 'change' }],
destinationType: [{ required: true, message: '请选择目的仓类型', trigger: 'change' }],
@@ -234,8 +245,9 @@ function emptyDraft(): FirstLegOrderDraft {
destinationWarehouseId: '',
destinationWarehouseName: '',
pickupMethod: 'SELF_EXPRESS',
channelCode: 'SEA-US-FBA',
channelName: '美线 FBA 直送',
channelCode: '',
channelName: '',
pricingTaxMode: 'TAX_INCLUDED',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
destinationType: 'FBA',
@@ -303,6 +315,8 @@ const progressSummaryItems = computed(() => {
})
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
const selectedPricingChannel = computed(() => channelOptions.value.find((item) => item.channelCode === form.channelCode))
const availablePricingTaxModes = computed(() => selectedPricingChannel.value?.pricingTaxModes ?? [])
const hasActiveFilters = computed(() => {
const values = appliedFilters.value
return Boolean(
@@ -382,6 +396,9 @@ watch(detailOpen, (open) => {
onBeforeUnmount(() => {
resetInboundBillingState()
channelRequestSequence += 1
estimateRequestSequence += 1
if (estimateTimer) clearTimeout(estimateTimer)
})
function markEditorClean() {
@@ -430,6 +447,7 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
pickupMethod: order.pickupMethod,
channelCode: order.channelCode,
channelName: order.channelName,
pricingTaxMode: order.pricingTaxMode,
taxMode: order.taxMode,
customsInspection: order.customsInspection,
destinationType: order.destinationType,
@@ -440,10 +458,118 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
}
function selectChannel(code: string) {
const channel = channelOptions.find((item) => item.code === code)
form.channelName = channel?.name ?? ''
const channel = channelOptions.value.find((item) => item.channelCode === code)
form.channelName = channel?.channelName ?? ''
if (channel && !channel.pricingTaxModes.includes(form.pricingTaxMode)) {
form.pricingTaxMode = channel.pricingTaxModes[0] ?? 'TAX_INCLUDED'
}
}
function pricingTaxModeLabel(mode: 'TAX_INCLUDED' | 'TAX_EXCLUDED') {
return mode === 'TAX_INCLUDED' ? '包税' : '不包税'
}
function resetPriceEstimate() {
estimateRequestSequence += 1
priceEstimate.value = null
priceEstimateLoading.value = false
priceEstimateError.value = ''
if (estimateTimer) clearTimeout(estimateTimer)
estimateTimer = undefined
}
async function loadChannelsForWarehouse(warehouseId: string) {
const sequence = ++channelRequestSequence
const selectedCode = form.channelCode
channelOptions.value = []
channelError.value = ''
channelLoading.value = true
form.channelCode = ''
form.channelName = ''
resetPriceEstimate()
try {
const channels = await getMerchantPricingChannels(warehouseId)
if (sequence !== channelRequestSequence || form.receivingWarehouseId !== warehouseId) return
channelOptions.value = channels
const selected = channels.find((item) => item.channelCode === selectedCode)
if (selected) {
form.channelCode = selected.channelCode
form.channelName = selected.channelName
if (!selected.pricingTaxModes.includes(form.pricingTaxMode)) {
form.pricingTaxMode = selected.pricingTaxModes[0] ?? 'TAX_INCLUDED'
}
}
} catch (error) {
if (sequence !== channelRequestSequence || form.receivingWarehouseId !== warehouseId) return
channelError.value = errorMessage(error)
} finally {
if (sequence === channelRequestSequence) channelLoading.value = false
}
}
function createEstimateRequest(): MerchantPriceEstimateRequest | null {
if (!form.receivingWarehouseId || !form.channelCode || form.destinationType === 'PRIVATE') return null
if (!availablePricingTaxModes.value.includes(form.pricingTaxMode)) return null
const summary = summarizeFirstLegCargo(form.items)
if (summary.grossWeightKg <= 0 || summary.volumeCbm <= 0) return null
if (form.destinationType === 'FBA' && !form.amazonWarehouseCode) return null
if (form.destinationType === 'OVERSEAS' && !form.destinationWarehouseId) return null
return {
originWarehouseId: form.receivingWarehouseId,
channelCode: form.channelCode,
destinationType: form.destinationType,
destinationCode: form.destinationType === 'FBA' ? form.amazonWarehouseCode : undefined,
destinationWarehouseId: form.destinationType === 'OVERSEAS' ? form.destinationWarehouseId : undefined,
destinationName: form.destinationWarehouseName || undefined,
pricingTaxMode: form.pricingTaxMode,
actualWeightKg: summary.grossWeightKg,
actualVolumeCbm: summary.volumeCbm,
}
}
async function loadPriceEstimate() {
const request = createEstimateRequest()
const sequence = ++estimateRequestSequence
priceEstimate.value = null
priceEstimateError.value = ''
if (!request) {
priceEstimateLoading.value = false
return
}
priceEstimateLoading.value = true
try {
const estimate = await estimateMerchantFirstLegPrice(request)
if (sequence === estimateRequestSequence) priceEstimate.value = estimate
} catch (error) {
if (sequence === estimateRequestSequence) priceEstimateError.value = errorMessage(error)
} finally {
if (sequence === estimateRequestSequence) priceEstimateLoading.value = false
}
}
function schedulePriceEstimate() {
if (estimateTimer) clearTimeout(estimateTimer)
estimateTimer = setTimeout(() => void loadPriceEstimate(), 300)
}
function retryPricingChannels() {
if (form.receivingWarehouseId) void loadChannelsForWarehouse(form.receivingWarehouseId)
}
watch(
() => JSON.stringify({
receivingWarehouseId: form.receivingWarehouseId,
channelCode: form.channelCode,
pricingTaxMode: form.pricingTaxMode,
destinationType: form.destinationType,
destinationWarehouseId: form.destinationWarehouseId,
amazonWarehouseCode: form.amazonWarehouseCode,
destinationWarehouseName: form.destinationWarehouseName,
items: form.items.map((item) => [item.grossWeightKg, item.volumeCbm]),
}),
schedulePriceEstimate,
)
async function selectDestinationWarehouse(id: string) {
const warehouse = availableDestinationWarehouses.value.find((item) => item.id === id)
if (!warehouse) return
@@ -555,9 +681,19 @@ function removePrivateAddress() {
async function loadSelectedWarehouse(id: string | number | boolean | undefined) {
const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String(id) : ''
if (!warehouseId) return
if (!warehouseId) {
channelRequestSequence += 1
channelOptions.value = []
channelError.value = ''
form.channelCode = ''
form.channelName = ''
resetPriceEstimate()
return
}
const requests = [loadChannelsForWarehouse(warehouseId)]
try {
await store.loadWarehouseDetail(warehouseId)
requests.push(store.loadWarehouseDetail(warehouseId).then(() => undefined))
await Promise.all(requests)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败')
}
@@ -598,6 +734,11 @@ async function openEdit(order: FirstLegOrder) {
const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId)
if (!warehouseAvailable) {
draft.receivingWarehouseId = ''
draft.channelCode = ''
draft.channelName = ''
channelOptions.value = []
channelError.value = ''
resetPriceEstimate()
ElMessage.warning('原平台收货仓当前不可用,请重新选择可用收货仓')
}
if (draft.destinationType !== 'PRIVATE') {
@@ -930,7 +1071,15 @@ async function searchOrders(model: QueryModel) {
</div>
<div v-if="selectedWarehouse" class="selected-warehouse" aria-label="所选收货仓详情"><MapPin :size="17" /><div><span>{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}</span><small>{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}</small><small v-if="selectedWarehouse.contactName || selectedWarehouse.contactPhone">联系人:{{ selectedWarehouse.contactName || '-' }} {{ selectedWarehouse.contactPhone }}</small></div></div>
<div class="order-form-grid compact-transport-grid">
<el-form-item label="渠道" prop="channelCode" required><el-select v-model="form.channelCode" placeholder="请选择运输渠道" @change="selectChannel"><el-option v-if="form.channelCode && !channelOptions.some((channel) => channel.code === form.channelCode)" :label="form.channelName || form.channelCode" :value="form.channelCode" /><el-option v-for="channel in channelOptions" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select></el-form-item>
<el-form-item label="渠道" prop="channelCode" required>
<el-select v-model="form.channelCode" :loading="channelLoading" :disabled="!form.receivingWarehouseId || channelLoading" :placeholder="form.receivingWarehouseId ? '请选择运输渠道' : '请先选择国内收货仓'" @change="selectChannel"><el-option v-for="channel in channelOptions" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select>
<small v-if="channelError" class="field-guidance field-guidance--error">渠道加载失败:{{ channelError }} <el-button link type="primary" @click="retryPricingChannels">重试</el-button></small>
<small v-else-if="form.receivingWarehouseId && !channelLoading && channelOptions.length === 0" class="field-guidance">当前收货仓暂无可用渠道,请联系平台管理员。</small>
</el-form-item>
<el-form-item label="报价包税方式" prop="pricingTaxMode" required>
<el-radio-group v-model="form.pricingTaxMode"><el-radio v-for="mode in availablePricingTaxModes" :key="mode" :value="mode">{{ pricingTaxModeLabel(mode) }}</el-radio></el-radio-group>
<small v-if="form.channelCode && availablePricingTaxModes.length === 0" class="field-guidance field-guidance--error">当前渠道没有可用报价方式,请重新选择渠道。</small>
</el-form-item>
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单报关</el-radio><el-radio value="TAX_REFUND">退税报关</el-radio></el-radio-group></el-form-item>
<el-form-item label="运输方式" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :value="item.code">{{ item.label }}</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否商检" prop="customsInspection" required><el-radio-group v-model="form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item>
@@ -990,6 +1139,13 @@ async function searchOrders(model: QueryModel) {
</div></el-collapse-item></el-collapse>
</article>
<p class="cargo-tip">FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。</p>
<aside class="price-estimate" aria-live="polite">
<div class="price-estimate__heading"><div><strong>参考运费</strong><span>仅供参考,最终以实际结算为准</span></div><el-button v-if="priceEstimateError" link type="primary" @click="loadPriceEstimate">重新估价</el-button></div>
<div v-if="priceEstimateLoading" class="price-estimate__state">正在匹配当前生效价格...</div>
<div v-else-if="priceEstimate" class="price-estimate__result"><strong>{{ priceEstimate.currency }} {{ Number(priceEstimate.estimatedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</strong><span>计费量 {{ priceEstimate.chargeableQuantity }} {{ priceEstimate.chargeUnit }} · 单价 {{ priceEstimate.currency }} {{ priceEstimate.unitPrice }}</span><small>{{ priceEstimate.disclaimer || '仅供参考,最终以实际结算为准' }}</small></div>
<div v-else-if="priceEstimateError" class="price-estimate__state is-error">暂未取得参考价:{{ priceEstimateError }}。您仍可正常保存订单草稿。</div>
<div v-else class="price-estimate__state">选择收货仓、渠道和目的仓,并填写货物重量与体积后显示参考价。</div>
</aside>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
@@ -1018,7 +1174,6 @@ async function searchOrders(model: QueryModel) {
<div><dt>发货方式</dt><dd>{{ pickupMethodLabels[detailOrder.pickupMethod] }}</dd></div>
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div>
<div><dt>承运商</dt><dd>{{ detailOrder.carrierName || '待平台指派' }}</dd></div>
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '-' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '仓库编码待更新' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
@@ -1140,6 +1295,17 @@ async function searchOrders(model: QueryModel) {
.address-book-control { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: center; }
.address-book-control :deep(.el-button) { min-height: 40px; margin-left: 0; }
.field-guidance { display: block; margin-top: 5px; color: var(--muted); line-height: 1.45; }
.field-guidance--error { color: #b54708; }
.price-estimate { margin-top: 16px; padding: 15px 16px; border: 1px solid #cbd5da; background: #f7faf9; }
.price-estimate__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.price-estimate__heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.price-estimate__heading strong { color: var(--navy); font-size: 13px; }
.price-estimate__heading span, .price-estimate__state, .price-estimate__result span, .price-estimate__result small { color: var(--muted); font-size: 11px; line-height: 1.5; }
.price-estimate__state { padding-top: 12px; }
.price-estimate__state.is-error { color: #b54708; }
.price-estimate__result { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 5px 14px; padding-top: 12px; }
.price-estimate__result > strong { color: var(--primary); font-size: 20px; }
.price-estimate__result > small { grid-column: 1 / -1; }
.selected-destination { display: flex; width: min(100%, 720px); align-items: flex-start; gap: 9px; margin: -4px 0 18px 140px; padding: 10px 12px; background: #f2f7f6; color: var(--primary); }
.selected-destination svg { flex: 0 0 17px; margin-top: 2px; }
.selected-destination div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
@@ -1248,6 +1414,8 @@ async function searchOrders(model: QueryModel) {
.segmented-options :deep(.el-radio-button__inner) { border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; }
.warehouse-options { display: grid; width: 100%; grid-template-columns: 1fr; }
.warehouse-options :deep(.el-radio-button), .warehouse-options :deep(.el-radio-button__inner) { width: 100%; }
.price-estimate__result { grid-template-columns: 1fr; }
.price-estimate__result > small { grid-column: auto; }
:global(.first-leg-editor .el-drawer__header) { min-height: 56px; padding: 15px 20px; }
:global(.first-leg-editor .el-drawer__body) { padding: 16px 20px 28px; }
:global(.first-leg-editor .el-drawer__footer) { padding: 10px 20px; }