From 6df8d8976d7bfa6157beabf2894e9e555ce629a3 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 18:59:12 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20HandsontablePro=20=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E8=A1=8C=E9=AB=98=E4=BA=AE=E6=8C=89=E8=A7=86=E8=A7=89=E8=A1=8C?= =?UTF-8?q?=E5=8F=B7=E5=8C=B9=E9=85=8D=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=90=8E=E7=82=B9=E5=87=BB=E9=AB=98=E4=BA=AE=E9=94=99?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/components/HandsontablePro/index.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/HandsontablePro/index.vue b/src/components/HandsontablePro/index.vue index 33cb394..bda9ae2 100644 --- a/src/components/HandsontablePro/index.vue +++ b/src/components/HandsontablePro/index.vue @@ -2046,6 +2046,7 @@ }; removeFilterIconFromColumns(); syncHybridHeaders(); + applySelectedRowStyles(); remasureActionColumnFromDom(); // remasure/updateSettings 后 HOT 会按原生滚动条再次缩短 clone,需再对齐一次 scheduleCloneOverlaySync(); @@ -2443,15 +2444,14 @@ const tbody = hotContainer.value.querySelector('.ht_master tbody'); if (!tbody) return; + // HOT 虚拟滚动只渲染可视区行且复用 tr,DOM 下标 ≠ 视觉行号,须按单元格坐标取行 const rows = tbody.querySelectorAll('tr'); - rows.forEach((row, index) => { - // 移除之前的所有选中状态 - row.classList.remove('selected-row'); - - // 为当前选中的行添加类 - if (selectedRowIndicesSet.value.has(index)) { - row.classList.add('selected-row'); - } + rows.forEach((row) => { + const cell = row.querySelector('td, th') as HTMLTableCellElement | null; + const visualRow = cell ? hotInstance!.getCoords(cell)?.row : undefined; + const selected = + typeof visualRow === 'number' && visualRow >= 0 && selectedRowIndicesSet.value.has(visualRow); + row.classList.toggle('selected-row', selected); }); }; -- 2.45.2 From a917216c3bf2a6b6d00d9ec4866c6711ff468634 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 20 Oct 2026 11:23:15 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E8=BF=90?= =?UTF-8?q?=E5=8D=95/=E4=BB=A3=E5=8F=91=E8=BF=90=E5=8D=95=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E8=A1=A5=E9=BD=90=E5=9F=BA=E6=9C=AC=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E4=B8=8E=E4=B8=8B=E5=8D=95=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/locale/en-US.ts | 2 + src/locale/zh-CN.ts | 1 + .../CreateLogisticsModal.vue | 526 ++++++++++++++++-- 3 files changed, 474 insertions(+), 55 deletions(-) diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index c40dcb7..fe7a24a 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -1058,6 +1058,8 @@ export default { 'order.platformStoreOrder.createLogistics.tip': 'Review recipient and line items for each order, then confirm the shipping product before submitting.', 'order.platformStoreOrder.createLogistics.product': 'Shipping Product', 'order.platformStoreOrder.createLogistics.productRequired': 'Please select a shipping product', + 'order.platformStoreOrder.createLogistics.basicRequired': + 'Please complete waybill basic info (warehouse, cargo type, weight, dimensions, inbound method, etc.)', 'order.platformStoreOrder.createLogistics.orderList': 'Selected Orders', 'order.platformStoreOrder.createLogistics.receiverTitle': 'Recipient Check', 'order.platformStoreOrder.createLogistics.linesTitle': 'Line Items', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 5478774..67e1dba 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -1047,6 +1047,7 @@ export default { 'order.platformStoreOrder.createLogistics.tip': '请逐单核对收件人与商品明细,确认运输方式后再提交,避免下错单。', 'order.platformStoreOrder.createLogistics.product': '运输方式', 'order.platformStoreOrder.createLogistics.productRequired': '请选择运输方式', + 'order.platformStoreOrder.createLogistics.basicRequired': '请完整填写运单基本信息(仓库、货物属性、实重、长宽高、到仓方式等)', 'order.platformStoreOrder.createLogistics.orderList': '待创建清单', 'order.platformStoreOrder.createLogistics.receiverTitle': '收件人校对', 'order.platformStoreOrder.createLogistics.linesTitle': '商品明细', diff --git a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue index e659513..699e6b4 100644 --- a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue +++ b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue @@ -13,13 +13,33 @@ import { postOrderStockBindInventoryCandidates, postOrderStockBindParcelCandidates, } from '@/api/order' +import { getSenderAddressList } from '@/api/order/senderAddress' +import { getExportQualificationList } from '@/api/customer/exportQualification' +import { getDictListAll } from '@/api/system/dict' import { resolveOmsAssetUrl } from '@/api/oms/common' import SlidingTabs from '@/components/SlidingTabs/index.vue' import { $baseMessage } from '@/utils' +import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute' import { normalizeListRow, pickField } from '@/utils/warehouseListMapper' import type { CreateLogisticsMode } from './constants' +type SelectOption = { label: string; value: string } +type QualOption = { id: number; label: string; isDefault: boolean } + +const SAMPLE_EXPORT_CUSTOMS_MODE_CODE = '9610Simple' +const EXPORT_CUSTOMS_MODE_9610_SUMMARY = '9610Summary' +const AGREEMENT_TEXT = `《物流下单用户协议》及《物流服务赔偿协议》 + +一、服务说明 +您确认通过本系统创建的运单/代发订单,将按所选运输方式、仓库与件重尺计费并履约。 + +二、责任与理赔 +请以正式协议文本为准;勾选即视为已阅读并同意全部条款。 + +三、勾选确认 +本人已仔细阅读并充分理解全部条款,自愿接受本协议所有约定。` + export type { CreateLogisticsMode } export type BindDraftItem = { @@ -43,6 +63,8 @@ type OrderDraft = { customerId?: number warehouseId?: number platformOrderNo: string + /** 可编辑客户单号,默认平台订单号 */ + clientNo: string orderCountry: string recipientName: string recipientPhone: string @@ -103,6 +125,28 @@ const batchMatchDeclaration = ref(false) const batchQtyPerOrder = ref(1) let searchTimer: ReturnType | null = null +/** 运单基本信息(对齐下单页) */ +const cargoAttribute = ref('general') +const actualWeight = ref(undefined) +const quantity = ref(1) +const lengthCm = ref(10) +const widthCm = ref(10) +const heightCm = ref(10) +const exportCustomsMode = ref(SAMPLE_EXPORT_CUSTOMS_MODE_CODE) +const exportQualificationId = ref() +const platformCode = ref() +const inboundMethod = ref<'selfShip' | 'doorPickup'>('selfShip') +const senderAddressId = ref() +const expectPickupTime = ref() +const agreementChecked = ref(false) +const agreementModalVisible = ref(false) +const dictLoading = ref(false) +const cargoAttributeOptions = ref([]) +const exportCustomsModeOptions = ref([]) +const ecommercePlatformOptions = ref([]) +const exportQualificationOptions = ref([]) +const pickupAddressOptions = ref>([]) + const modalVisible = computed({ get: () => props.visible, set: (v: boolean) => emit('update:visible', v), @@ -167,17 +211,172 @@ const warehouseLabel = computed(() => { return opt?.label || '-' }) -const canSubmit = computed(() => { +const needExportQualification = computed( + () => + String(exportCustomsMode.value ?? '').trim().toLowerCase() === + EXPORT_CUSTOMS_MODE_9610_SUMMARY.toLowerCase(), +) + +const basicInfoReady = computed(() => { if (!productCode.value) return false + if (!warehouseId.value) return false + if (!String(cargoAttribute.value || '').trim()) return false + if (!(Number(actualWeight.value) > 0)) return false + if (!(Number(quantity.value) > 0)) return false + if (!(Number(lengthCm.value) > 0) || !(Number(widthCm.value) > 0) || !(Number(heightCm.value) > 0)) { + return false + } + if (needExportQualification.value) { + if (!(Number(exportQualificationId.value) > 0)) return false + if (!String(platformCode.value || '').trim()) return false + } + if (inboundMethod.value === 'doorPickup') { + if (!(Number(senderAddressId.value) > 0)) return false + if (!expectPickupTime.value) return false + } + if (drafts.value.some((d) => !String(d.clientNo || '').trim())) return false + return true +}) + +const canSubmit = computed(() => { + if (!basicInfoReady.value) return false + if (!agreementChecked.value) return false if (drafts.value.length === 0) return false if (drafts.value.some((d) => d.receiverMissing || d.linesMissing)) return false if (isFulfill.value) { - if (!warehouseId.value) return false if (drafts.value.some((d) => d.bindItems.length === 0)) return false } return true }) +function resolveExportCustomsMode9610Simple() { + const opts = exportCustomsModeOptions.value + const byCode = opts.find( + (o) => o.value === SAMPLE_EXPORT_CUSTOMS_MODE_CODE || o.value.toLowerCase() === '9610simple', + ) + if (byCode) return byCode.value + const byLabel = opts.find((o) => /9610.*简易|简易.*9610/i.test(o.label)) + if (byLabel) return byLabel.value + return SAMPLE_EXPORT_CUSTOMS_MODE_CODE +} + +function mapDictOptions(rows: any[]): SelectOption[] { + return (rows || []) + .map((row) => { + const value = String(row?.dictCode ?? row?.DictCode ?? '').trim() + const label = String(row?.dictName ?? row?.DictName ?? value).trim() + if (!value) return null + return { label: label || value, value } + }) + .filter((x): x is SelectOption => !!x) +} + +async function loadBasicDicts() { + dictLoading.value = true + try { + const [cargoRows, exportRows, platformRows] = await Promise.all([ + getDictListAll({ dictType: 'cargo_attribute' }), + getDictListAll({ dictType: 'export_customs_mode' }), + getDictListAll({ dictType: 'e-commerce_platform' }), + ]) + cargoAttributeOptions.value = mapDictOptions(cargoRows as any[]) + exportCustomsModeOptions.value = mapDictOptions(exportRows as any[]) + ecommercePlatformOptions.value = mapDictOptions(platformRows as any[]) + if (!cargoAttribute.value && cargoAttributeOptions.value[0]) { + cargoAttribute.value = normalizeCargoAttributeCode(cargoAttributeOptions.value[0].value) + } else { + cargoAttribute.value = normalizeCargoAttributeCode(cargoAttribute.value) + } + exportCustomsMode.value = resolveExportCustomsMode9610Simple() + } catch { + cargoAttributeOptions.value = [ + { label: '普货', value: 'general' }, + { label: '带电', value: 'electronics' }, + { label: '敏感', value: 'sensitive' }, + ] + exportCustomsModeOptions.value = [ + { label: '9610 简易申报', value: SAMPLE_EXPORT_CUSTOMS_MODE_CODE }, + { label: '9610 汇总申报', value: EXPORT_CUSTOMS_MODE_9610_SUMMARY }, + ] + } finally { + dictLoading.value = false + } +} + +async function loadExportQualifications() { + const customerId = resolveCandidatesCustomerId() + try { + const res = await getExportQualificationList({ + page: 1, + pageSize: 100, + customerId, + }) + exportQualificationOptions.value = (res.items || []) + .map((item: Record) => { + const id = Number(pickField(item, 'id', 'Id') ?? 0) + if (!id) return null + const companyName = String(pickField(item, 'companyName', 'CompanyName') ?? '').trim() + const creditCode = String(pickField(item, 'socialCreditCode', 'SocialCreditCode') ?? '').trim() + const isDefault = Boolean(pickField(item, 'isDefault', 'IsDefault')) + const base = companyName || creditCode || `#${id}` + return { id, label: isDefault ? `${base}(默认)` : base, isDefault } + }) + .filter((x): x is QualOption => !!x) + if (!exportQualificationId.value) { + const def = + exportQualificationOptions.value.find((x) => x.isDefault) || exportQualificationOptions.value[0] + exportQualificationId.value = def?.id + } + } catch { + exportQualificationOptions.value = [] + } +} + +async function loadPickupAddresses() { + const customerId = resolveCandidatesCustomerId() + try { + const res = await getSenderAddressList({ + page: 1, + pageSize: 200, + addressType: 'pickup', + customerId, + }) + pickupAddressOptions.value = (res.items || []) + .map((item: Record) => { + const id = Number(pickField(item, 'id', 'Id') ?? 0) + if (!id) return null + const name = String(pickField(item, 'contactName', 'ContactName', 'name', 'Name') ?? '').trim() + const phone = String(pickField(item, 'phone', 'Phone') ?? '').trim() + const label = [name, phone].filter(Boolean).join(' · ') || `#${id}` + return { label, value: id } + }) + .filter((x): x is { label: string; value: number } => !!x) + } catch { + pickupAddressOptions.value = [] + } +} + +function openAgreementModal() { + agreementModalVisible.value = true +} + +function buildParcelBasicPayload() { + return { + cargoAttribute: normalizeCargoAttributeCode(cargoAttribute.value), + actualWeight: Number(actualWeight.value), + quantity: Number(quantity.value), + length: Number(lengthCm.value), + width: Number(widthCm.value), + height: Number(heightCm.value), + exportCustomsMode: String(exportCustomsMode.value || '').trim() || resolveExportCustomsMode9610Simple(), + exportQualificationId: needExportQualification.value ? exportQualificationId.value : undefined, + platformCode: needExportQualification.value ? platformCode.value : undefined, + inboundMethod: inboundMethod.value, + senderAddressId: inboundMethod.value === 'doorPickup' ? senderAddressId.value : undefined, + expectPickupTime: inboundMethod.value === 'doorPickup' ? expectPickupTime.value : undefined, + } +} + function close() { emit('update:visible', false) } @@ -208,11 +407,13 @@ function mapDetail(raw: Record): OrderDraft { const shipLines = lines const customerIdRaw = Number(pickField(row, 'customerId', 'CustomerId') ?? 0) const warehouseIdRaw = Number(pickField(row, 'warehouseId', 'WarehouseId') ?? 0) + const platformOrderNo = String(pickField(row, 'platformOrderNo', 'PlatformOrderNo') ?? '') return { storeOrderId: Number(pickField(row, 'id', 'Id')), customerId: Number.isFinite(customerIdRaw) && customerIdRaw > 0 ? customerIdRaw : undefined, warehouseId: Number.isFinite(warehouseIdRaw) && warehouseIdRaw > 0 ? warehouseIdRaw : undefined, - platformOrderNo: String(pickField(row, 'platformOrderNo', 'PlatformOrderNo') ?? ''), + platformOrderNo, + clientNo: platformOrderNo, orderCountry: String(pickField(row, 'orderCountry', 'OrderCountry') ?? ''), recipientName, recipientPhone: String(pickField(row, 'recipientPhone', 'RecipientPhone') ?? ''), @@ -525,6 +726,10 @@ function buildBindPayload(items: BindDraftItem[]) { async function handleSubmit(): Promise { if (!canSubmit.value) { + if (!agreementChecked.value) { + $baseMessage(t('order.placeOrder.agreement.required'), 'warning') + return false + } if (!productCode.value) { $baseMessage( isFulfillWms.value @@ -534,10 +739,14 @@ async function handleSubmit(): Promise { ) return false } - if (isFulfill.value && !warehouseId.value) { + if (!warehouseId.value) { $baseMessage(t('order.platformStoreOrder.message.warehouseRequired'), 'warning') return false } + if (!basicInfoReady.value) { + $baseMessage(t('order.platformStoreOrder.createLogistics.basicRequired'), 'warning') + return false + } const bad = drafts.value.find( (d) => d.receiverMissing || d.linesMissing || (isFulfill.value && d.bindItems.length === 0), ) @@ -579,12 +788,14 @@ async function handleSubmit(): Promise { try { const items = drafts.value.map((d) => ({ storeOrderId: d.storeOrderId, + clientNo: String(d.clientNo || '').trim() || undefined, bindItems: isFulfill.value ? buildBindPayload(d.bindItems) : undefined, })) const body = { productCode: productCode.value, warehouseId: warehouseId.value, customerId: props.customerId, + parcelBasic: buildParcelBasicPayload(), items, } const result = isFulfillWms.value @@ -636,7 +847,29 @@ watch( batchMode.value = props.storeOrderIds.length > 1 batchMatchDeclaration.value = false batchQtyPerOrder.value = 1 + cargoAttribute.value = 'general' + actualWeight.value = undefined + quantity.value = 1 + lengthCm.value = 10 + widthCm.value = 10 + heightCm.value = 10 + exportCustomsMode.value = SAMPLE_EXPORT_CUSTOMS_MODE_CODE + exportQualificationId.value = undefined + platformCode.value = undefined + inboundMethod.value = 'selfShip' + senderAddressId.value = undefined + expectPickupTime.value = undefined + agreementChecked.value = false + await loadBasicDicts() await loadDrafts() + // 实重默认按订单申报行估算(取首单) + const first = drafts.value[0] + if (first && !(Number(actualWeight.value) > 0)) { + const est = first.lines.reduce((s, x) => s + Math.max(1, Number(x.quantity) || 0) * 0.01, 0) + actualWeight.value = Number(est.toFixed(3)) || 0.01 + } + await loadExportQualifications() + await loadPickupAddresses() if (isFulfill.value) await loadCandidates() }, { immediate: true }, @@ -652,14 +885,24 @@ watch(leftTab, () => { if (!props.visible || !isFulfill.value) return void loadCandidates() }) + +watch(needExportQualification, (need) => { + if (!props.visible || !need) return + void loadExportQualifications() +}) + +watch(inboundMethod, (method) => { + if (!props.visible || method !== 'doorPickup') return + void loadPickupAddresses() +}) -- 2.45.2 From 209a6e1c2a43b603c4143a18dacba4dc031b6b52 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 20 Oct 2026 11:48:54 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E5=88=9B=E5=BB=BA=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E5=88=B0=E4=BB=93=E6=96=B9=E5=BC=8F=E5=AF=B9=E9=BD=90=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=EF=BC=88=E4=BB=93=E5=BA=93=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=20Popover/=E4=B8=8A=E9=97=A8=E6=8F=BD=E6=94=B6=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../CreateLogisticsModal.vue | 395 +++++++++++++++++- src/views/order/platformStoreOrder/index.vue | 18 +- 2 files changed, 404 insertions(+), 9 deletions(-) diff --git a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue index f495c7f..3b7c7f6 100644 --- a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue +++ b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue @@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { Modal } from '@arco-design/web-vue' -import { IconPlus, IconDelete, IconQuestionCircle } from '@arco-design/web-vue/es/icon' +import { IconPlus, IconDelete, IconQuestionCircle, IconCopy } from '@arco-design/web-vue/es/icon' import { batchCreatePlatformStoreFulfill, batchCreatePlatformStoreFulfillWms, @@ -16,6 +16,7 @@ import { } from '@/api/order' import { getSenderAddressList } from '@/api/order/senderAddress' import { getExportQualificationList } from '@/api/customer/exportQualification' +import { getSalesProductList } from '@/api/channel/salesProduct' import { getDictListAll } from '@/api/system/dict' import { resolveOmsAssetUrl } from '@/api/oms/common' import SlidingTabs from '@/components/SlidingTabs/index.vue' @@ -23,6 +24,19 @@ import ExportCustoms9610GuideModal from '@/views/order/placeOrder/components/Exp import { $baseMessage } from '@/utils' import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute' import { normalizeListRow, pickField } from '@/utils/warehouseListMapper' +import { + formatWarehouseShipAddress, + formatWarehouseShipRecipient, +} from '@/utils/warehouseShipAddress' + +type WarehouseOption = { + label: string + value: number + recipient?: string + contactPhone?: string + shippingAddress?: string + remark?: string +} import type { CreateLogisticsMode } from './constants' @@ -92,7 +106,7 @@ const props = defineProps<{ mode: CreateLogisticsMode storeOrderIds: number[] productOptions: Array<{ label: string; value: string }> - warehouseOptions: Array<{ label: string; value: number }> + warehouseOptions: WarehouseOption[] defaultProductCode?: string defaultWarehouseId?: number customerId?: number @@ -141,6 +155,7 @@ const platformCode = ref() const inboundMethod = ref<'selfShip' | 'doorPickup'>('selfShip') const senderAddressId = ref() const expectPickupTime = ref() +const carrierService = ref() const agreementChecked = ref(false) const agreementModalVisible = ref(false) const exportCustomsGuideVisible = ref(false) @@ -150,11 +165,70 @@ const exportCustomsModeOptions = ref([]) const ecommercePlatformOptions = ref([]) const exportQualificationOptions = ref([]) const pickupAddressOptions = ref>([]) +const domesticPickupProductOptions = ref([]) +const pickupAddressLoading = ref(false) function goToExportQualificationManage() { router.push({ path: '/user/exportQualification' }) } +const warehouseShipAddressList = computed(() => props.warehouseOptions || []) +const warehousesWithShipAddress = computed(() => + warehouseShipAddressList.value.filter((opt) => warehouseHasAddress(opt)), +) + +function warehouseHasAddress(opt?: WarehouseOption) { + if (!opt) return false + return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress) +} + +function displayWarehouseRecipient(opt?: WarehouseOption) { + return formatWarehouseShipRecipient(opt?.recipient, '') +} + +function displayWarehouseShippingAddress(opt?: WarehouseOption) { + return formatWarehouseShipAddress(opt?.shippingAddress, '') +} + +function buildWarehouseShipCopyText(opt?: WarehouseOption) { + if (!opt) return '' + const lines = [ + opt.label || '', + `收件人:${displayWarehouseRecipient(opt)}`, + `电话:${opt.contactPhone || '-'}`, + `地址:${displayWarehouseShippingAddress(opt)}`, + ] + const clientNo = String(activeDraft.value?.clientNo ?? '').trim() + if (clientNo) lines.push(`客户单号:${clientNo}`) + return lines.filter(Boolean).join('\n') +} + +async function copyWarehouseShipAddress(opt?: WarehouseOption) { + const text = buildWarehouseShipCopyText(opt) + if (!text.trim()) { + $baseMessage('该仓库暂未配置收货地址', 'warning') + return + } + try { + await navigator.clipboard.writeText(text) + $baseMessage('仓库寄件信息已复制', 'success') + } catch { + const area = document.createElement('textarea') + area.value = text + area.style.position = 'fixed' + area.style.left = '-9999px' + document.body.appendChild(area) + area.select() + try { + document.execCommand('copy') + $baseMessage('仓库寄件信息已复制', 'success') + } catch { + $baseMessage('复制失败', 'error') + } + document.body.removeChild(area) + } +} + const modalVisible = computed({ get: () => props.visible, set: (v: boolean) => emit('update:visible', v), @@ -347,6 +421,7 @@ async function loadExportQualifications() { async function loadPickupAddresses() { const customerId = resolveCandidatesCustomerId() + pickupAddressLoading.value = true try { const res = await getSenderAddressList({ page: 1, @@ -366,6 +441,38 @@ async function loadPickupAddresses() { .filter((x): x is { label: string; value: number } => !!x) } catch { pickupAddressOptions.value = [] + } finally { + pickupAddressLoading.value = false + } +} + +async function loadDomesticPickupProducts() { + try { + const [groupRows, productRes] = await Promise.all([ + getDictListAll({ dictType: 'sales_product_group' }), + getSalesProductList({ page: 1, pageSize: 200, isActive: 2 }), + ]) + const domesticPickupGroupCodes = new Set( + (groupRows || []) + .filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收')) + .map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim()) + .filter(Boolean), + ) + const items = (productRes as any)?.items || [] + domesticPickupProductOptions.value = items + .filter((row: any) => { + const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim() + return domesticPickupGroupCodes.size > 0 && domesticPickupGroupCodes.has(groupCode) + }) + .map((row: any) => { + const code = String(row?.productCode ?? row?.ProductCode ?? '').trim() + const name = String(row?.productName ?? row?.ProductName ?? '').trim() + if (!code) return null + return { value: code, label: name ? `${name} (${code})` : code } + }) + .filter((x: SelectOption | null): x is SelectOption => !!x) + } catch { + domesticPickupProductOptions.value = [] } } @@ -389,6 +496,10 @@ function buildParcelBasicPayload() { inboundMethod: inboundMethod.value, senderAddressId: inboundMethod.value === 'doorPickup' ? senderAddressId.value : undefined, expectPickupTime: inboundMethod.value === 'doorPickup' ? expectPickupTime.value : undefined, + carrierService: + inboundMethod.value === 'doorPickup' && String(carrierService.value || '').trim() + ? String(carrierService.value).trim() + : undefined, } } @@ -874,6 +985,7 @@ watch( inboundMethod.value = 'selfShip' senderAddressId.value = undefined expectPickupTime.value = undefined + carrierService.value = undefined agreementChecked.value = false await loadBasicDicts() await loadDrafts() @@ -883,8 +995,11 @@ watch( const est = first.lines.reduce((s, x) => s + Math.max(1, Number(x.quantity) || 0) * 0.01, 0) actualWeight.value = String(Number(est.toFixed(3)) || 0.01) } - await loadExportQualifications() - await loadPickupAddresses() + await Promise.all([ + loadExportQualifications(), + loadPickupAddresses(), + loadDomesticPickupProducts(), + ]) if (isFulfill.value) await loadCandidates() }, { immediate: true }, @@ -909,6 +1024,7 @@ watch(needExportQualification, (need) => { watch(inboundMethod, (method) => { if (!props.visible || method !== 'doorPickup') return void loadPickupAddresses() + if (!domesticPickupProductOptions.value.length) void loadDomesticPickupProducts() }) @@ -1117,11 +1233,106 @@ watch(inboundMethod, (method) => { size="small" class="inbound-method-radios" > - 自寄到仓 + + + 自寄到仓 + + + + + + 上门揽收 - + @@ -1129,12 +1340,24 @@ watch(inboundMethod, (method) => { @@ -1708,6 +1950,14 @@ watch(inboundMethod, (method) => { grid-column: 1 / -1; } +.inbound-pickup-batch-hint { + margin: -4px 0 8px; + grid-column: 1 / -1; + font-size: 12px; + line-height: 1.45; + color: var(--color-text-3, #86909c); +} + .inbound-method-radios { display: inline-flex; flex-wrap: wrap; @@ -2474,3 +2724,136 @@ watch(inboundMethod, (method) => { } + + diff --git a/src/views/order/platformStoreOrder/index.vue b/src/views/order/platformStoreOrder/index.vue index 5d274a7..c1ee30f 100644 --- a/src/views/order/platformStoreOrder/index.vue +++ b/src/views/order/platformStoreOrder/index.vue @@ -82,7 +82,15 @@ const fulfillStatus = ref(resolveFulfillStatusFromQuery()) const platformCode = ref(resolvePlatformFromQuery()) const keyword = ref('') const summary = ref>({}) -const warehouseOptions = ref>([]) +type WarehouseOption = { + label: string + value: number + recipient?: string + contactPhone?: string + shippingAddress?: string + remark?: string +} +const warehouseOptions = ref([]) const warehouseNameMap = computed(() => buildWarehouseNameMap(warehouseOptions.value)) const productOptions = ref>([]) const wmsProductOptions = ref>([]) @@ -728,8 +736,12 @@ onMounted(async () => { const warehouseTask = getWarehouseList({ page: 1, pageSize: 200 }) .then(({ items }) => { warehouseOptions.value = (items || []).map((w: any) => ({ - label: w.warehouseName || `#${w.id}`, - value: Number(w.id), + label: w.warehouseName || w.WarehouseName || `#${w.id ?? w.Id}`, + value: Number(w.id ?? w.Id), + recipient: String(w.recipient ?? w.Recipient ?? '').trim() || undefined, + contactPhone: String(w.contactPhone ?? w.ContactPhone ?? '').trim() || undefined, + shippingAddress: String(w.shippingAddress ?? w.ShippingAddress ?? '').trim() || undefined, + remark: String(w.remark ?? w.Remark ?? '').trim() || undefined, })) if (warehouseOptions.value.length) matchWarehouseId.value = warehouseOptions.value[0].value }) -- 2.45.2 From 4a8bf209e2a4b469fe5c6513af45209939fa8231 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 20 Oct 2026 11:52:15 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=88=9B=E5=BB=BA=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E4=BF=A1=E6=81=AF=E6=94=B9=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E5=9B=9B=E5=88=97=EF=BC=8C=E5=8D=8F=E8=AE=AE=E6=A0=8F=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E5=BC=B9=E7=AA=97=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../CreateLogisticsModal.vue | 87 ++++++++++++------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue index 3b7c7f6..09fe03b 100644 --- a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue +++ b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue @@ -242,6 +242,17 @@ const title = computed(() => { return t('order.platformStoreOrder.createLogistics.title') }) +const submitOkText = computed(() => { + if (isFulfillWms.value) return t('order.platformStoreOrder.createFulfillWms.submit') + if (isFulfill.value) return t('order.platformStoreOrder.createFulfill.submit') + return t('order.platformStoreOrder.createLogistics.submit') +}) + +async function onFooterOk() { + const ok = await handleSubmit() + if (ok) close() +} + const activeDraft = computed(() => drafts.value.find((d) => d.storeOrderId === activeId.value)) const activeShipLines = computed(() => activeDraft.value?.lines || []) @@ -1034,22 +1045,12 @@ watch(inboundMethod, (method) => { :title="title" :width="isFulfill ? '1360px' : '1080px'" :top="isFulfill ? '20px' : '6vh'" - :ok-loading="submitting" - :ok-text=" - isFulfillWms - ? t('order.platformStoreOrder.createFulfillWms.submit') - : isFulfill - ? t('order.platformStoreOrder.createFulfill.submit') - : t('order.platformStoreOrder.createLogistics.submit') - " - :ok-button-props="{ disabled: !canSubmit || loading }" unmount-on-close :body-style="{ padding: '12px 16px', maxHeight: isFulfill ? 'calc(100vh - 140px)' : 'calc(100vh - 160px)', overflow: isFulfill ? 'hidden' : 'auto', }" - :on-before-ok="handleSubmit" @cancel="close" >
{{ t('common.loading') }}
@@ -1077,7 +1078,7 @@ watch(inboundMethod, (method) => { }} - + -
- - - {{ t('order.placeOrder.agreement.label') }} - -
- - {{ productLabel }} - - · {{ drafts.length }} {{ t('order.platformStoreOrder.createLogistics.unitOrders') }} - - -
@@ -1746,6 +1733,34 @@ watch(inboundMethod, (method) => {
+ + { } } -.psc-submit-bar { +.psc-modal-footer { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 12px 16px; flex-wrap: wrap; - margin-bottom: 10px; - padding: 12px 16px; - flex-shrink: 0; + width: 100%; } .order-agreement-bar { @@ -2071,10 +2083,19 @@ watch(inboundMethod, (method) => { } } -.psc-submit-bar__hint { - margin-left: auto; +.psc-modal-footer__hint { font-size: 12px; color: var(--color-text-3); + flex: 1; + min-width: 120px; +} + +.psc-modal-footer__actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-shrink: 0; } .psc-agreement-body { -- 2.45.2 From 336f8657ee650943296505f1c42777062e3b08d9 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 21 Oct 2026 18:07:41 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E5=AE=98=E7=BD=91=E6=B5=B7?= =?UTF-8?q?=E6=B4=8B=E8=93=9D=E6=B3=A2=E6=B5=AA=E9=A6=96=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E6=8C=87=E5=8D=97=E5=88=86=E5=8F=97=E4=BC=97=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=9A=E5=8A=A1=E9=A1=B5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官网换 GradientWaves/TextType 并做性能减负;模块指南拆开发/运营;含营销指南、轨迹单与 H5 价格等改动。 Co-authored-by: Cursor --- package.json | 1 + pnpm-lock.yaml | 8 + src/api/TrackOrder/typings.d.ts | 42 ++ src/components.d.ts | 1 + src/components/GuideAudienceTabs/index.vue | 90 +++ src/components/HandsontablePro/index.vue | 56 +- src/components/top-nav/index.vue | 1 + src/locale/en-US.ts | 1 + src/locale/zh-CN.ts | 1 + src/router/routes/newModules/marketing.ts | 13 + src/utils/headerFilterAdapter.ts | 23 +- src/utils/menu-icon.ts | 1 + src/utils/route-name-mapper.ts | 4 + .../apiDebugger/{guide.md => guide-dev.md} | 27 +- src/views/api/apiDebugger/guide-ops.md | 210 +++++++ src/views/api/apiDebugger/index.vue | 43 +- src/views/channel/channelGuide/guide-dev.md | 154 +++++ src/views/channel/channelGuide/guide-ops.md | 182 ++++++ src/views/channel/channelGuide/guide.md | 173 ------ src/views/channel/channelGuide/index.vue | 42 +- src/views/h5/customer-center/locale/en-US.ts | 8 +- src/views/h5/customer-center/locale/zh-CN.ts | 9 +- src/views/h5/customer-center/price/index.vue | 293 ++++++++- .../marketing/marketingGuide/guide-dev.md | 126 ++++ .../marketing/marketingGuide/guide-ops.md | 342 ++++++++++ src/views/marketing/marketingGuide/index.vue | 13 + src/views/order/orderGuide/guide-dev.md | 361 +++++++++++ src/views/order/orderGuide/guide-ops.md | 280 +++++++++ src/views/order/orderGuide/guide.md | 583 ------------------ src/views/order/orderGuide/index.vue | 42 +- .../CreateLogisticsModal.vue | 81 ++- src/views/site/GradientWaves.vue | 495 +++++++++++++++ src/views/site/SiteAdvisorShell.vue | 53 +- src/views/site/SiteIllusIcon.vue | 47 +- src/views/site/SiteMessageCard.vue | 95 +++ src/views/site/SiteStageMaps.vue | 74 --- src/views/site/TextType.vue | 220 +++++++ src/views/site/index.vue | 4 - src/views/site/lib/site-cover.ts | 117 ---- src/views/site/lib/site-reply.ts | 36 ++ src/views/site/lib/site-welcome.ts | 71 +++ src/views/site/loadVendors.ts | 18 - src/views/site/styles/ai-advisor.css | 154 +++-- src/views/track/trackGuide/guide-dev.md | 137 ++++ src/views/track/trackGuide/guide-ops.md | 179 ++++++ src/views/track/trackGuide/guide.md | 550 ----------------- src/views/track/trackGuide/index.vue | 42 +- .../components/TrackOrderDetailDrawer.vue | 11 +- .../composables/useTrackOrderData.ts | 26 +- .../track/trackOrder/config/tableColumns.ts | 21 +- src/views/track/trackOrder/index.vue | 2 + vite.config.ts | 2 + 52 files changed, 3742 insertions(+), 1823 deletions(-) create mode 100644 src/components/GuideAudienceTabs/index.vue rename src/views/api/apiDebugger/{guide.md => guide-dev.md} (95%) create mode 100644 src/views/api/apiDebugger/guide-ops.md create mode 100644 src/views/channel/channelGuide/guide-dev.md create mode 100644 src/views/channel/channelGuide/guide-ops.md delete mode 100644 src/views/channel/channelGuide/guide.md create mode 100644 src/views/marketing/marketingGuide/guide-dev.md create mode 100644 src/views/marketing/marketingGuide/guide-ops.md create mode 100644 src/views/marketing/marketingGuide/index.vue create mode 100644 src/views/order/orderGuide/guide-dev.md create mode 100644 src/views/order/orderGuide/guide-ops.md delete mode 100644 src/views/order/orderGuide/guide.md create mode 100644 src/views/site/GradientWaves.vue delete mode 100644 src/views/site/SiteStageMaps.vue create mode 100644 src/views/site/TextType.vue delete mode 100644 src/views/site/lib/site-cover.ts delete mode 100644 src/views/site/loadVendors.ts create mode 100644 src/views/track/trackGuide/guide-dev.md create mode 100644 src/views/track/trackGuide/guide-ops.md delete mode 100644 src/views/track/trackGuide/guide.md diff --git a/package.json b/package.json index 34df51b..11063c5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "mermaid": "^11.16.0", "mitt": "^3.0.0", "nprogress": "^0.2.0", + "ogl": "^1.0.11", "pinia": "^2.0.23", "qrcode": "^1.5.4", "query-string": "^8.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8415c73..966f6f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + ogl: + specifier: ^1.0.11 + version: 1.0.11 pinia: specifier: ^2.0.23 version: 2.3.1(typescript@4.9.5)(vue@3.5.18(typescript@4.9.5)) @@ -5185,6 +5188,9 @@ packages: ofetch@1.4.1: resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + ogl@1.0.11: + resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -12685,6 +12691,8 @@ snapshots: node-fetch-native: 1.6.6 ufo: 1.6.1 + ogl@1.0.11: {} + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src/api/TrackOrder/typings.d.ts b/src/api/TrackOrder/typings.d.ts index 8d52fa5..22d119c 100644 --- a/src/api/TrackOrder/typings.d.ts +++ b/src/api/TrackOrder/typings.d.ts @@ -80,6 +80,48 @@ declare namespace API { createTimeStart?: string; /** 创建时间截止 */ createTimeEnd?: string; + /** 尾程单号 */ + trackNo?: string; + /** 其他单号 */ + otherNo?: string; + /** 销售产品 */ + product?: string; + /** 渠道 */ + channel?: string; + /** API 编码 */ + apiCode?: string; + /** 国家编码 */ + countryCode?: string; + /** 州省 */ + province?: string; + /** 城市 */ + city?: string; + /** 邮编 */ + postcode?: string; + /** 邮箱 */ + email?: string; + /** 最新轨迹状态 */ + newestTrackStatus?: string; + /** 最新轨迹内容 */ + newestTrackContent?: string; + /** 最新轨迹内容英文 */ + newestTrackEnContent?: string; + /** 最新轨迹发生地 */ + newestTrackPlace?: string; + /** 最新轨迹时间开始 */ + newestTrackTimeStart?: string; + /** 最新轨迹时间截止 */ + newestTrackTimeEnd?: string; + /** 最新轨迹时区 */ + newestTrackTimeZone?: number; + /** 是否完结 */ + isFinish?: boolean; + /** 预计到达开始 */ + estimatedDeliveryDateStart?: string; + /** 预计到达截止 */ + estimatedDeliveryDateEnd?: string; + /** 目的地时区 */ + destinationTimeZone?: number; }; type OrderOperationRecordIndex = { diff --git a/src/components.d.ts b/src/components.d.ts index 12adeaa..4502aec 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -27,6 +27,7 @@ declare module 'vue' { FormPro: typeof import('./components/FormPro/index.vue')['default'] FormWrapper: typeof import('./components/global-setting/form-wrapper.vue')['default'] GlobalSetting: typeof import('./components/global-setting/index.vue')['default'] + GuideAudienceTabs: typeof import('./components/GuideAudienceTabs/index.vue')['default'] HandsontablePro: typeof import('./components/HandsontablePro/index.vue')['default'] HeaderFilterPopover: typeof import('./components/HandsontablePro/HeaderFilterPopover.vue')['default'] InterfaceDetail: typeof import('./components/InterfaceDetail/index.vue')['default'] diff --git a/src/components/GuideAudienceTabs/index.vue b/src/components/GuideAudienceTabs/index.vue new file mode 100644 index 0000000..8d65639 --- /dev/null +++ b/src/components/GuideAudienceTabs/index.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/components/HandsontablePro/index.vue b/src/components/HandsontablePro/index.vue index bda9ae2..cdd3f30 100644 --- a/src/components/HandsontablePro/index.vue +++ b/src/components/HandsontablePro/index.vue @@ -1008,7 +1008,13 @@ } function buildHybridHeader(col: number, TH: HTMLElement, colDef: ColumnDef) { - if (TH.querySelector('.ht-header-hybrid-wrap')) return; + // 列虚拟化会复用 th:若已绑定同一列则跳过,避免打断正在输入的表头搜索 + if ( + TH.getAttribute('data-col-key') === colDef.key && + TH.querySelector('.ht-header-hybrid-wrap') + ) { + return; + } const searchField = resolveSearchField(colDef, col); const headerMode = deriveHeaderMode(colDef); @@ -1026,6 +1032,8 @@ TH.style.verticalAlign = 'middle'; TH.style.overflow = 'visible'; TH.setAttribute('data-filter-field', filterField); + TH.setAttribute('data-col-key', colDef.key); + TH.setAttribute('data-visual-col', String(col)); const wrap = document.createElement('div'); wrap.className = 'ht-header-hybrid-wrap'; @@ -1089,24 +1097,21 @@ const headerRows = hotContainer.value.querySelectorAll('.ht_clone_top thead tr, .ht_master thead tr'); if (!headerRows.length) return; + // 列虚拟化后 thead 里 th 的 DOM 下标 ≠ 逻辑列下标,必须按 data-col-key 对齐 headerRows.forEach((headerRow: Element) => { - const headerCells = headerRow.querySelectorAll('th'); - headerCells.forEach((th: Element, index: number) => { - const { actionColIndex, serialColIndex, checkboxColIndex, dataColStart } = getLayoutIndices(); - - if (index === checkboxColIndex || index === actionColIndex || index === serialColIndex) { - return; - } - - const dataColIndex = index - dataColStart; - if (dataColIndex < 0 || dataColIndex >= effectiveColumns.value.length) return; - - const colDef = effectiveColumns.value[dataColIndex]; + const headerCells = headerRow.querySelectorAll('th[data-col-key]'); + headerCells.forEach((th: Element) => { const thEl = th as HTMLElement; - const wrap = thEl.querySelector('.ht-header-hybrid-wrap'); - if (!wrap) return; + const colKey = thEl.getAttribute('data-col-key'); + if (!colKey) return; - const searchField = resolveSearchField(colDef, index); + const colDef = effectiveColumns.value.find((c) => c.key === colKey); + const wrap = thEl.querySelector('.ht-header-hybrid-wrap'); + if (!colDef || !wrap) return; + + const visualColAttr = thEl.getAttribute('data-visual-col'); + const visualCol = visualColAttr != null ? Number(visualColAttr) : undefined; + const searchField = resolveSearchField(colDef, Number.isFinite(visualCol) ? visualCol : undefined); const filterField = resolveFilterField(colDef); const headerMode = deriveHeaderMode(colDef); const rawSearch = String((props.searchParams as any)[searchField] || ''); @@ -1135,6 +1140,12 @@ ? formatFilterDisplayText(colDef, String((props.searchParams as any)[filterField])) : colDef.label; } + + const label = thEl.querySelector('.ht-header-hybrid-label') as HTMLElement | null; + if (label) { + label.textContent = colDef.label; + label.title = colDef.label; + } }); }); } @@ -1610,6 +1621,9 @@ // 复选框列:保持默认 if (col === checkboxColIndex) { + TH.removeAttribute('data-col-key'); + TH.removeAttribute('data-visual-col'); + TH.removeAttribute('data-filter-field'); return; } @@ -1618,8 +1632,11 @@ const isActionCol = col === actionColIndex; const label = col === serialColIndex ? props.serialNoHeader : props.actionColumnHeader; TH.innerHTML = ''; + TH.removeAttribute('data-col-key'); + TH.removeAttribute('data-visual-col'); + TH.removeAttribute('data-filter-field'); TH.classList.add('ht-header-simple', 'htCenter'); - TH.classList.remove('htRight', 'htLeft'); + TH.classList.remove('htRight', 'htLeft', 'ht-header-hybrid'); if (isActionCol) { TH.classList.add('ht-header-action'); TH.classList.remove('ht-header-serial'); @@ -1680,10 +1697,7 @@ } const colDef = effectiveColumns.value[dataColIndex]; - if (TH.querySelector('.ht-header-hybrid-wrap')) { - return; - } - + // 列虚拟化会复用 th:列 key 变了必须重建,否则 placeholder/搜索绑定会串列 buildHybridHeader(col, TH, colDef); }, cells: function(row: number, col: number) { diff --git a/src/components/top-nav/index.vue b/src/components/top-nav/index.vue index 03091cc..794f862 100644 --- a/src/components/top-nav/index.vue +++ b/src/components/top-nav/index.vue @@ -968,6 +968,7 @@ const pathToRouteNameMap: Record = { '/channel/autoTrigger': 'AutoTrigger', '/channel/issueTemplate': 'IssueTemplate', '/channel/guide': 'ChannelGuide', + '/marketing/guide': 'MarketingGuide', '/order/guide': 'OrderGuide', // Track 相关路由 '/track/guide': 'TrackGuide', diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index fe7a24a..eba0bc2 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -65,6 +65,7 @@ export default { 'menu.finance.accountFlow': 'Account Transactions', 'menu.finance.receivablePayable': 'Receivables & Payables', 'menu.marketing': 'Marketing', + 'menu.marketing.marketingGuide': 'Getting Started', 'menu.marketing.memberLevel': 'Member Levels', 'marketing.memberLevel.pageTip': 'Levels are based on rolling 12-month paid base-freight points. Discount applies to base freight only; coupons do not stack. V0–V3 never downgrade; V4/V5 step down one level. Floor is 5% off.', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 67e1dba..c403cf9 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -65,6 +65,7 @@ export default { 'menu.finance.accountFlow': '资金流水', 'menu.finance.receivablePayable': '应收应付', 'menu.marketing': '营销', + 'menu.marketing.marketingGuide': '新手指引', 'menu.marketing.memberLevel': '会员等级', 'marketing.memberLevel.pageTip': '会员等级按近 12 个月实付基础运费积分滚动定级。折扣只打基础运费,附加费原价;与优惠券取更优不叠加。V0–V3 只升不降,V4/V5 可逐级降。最高 95 折。', diff --git a/src/router/routes/newModules/marketing.ts b/src/router/routes/newModules/marketing.ts index 5c1dd57..4844eed 100644 --- a/src/router/routes/newModules/marketing.ts +++ b/src/router/routes/newModules/marketing.ts @@ -12,6 +12,19 @@ const MARKETING: AppRouteRecordRaw = { order: 7, }, children: [ + { + path: 'guide', + name: 'MarketingGuide', + component: () => import('@/views/marketing/marketingGuide/index.vue'), + meta: { + locale: 'menu.marketing.marketingGuide', + requiresAuth: true, + // 与渠道/订单/轨迹指引一致:独立 Guide 权限码,供「同步子菜单」写入 RBAC + menuPermission: 'Marketing.Guide', + order: 0, + icon: 'icon-book', + }, + }, { path: 'memberLevel', name: 'MarketingMemberLevel', diff --git a/src/utils/headerFilterAdapter.ts b/src/utils/headerFilterAdapter.ts index 3bd1b87..c7ef100 100644 --- a/src/utils/headerFilterAdapter.ts +++ b/src/utils/headerFilterAdapter.ts @@ -253,15 +253,28 @@ export function adaptKeywordHeaderFilters( return pickHeaderText(params, keys) || undefined; } -/** 轨迹列表:补齐 createTime 区间 */ +/** 轨迹列表:补齐 createTime / 最新轨迹时间 / 预计到达 区间 */ export function adaptTrackOrderDateRange( params: HeaderSearchParams, fallback?: { createTimeStart?: string; createTimeEnd?: string }, -): { createTimeStart?: string; createTimeEnd?: string } { - const { start, end } = pickDateRange(params, 'createTime'); +): { + createTimeStart?: string; + createTimeEnd?: string; + newestTrackTimeStart?: string; + newestTrackTimeEnd?: string; + estimatedDeliveryDateStart?: string; + estimatedDeliveryDateEnd?: string; +} { + const createTime = pickDateRange(params, 'createTime'); + const newestTrackTime = pickDateRange(params, 'newestTrackTime'); + const estimatedDeliveryDate = pickDateRange(params, 'estimatedDeliveryDate'); return { - createTimeStart: start || fallback?.createTimeStart || undefined, - createTimeEnd: end || fallback?.createTimeEnd || undefined, + createTimeStart: createTime.start || fallback?.createTimeStart || undefined, + createTimeEnd: createTime.end || fallback?.createTimeEnd || undefined, + newestTrackTimeStart: newestTrackTime.start, + newestTrackTimeEnd: newestTrackTime.end, + estimatedDeliveryDateStart: estimatedDeliveryDate.start, + estimatedDeliveryDateEnd: estimatedDeliveryDate.end, }; } diff --git a/src/utils/menu-icon.ts b/src/utils/menu-icon.ts index 9c5d3f5..19e9feb 100644 --- a/src/utils/menu-icon.ts +++ b/src/utils/menu-icon.ts @@ -119,6 +119,7 @@ export const PATH_TO_LOCALE_KEY: Record = { '/track/guide': 'menu.track.trackGuide', '/order/guide': 'menu.order.orderGuide', '/channel/guide': 'menu.channel.channelGuide', + '/marketing/guide': 'menu.marketing.marketingGuide', } type MenuIconItem = { diff --git a/src/utils/route-name-mapper.ts b/src/utils/route-name-mapper.ts index 1a45ec1..a392d62 100644 --- a/src/utils/route-name-mapper.ts +++ b/src/utils/route-name-mapper.ts @@ -114,6 +114,10 @@ const routeNameMap: Record = { '/channel': 'Channel', 'channel_guide': 'ChannelGuide', '/channel/guide': 'ChannelGuide', + 'marketing': 'Marketing', + '/marketing': 'Marketing', + 'marketing_guide': 'MarketingGuide', + '/marketing/guide': 'MarketingGuide', 'channel_sales_products': 'SalesProducts', '/channel/salesProducts': 'SalesProducts', 'channel_service_channels': 'ServiceChannels', diff --git a/src/views/api/apiDebugger/guide.md b/src/views/api/apiDebugger/guide-dev.md similarity index 95% rename from src/views/api/apiDebugger/guide.md rename to src/views/api/apiDebugger/guide-dev.md index ce8727d..3c7784c 100644 --- a/src/views/api/apiDebugger/guide.md +++ b/src/views/api/apiDebugger/guide-dev.md @@ -1,7 +1,7 @@ -# Like API 新手指引 +# Like API 开发指引 -> 帮助你在 Like API 中完成「配置下游/上游接口 → 建立映射 → 应用授权 → 调试与排错」。 -> 下文单号、密钥、域名为**示例**,请替换为你自己的环境数据。 +> 本文面向 **开发 / 实施** 同学:下游传输层与映射体、占位符、脚本与网关转发细节。 +> 运营向配置步骤见同页「运营」Tab。下文单号、密钥为**示例**。 --- @@ -394,22 +394,15 @@ appKey: YOUR_APP_KEY --- -## 8. 本页:API 调试(`/api/apiDebugger`) +## 8. 指引页与调试入口(当前前端) -| Tab | 用途 | -|-----|------| -| **新手指引** | 本文 | -| **接口调试** | 独立 ApiTest,不绑 standardCode / supplierCode | +| 入口 | 路径 / 位置 | 说明 | +|------|-------------|------| +| 本页指引 | `/api/apiDebugger` | 「运营 / 开发」双 Tab Markdown;**不再内嵌**自由调试面板 | +| 独立调试页 | `/api/apiTest` | 通用 `ApiTest`:任意 URL,不绑 standardCode | +| 上游 / 下游内嵌 | 接口详情 → API 调试 Tab | 带当前接口 code,可对照 **示例** cURL | -| 区域 | 说明 | -|------|------| -| Query / Body / Header | 自由调试 HTTP | -| 导入 cURL | 仅解析到调试面板,**不写**接口配置 | - -| 入口 | 适用 | -|------|------| -| 本页 | 任意 URL 探测 | -| 上游/下游内嵌 ApiTest | 带当前接口 code,可对照 **示例** cURL | +导入 cURL 只进调试面板,**不会**写回下游接口配置;改配置请走智能填充 + 保存发布。 --- diff --git a/src/views/api/apiDebugger/guide-ops.md b/src/views/api/apiDebugger/guide-ops.md new file mode 100644 index 0000000..956acdc --- /dev/null +++ b/src/views/api/apiDebugger/guide-ops.md @@ -0,0 +1,210 @@ +# Like API 运营指引 + +> 本文面向要在系统里 **对接外部服务商接口** 的运营 / 实施同学。 +> 传输层字段、映射树、占位符细节见同页 **「开发」** Tab。 +> 下文里的单号、密钥均为示例,请换成你们环境的真实数据。 + +--- + +## 1. Like API 是干什么的? + +业务系统(OMS、轨迹等)不想直接对接每一家服务商的「奇葩报文」,于是: + +``` +业务只认「标准字段」 + → Like API 翻译成服务商要的格式 + → 发出真实 HTTP + → 再把服务商返回翻译回标准字段 +``` + +| 能力 | 你能得到什么 | +|------|----------------| +| 统一入口 | 业务调同一个标准接口名 | +| 参数映射 | 字段名、层级不同也能对上 | +| 脚本 | 签名、时间戳、Token 等自动化 | +| 日志 | 每次调用可查请求/响应,方便对责 | + +--- + +## 2. 五个名词先分清 + +| 名词 | 白话 | 例子 | +|------|------|------| +| **上游接口** | 给业务用的「标准口」 | 标准码 `TRACK_QUERY` | +| **下游接口** | 服务商真实 HTTP 接口 | 供应商码 `SP_XXX_QUERY` | +| **映射** | 上游 ↔ 下游的字段对照 + 脚本 | 把 `orderNo` 对到对方的 `mailNo` | +| **应用** | 谁有权调用(AppKey、白名单) | 某客户系统的 AppKey | +| **发布** | 配置生效开关 | **不发布 = 线上仍用旧版** | + +调用时大致要带:`appKey` + 标准码 / 供应商码 + 业务 JSON。 + +--- + +## 3. 一次调用怎么走(心里有图) + +``` +业务 JSON + → 鉴权(App 有没有权限) + → 入参映射(标准 → 服务商结构) + → 填占位符、跑前置脚本(常用来签名) + → 真正请求服务商 + → 后置脚本、出参映射 + → 标准结果返回业务 +``` + +--- + +## 4. 推荐上手顺序(照做) + +1. **下游接口**:拿服务商 cURL → 智能填充 → 核对 → 保存 → **发布** +2. **上游接口**:定好标准入参 / 出参树 → 保存 → **发布** +3. **映射**:选服务商 + 下游 → 连字段 → 写签名脚本(如需)→ 保存 → **发布** +4. **应用**:把该上游接口授权给对应 AppKey,并上线 +5. **验证**:用本页「接口调试」或映射里的调试;到 **请求日志** 对最终报文 + +--- + +## 5. 下游接口怎么配(重点举例) + +菜单:**下游接口** → 左侧选服务商 → 打开某个接口。 + +### 5.1 先选对 ContentType + +| 服务商实际发什么 | 你应选 | 界面重点 | +|------------------|--------|----------| +| JSON Body | Json | **Body 参数树**(业务结构 + 发出去的 JSON) | +| XML Body | Xml | 同上,树形 | +| 表单 `a=1&b=2`,业务 JSON 塞在某个字段里 | FormUrlEncoded | **Body 表单 KV** + **Mapping Body 树** | + +选错类型 → Tab 对不上 → 保存报错或转发报文错。 + +### 5.2 例子 A:纯 JSON 下单 + +服务商要: + +```json +{ "mailNo": "A001", "dest": "MY" } +``` + +业务标准是: + +```json +{ "orderNo": "A001", "country": "MY" } +``` + +运营动作: + +1. 下游 Body 树建:`mailNo`、`dest` +2. 上游 In Parameters 建:`orderNo`、`country` +3. 映射里连线:`orderNo → mailNo`,`country → dest` +4. 发布下游 + 映射 + +### 5.3 例子 B:Form + 整段 JSON(很常见) + +服务商 cURL 类似: + +```text +POST /openapi/notify?_aop_signature=xxx&bizEventDto={...json...} +``` + +没有独立的 JSON Body,而是: + +- Query/表单上有一个字段装着 **整段业务 JSON** +- 另有一个签名字段 + +运营动作: + +1. ContentType 选 **FormUrlEncoded** +2. **Mapping Body** 树:画出 `{...json...}` 里的业务字段结构 +3. Query(或 Body 表单)里:`bizEventDto` = `$FromMappingBody`(表示「填入映射后的整段 JSON」) +4. `_aop_signature` **留空**,交给 **前置脚本** 计算写入 +5. 不要把同一段 DTO 又在 Body 表单里重复填一份(看清 cURL 到底是 Query 还是 Body) + +### 5.4 占位符(常用) + +| 占位符 | 用途 | +|--------|------| +| `$FromMappingBody` | Form 场景:把映射结果整段塞进某个字段 | +| `$FromAccount` | 取服务商账户里配置的账号信息 | +| `$TimeStamp` / `$DateTime` | 时间 | +| `$token` | Token | + +签名、密钥 **不要写死** 在界面上的「值」列,留给脚本。 + +### 5.5 智能填充 + +接口明细 → **智能填充** → 粘贴服务商 cURL → 填充到表单。 + +填充后务必: + +- 打开 Body / Mapping Body 看树是否完整 +- 签名字段是否被清空 +- 再保存、发布 + +--- + +## 6. 映射怎么配 + +菜单位置:**上游接口** → 选标准接口 → **映射明细**。 + +一条映射 = 一个上游 + 一个服务商 + 一个下游 + 入参/出参对照 + 可选脚本。 + +| Tab | 做什么 | +|-----|--------| +| 入参映射 | 标准字段 → 服务商字段 | +| 出参映射 | 服务商返回 → 标准返回 | +| 前置脚本 | 发出前:签名、改 Header | +| 后置脚本 | 收到后:改包装、补字段 | +| API 调试 | 带当前标准码/供应商码联调 | + +**Form 场景:** 映射右侧连的是 **Mapping Body 树**,不是 Body 表单那一行 KV。 +**Json 场景:** 连的是 **Body 参数树**。 + +一个上游可以绑多个下游(按顺序调),适合「一笔业务通知多家」的场景。 + +--- + +## 7. 本页:API 调试(`/api/apiDebugger`) + +| Tab | 用途 | +|-----|------| +| **新手指引** | 本文 | +| **接口调试** | 自由填 URL / Header / Body,或导入 cURL 试连通 | + +注意: + +- 本页调试 **不会** 自动写回下游接口配置 +- 正式联调更推荐:在已配置的上游/下游里用内嵌调试(会带上接口编码) +- 排错首选 **请求日志**:看最终发出去的 Query、Body、签名是否齐全 + +--- + +## 8. 配置自检清单 + +| 检查项 | 说明 | +|--------|------| +| ContentType 和真实 cURL 一致 | Json / Form 别选错 | +| 下游、映射、应用都已 **发布** | 改了不生效十有八九是没发布 | +| Form 的业务树在 Mapping Body | 表单 KV 只负责「哪个字段装整段 JSON / 签名」 | +| 签名字段名为空且脚本字段名一致 | 名称差一个字母就会验签失败 | +| App 已授权该上游并上线 | 否则鉴权失败 | +| 用请求日志对比服务商文档 | 比在业务系统里猜更快 | + +--- + +## 9. 常见问题 + +| 现象 | 处理 | +|------|------| +| 保存提示 Body / Mapping Body 不能为空 | Form 要同时有表单 KV 和 Mapping Body 树;Json 要有 Body 树节点 | +| 映射页右侧树是空的 | 下游没保存/没发布,或开错了 Tab | +| 映射看着对,服务商仍报错 | 打开请求日志看最终报文;查 `$FromMappingBody` 有没有替换、签名有没有写到对的字段 | +| 配置改了线上没变 | 下游、映射、应用是否都重新发布 | +| Query 上已有 DTO,Body 又多了一行 | 按 cURL 只保留一处 `$FromMappingBody` | + +--- + +## 10. 一句话心智模型 + +> **上游是公司普通话,下游是服务商方言;映射是翻译器,发布才上岗。** +> 排错先看 **请求日志里真正发出的那一包**,再回头改树和脚本。 diff --git a/src/views/api/apiDebugger/index.vue b/src/views/api/apiDebugger/index.vue index 8b8647d..eddda99 100644 --- a/src/views/api/apiDebugger/index.vue +++ b/src/views/api/apiDebugger/index.vue @@ -1,6 +1,7 @@ - - diff --git a/src/views/channel/channelGuide/guide-dev.md b/src/views/channel/channelGuide/guide-dev.md new file mode 100644 index 0000000..1abdf2e --- /dev/null +++ b/src/views/channel/channelGuide/guide-dev.md @@ -0,0 +1,154 @@ +# 渠道中心开发指引 + +> 面向 **开发**。运营配置顺序见同页「运营」Tab。 +> 以下纠正旧文档中「邮编左开右闭」「创建不排货」等过时描述。 + +--- + +## 1. 模块职责 + +渠道模块为订单提供:地理分区、计重、服务渠道买价、销售产品卖价与排货、发布快照。 + +**心智模型:** 订单计费绑定 **发布快照版本**(或 `LIVE-{productId}`),不是「永远读最新草稿」。 +排货在 **预报计费** 与 **入库计费** 都会调用(见订单开发指引)。 + +API 前缀 `/channel/*`,Swagger **OMS-Channel**。 + +--- + +## 2. 核心概念 + +| 概念 | 说明 | +|------|------| +| **价格分区** | AreaType=price;卖/买价矩阵的分区因素 | +| **黑名单** | 命中则创单拒绝 | +| **偏远** | 写 `order.IsRemote`,可参与附加费 zone | +| **计重规则 / 进位** | 体积重、条件、阶梯进位 | +| **服务渠道** | 下游通道 + 买价发布 | +| **销售产品** | ProductCode;卖价 + 排货 Providers | +| **发布快照** | `t_sales_product_publish_record`;`operatorType=price\|rules` | +| **KPI Pack** | 分区明细走 `t_kpi_area_publish_record`;产品快照里多是 Ref,运行时 Hydrate | + +### 区间约定(易错) + +| 场景 | 区间语义 | +|------|----------| +| **重量段 / 计重进位** | **左开右闭** `(start, end]` | +| **邮编 range 匹配** | **闭区间** `[left, right]`(`GeoPartitionMatcher`) | + +旧指引写「邮编左开右闭」是错的。 + +--- + +## 3. 推荐配置顺序(实现视角) + +``` +① 价格分区 / 黑名单 / 偏远(KPI + pack) +② 计重规则 + 进位 +③ 服务渠道 + 买价矩阵 + 发布渠道价格 +④ 销售产品配置(绑分区/计重/黑名单) +⑤ 卖价矩阵 + 排货 Providers +⑥ 发布 rules + 发布 price(price 发布可能要求 AI 分析报告,以当前校验为准) +``` + +排货候选须 **买价可算**,否则 Trace 里会出现 buyPrice skip。 + +--- + +## 4. 发布与 LIVE + +### 两条发布流 + +| operatorType | Content 要点 | 订单字段 | +|--------------|--------------|----------| +| `price` | Prices + PriceAreaRefs(Infos 运行时 hydrate) | `SalesProductPriceVersion` | +| `rules` | Product + Providers + 黑名单/偏远 Refs + 计重明细内联 | `SalesProductRulesVersion` | + +- 版本号:`yyyyMMddHHmmss`;生效选取 `PublishTime<=bindTime` 最新 Active +- 无发布记录:`BuildLive*Snapshot`,版本 `LIVE-{productId}` +- 订单已绑 `LIVE-*` 或空:按当前库重建 +- **回滚**:还原配置内容,**不新建**发布记录 + +Hydrate:`KpiAreaPackService.HydrateBindingAsync`。 + +渠道买价发布:`t_service_channel_publish_record`;排货试算买价用 **`DateTime.Now` 生效版本**(不是订单创建时刻)。 + +--- + +## 5. 排货 `AllocationResolver` + +Scheme(`AllocationSchemeCodes`): + +| 值 | 选渠 | +|----|------| +| `costLowest` | 应付最小(默认) | +| `timeFastest` | `Channel.MinTime` 最小 | +| `overallBest` | Provider `Weight` 最大 | +| `designated` | 运维指定;实现上仍偏成本池 | + +过滤流水线: + +1. `MatchCondition` 中文公式(空/`无` = 通过) +2. 否则旧版 `CustomerIds` +3. 同渠道去重(高 Weight、低 Serial) +4. Skip 阶段可记:`condition` / `customer` / `channel` / `buyPrice` + +订单调用点: + +| 时机 | 方法 | +|------|------| +| 预报计费 | `TryConfirmServiceChannelAndPayableAsync(forecast)` | +| 入库计费 | 同上 `(inbound)` | +| 手动重排货 | `ReallocateCoreAsync`(规则版本可写回当前生效) | + +Providers 来源:rules 快照 / LIVE 的 `t_sales_product_allocation_provider`。 +结果字段:`ChannelCode`、`AllocationScheme`、`AllocationTraceJson` 等。 + +--- + +## 6. 价格矩阵与计重 + +卖价与买价结构同类:价格项 → 因素(分区/货物/仓库/重量/客户组)→ 明细公式。 + +| 公式 | 含义 | +|------|------| +| `total` | 固定价(可客户组价) | +| `unit` | 单价 × 计费重 | +| `continue` | 首重 + 续重 | + +计重:`ParcelBillingCalculator.CalculateRawBillableWeight` + `ApplyRounding`。 +有效 `WeightRuleId`:产品 live 优先于快照内 ID(`ResolveEffectiveWeightRuleId`)。 + +--- + +## 7. KPI 匹配优先级 + +`GeoPartitionMatcher`:维度 `zip|state|city|country` × 模式 `prefix|exact|range|all`。 +排序倾向:zip > state > city > country;更长前缀 / 更窄 range 优先。 + +--- + +## 8. 菜单 ↔ 实现 + +| 菜单 | 服务 / 表 | +|------|-----------| +| 销售产品 | `SalesProductService`(+ Helpers / BillingSimulate) | +| 服务渠道 | `ServiceChannelService` | +| 价格/黑名单/偏远 | `KpiAreaService` + `KpiAreaPackService` | +| 计重 | `WeightRuleService` / `WeightRoundingService` | +| 排货核心 | `Shared/AllocationResolver.cs` | +| 订单消费 | `OrderService.ParcelBilling.cs` | + +主要表:`t_kpi_area*`、`t_kpi_area_publish_record`、`t_weight_*`、`t_service_channel*`、`t_sales_product*`、`t_sales_product_publish_record`、`t_sales_product_allocation_provider`。 + +--- + +## 9. 与订单报错对照 + +| 现象 | 渠道侧 | +|------|--------| +| 产品未启用 | `t_sales_product` 状态 | +| 未命中快照 | 是否发布;LIVE 是否缺计重/价格项 | +| 价格分区未匹配 | pack 明细 + 产品关联;邮编闭区间 | +| 黑名单 | 黑名单 pack + 产品绑定 | +| 待排货 / buyPrice skip | 排货列表、渠道启用、买价发布、公式条件、客户限制 | diff --git a/src/views/channel/channelGuide/guide-ops.md b/src/views/channel/channelGuide/guide-ops.md new file mode 100644 index 0000000..c34959e --- /dev/null +++ b/src/views/channel/channelGuide/guide-ops.md @@ -0,0 +1,182 @@ +# 渠道中心运营指引 + +> 本文面向 **运营** 同学,说明物流产品、卖价、买价、排货怎么配,以及改完为什么一定要 **发布**。 +> 快照表、排货代码见同页 **「开发」** Tab;订单用法见 [订单指引](/order/guide)。 + +--- + +## 1. 渠道模块管什么? + +可以把渠道中心想成「运费与走货规则的配置台」: + +| 配置什么 | 决定订单什么 | +|----------|--------------| +| **销售产品** | 客户下单时选的运输方式叫什么、卖多少钱 | +| **服务渠道** | 货实际走哪家下游、公司成本多少 | +| **价格分区** | 不同国家/邮编用哪一档卖价 | +| **黑名单** | 哪些地址 **禁止下单** | +| **偏远分区** | 哪些地址打偏远标(可加附加费,不拦下单) | +| **计重规则 / 进位** | 实重和体积重怎么算成计费重 | +| **排货规则** | 入库后自动选哪条服务渠道 | +| **对客说明** | 查价页给客户看的线路说明 | + +**最重要一条:** 订单计费读的是 **发布后的快照**,不是你刚改完还没点发布的草稿。 +改了卖价/规则却忘了发布 → 客户下单仍按旧价旧规则。 + +--- + +## 2. 推荐配置顺序(照着做) + +新开一条线路时,建议按这个顺序: + +``` +主数据 + ① 价格分区(国家/邮编落到哪一价区) + ② 黑名单(禁运) + ③ 偏远分区(可选) + ④ 计重规则 + 计重进位 + +成本侧 + ⑤ 建服务渠道(下游通道) + ⑥ 配渠道买价并发布 + +卖价侧 + ⑦ 建销售产品,绑上分区、计重、黑名单等 + ⑧ 配产品卖价 + ⑨ 配排货(挂上可用服务渠道) + ⑩ 写对客说明(可选) + ⑪ 发布「规则」+ 发布「价格」 +``` + +**例:** 只配了卖价、排货列表是空的,或渠道买价没发布 → 入库后容易进 **待排货**。 + +--- + +## 3. 核心概念(白话 + 例子) + +### 3.1 销售产品 vs 服务渠道 + +| | 销售产品 | 服务渠道 | +|--|----------|----------| +| 面向谁 | 客户 | 内部 / 下游 | +| 例子 | 「马来西亚特惠」`MY-ECO` | 「某尾程商 KL 仓线」 | +| 客户下单选 | 选销售产品 | **不选手选渠道**,入库时系统排货写入 | + +一个销售产品可以挂多条服务渠道,由排货策略决定用哪条。 + +### 3.2 价格分区 + +把目的地划成「价区」,卖价表按价区报价。 + +**例:** + +- 「西马 A」:邮编 (50000, 60000] +- 「东马」:另一组邮编 + +客户邮编 55000 → 落在西马 A → 用西马 A 那一列卖价。 +邮编完全不在任何明细里 → 下单报 **价格分区未匹配**。 + +邮编区间一般是 **左开右闭**:左端点本身不算进去,右端点算。 + +### 3.3 黑名单与偏远 + +| | 黑名单 | 偏远 | +|--|--------|------| +| 命中 | **不能下单** | 可以下单,订单标偏远 | +| 配置后 | 销售产品要关联对应黑名单区 | 销售产品关联偏远区 | + +### 3.4 计重(给客户算钱用) + +常见逻辑:体积重 = 长×宽×高 ÷ 泡比;再和实重比,按规则取大或取条件命中的那个;最后按进位表进到 0.5kg、1kg 等。 + +**例:** 30×20×10、实重 0.8;泡比 6000 → 体积重 1.0;取大后计费重 1.0kg → 按 1.0kg 那一档卖价。 + +### 3.5 发布:规则 与 价格 是两条线 + +| 发布类型 | 大概包含 | 忘了会怎样 | +|----------|----------|------------| +| **规则(rules)** | 计重、黑名单、偏远、排货等 | 计重/禁运/排货仍按旧版 | +| **价格(price)** | 卖价矩阵 + 价格分区明细 | 运费仍按旧卖价 | + +操作习惯:改完 → **预览** → **发布**。需要还原时用 **回滚**(按你们页面按钮)。 + +从未发布过时,系统可能临时读当前库配置(LIVE);一旦有正式发布,订单就绑发布版本号,更稳定可追溯。 + +--- + +## 4. 排货(入库时选渠道) + +在 **销售产品 → 排货规则** 配置: + +| 策略 | 意思 | +|------|------| +| 成本最低 | 可用渠道里公司成本最低 | +| 时效最快 | 时效字段最短的优先 | +| 综合最优 | 按权重打分 | + +列表里可限制「仅某些客户可用」;空 = 全部客户。 + +**例:** 渠道 A 成本 10、渠道 B 成本 14;策略成本最低 → 选 A。 +若 A 买价算不出来 → A 被踢掉 → 可能只剩 B,或全部失败变 **待排货**。 + +--- + +## 5. 卖价矩阵怎么理解 + +卖价通常按「费用项 + 条件」组合: + +- 费用项:基础运费、偏远附加等 +- 条件:价区、重量段、货物类型、仓库、客户组等 + +**例:** 西马 A、重量 (0.5, 1.0]、普货 → 基础运费 18 元。 +客户计费重 0.8、普货、落在西马 A → 命中 18 元。 +若只有带电价、客户选了普货 → 可能 **卖价未匹配**。 + +买价矩阵结构类似,只是算的是公司付给下游的成本,供排货比较。 + +--- + +## 6. 各菜单速查 + +| 菜单 | 路径 | 做什么 | +|------|------|--------| +| 新手指引 | `/channel/guide` | 本文 | +| 销售产品 | `/channel/salesProducts` | 产品、卖价、排货、发布、对客说明 | +| 服务渠道 | `/channel/serviceChannels` | 下游渠道与买价 | +| 价格分区 | `/channel/priceArea` | 卖价用的地理分区 | +| 黑名单区 | `/channel/blacklist` | 禁运 | +| 偏远分区 | `/channel/remoteZones` | 偏远标记 | +| 计重规则 / 进位 | `/channel/weightRules` 等 | 计费重算法 | +| 自动触发 | `/channel/autoTrigger` | 订单事件自动动作(拦截、打标等) | +| 问题模板 | `/channel/issueTemplate` | 问题件话术 | +| 标签设计 | `/channel/labelDesign` | 面单样式 | + +### 销售产品里常见 Tab + +| Tab | 内容 | +|-----|------| +| 产品配置 | 名称、编码、关联分区与计重 | +| 产品卖价 | 卖价表 + 价格发布 | +| 排货规则 | 策略与渠道列表 + 规则发布 | +| 发布记录 | 历史版本、回滚 | +| 对客说明 | 给查价页看的说明文案 | + +--- + +## 7. 订单报错 ↔ 渠道侧排查 + +| 订单侧现象 | 先查哪里 | +|------------|----------| +| 产品未启用 | 销售产品状态 | +| 价格分区未匹配 | 价格分区明细是否覆盖该邮编;产品是否关联 | +| 黑名单命中 | 黑名单明细 + 产品是否绑了该黑名单 | +| 未命中价格/规则快照 | 是否点过发布 | +| 卖价未匹配 | 卖价重量段 / 货物属性 / 价区 | +| 待排货 / 无可用渠道 | 排货列表、渠道启用、买价是否可算、客户白名单 | + +--- + +## 8. 一句话心智模型 + +> **销售产品决定客户怎么买、卖多少钱;服务渠道决定货怎么走、成本多少;排货把两者在入库时接上。** +> **改配置不发布 = 等于没改。** diff --git a/src/views/channel/channelGuide/guide.md b/src/views/channel/channelGuide/guide.md deleted file mode 100644 index 3b8129f..0000000 --- a/src/views/channel/channelGuide/guide.md +++ /dev/null @@ -1,173 +0,0 @@ -# 渠道中心新手指引 - -> 本文面向第一次接触 **渠道模块(OMS Channel)** 的运营、产品与开发同学,帮助你理解「主数据 → 服务渠道买价 → 销售产品卖价与排货 → 发布快照 → 订单计费」的完整配置链路。 - ---- - -## 1. 系统是什么? - -**渠道模块**是 OMS 的 **物流产品与价格配置中心**,为订单模块提供: - -- **地理分区**:价格分区、黑名单区、偏远分区; -- **计重规则**:体积重、计费重、重量进位; -- **服务渠道**:下游通道及 **买价(成本)**; -- **销售产品**:客户运输方式及 **卖价(应收)**、排货、对客说明; -- **发布机制**:版本化快照,供订单创建绑定。 - -API 路径前缀 `/channel/*`,Swagger 分组 **OMS-Channel**。 - -**心智模型:** 渠道决定订单 **卖多少、走哪条渠道、成本多少、能否发货、如何计重**。订单读 **发布快照**,而非实时改库后的配置。 - ---- - -## 2. 核心概念 - -| 概念 | 说明 | -|------|------| -| **KPI 分区** | `t_kpi_area` + `t_kpi_area_info`,按 AreaType 区分用途 | -| **价格分区** | AreaType=price,价格矩阵的「分区」因素 | -| **黑名单区** | AreaType=blacklist,命中则创建拒绝 | -| **偏远分区** | AreaType=remote,标记 order.IsRemote | -| **计重规则/进位** | 计费重计算与阶梯进位 | -| **服务渠道** | 下游通道 + 渠道买价 | -| **销售产品** | ProductCode = 下单运输方式编码 | -| **排货 Scheme** | costLowest / timeFastest / overallBest | -| **发布快照** | `t_sales_product_publish_record.Content` | - -邮编匹配:**左开右闭** `(left, right]`。 - ---- - -## 3. 推荐配置顺序 - -``` -主数据 - ① 价格分区 ② 黑名单 ③ 偏远 ④ 计重规则 ⑤ 计重进位 - -服务渠道(成本) - ⑥ 渠道组 + 渠道 ⑦ 买价矩阵 ⑧ 发布渠道价格 - -销售产品(卖价) - ⑨ 创建产品 ⑩ 产品配置(关联分区/计重) - ⑪ 产品卖价 ⑫ 排货规则 ⑬ 对客说明 - ⑭ 发布 rules ⑮ 发布 price -``` - -排货需要渠道 **买价可算**,否则预报会「无可用渠道」。 - ---- - -## 4. 发布与回滚 - -### 两条独立发布流 - -| operatorType | 快照内容 | 订单字段 | -|--------------|----------|----------| -| `price` | 卖价 + 价格分区明细 | SalesProductPriceVersion | -| `rules` | 产品 + 排货 + 黑名单/偏远/计重 | SalesProductRulesVersion | - -### 操作 - -1. 预览:`GET .../publish/preview` -2. 发布:`POST .../publish` → 版本号 `yyyyMMddHHmmss`,Status=生效 -3. 回滚:`POST .../publish/rollback` → 还原配置,不新建发布记录 - -无发布记录时订单使用 **LIVE-{productId}** 读当前库配置。 - ---- - -## 5. 排货详解 - -**销售产品 → 排货规则 Tab:** - -- **Scheme**:成本最低(默认)/ 时效最快 / 综合最优(权重评分) -- **服务商列表**:渠道、权重、指定客户(空=全部) - -订单 `ConfirmServiceChannelAndPayableAsync`:过滤候选 → 试算应付 → 按 Scheme 选中 → 写 ChannelCode 与下游字段。 - ---- - -## 6. 价格矩阵结构 - -卖价与买价结构相同: - -- **价格项**:费用项、单位、币种 -- **计费因素**:货物类型、分区、重量、仓库、客户等 -- **价格明细**:因素组合 + 公式 + 客户组价格 - ---- - -## 7. 各菜单说明 - -| 菜单 | 路径 | 功能 | -|------|------|------| -| 新手指引 | `/channel/guide` | 本文档 | -| 销售产品 | `/channel/salesProducts` | 配置/卖价/排货/发布/对客说明 | -| 服务渠道 | `/channel/serviceChannels` | 渠道与买价 | -| 价格分区 | `/channel/priceArea` | 价格用地理分区 | -| 黑名单区 | `/channel/blacklist` | 禁运区域 | -| 偏远分区 | `/channel/remoteZones` | 偏远标记 | -| 计重规则 | `/channel/weightRules` | 计费重逻辑 | -| 计重进位 | `/channel/weightRounding` | 重量进位 | -| 自动触发 | `/channel/autoTrigger` | 订单事件触发 | -| 问题模板 | `/channel/issueTemplate` | 异常话术 | -| 标签设计 | `/channel/labelDesign` | 面单模板 | - -### 销售产品 Tab - -| Tab | 内容 | -|-----|------| -| 产品配置 | 基础信息、关联分区与计重 | -| 产品卖价 | 卖价矩阵、价格发布 | -| 排货规则 | Scheme、服务商、发布 | -| 发布记录 | 历史版本、回滚 | -| 对客说明 | Markdown,查价页可查看 | - ---- - -## 8. 与订单模块衔接 - -``` -渠道配置+发布 → 订单 CreateParcelCoreAsync - ├─ 读 rules/price 快照 - ├─ 黑名单/偏远/计重 - ├─ 分区匹配 + 应收 - └─ 排货 + 应付 -``` - -| 订单报错 | 排查 | -|----------|------| -| 产品未启用 | 销售产品状态 | -| 价格分区未匹配 | 分区明细、邮编范围 | -| 无可用渠道 | 排货列表、渠道买价、客户白名单 | -| 黑名单命中 | 黑名单明细 + 产品关联 | - ---- - -## 9. 主要 API - -``` -/channel/sales-product/* -/channel/service-channel/* -/channel/kpi-area/* -/channel/weight-rule/* -/channel/weight-rounding/* -``` - ---- - -## 10. 关键数据表 - -`t_kpi_area*`、`t_weight_rule*`、`t_weight_rounding*`、`t_service_channel*`、`t_sales_product*`、`t_sales_product_publish_record` - ---- - -## 11. 后端代码索引 - -| 主题 | 文件 | -|------|------| -| 销售产品 | `SalesProductService.cs` | -| 服务渠道 | `ServiceChannelService.cs` | -| KPI 分区 | `KpiAreaService.cs` | -| 排货 | `AllocationResolver.cs` | -| 订单消费 | `OrderService.ParcelBilling.cs` | diff --git a/src/views/channel/channelGuide/index.vue b/src/views/channel/channelGuide/index.vue index 769e590..0b62405 100644 --- a/src/views/channel/channelGuide/index.vue +++ b/src/views/channel/channelGuide/index.vue @@ -1,6 +1,7 @@ - - diff --git a/src/views/h5/customer-center/locale/en-US.ts b/src/views/h5/customer-center/locale/en-US.ts index 561e3f9..9d920a8 100644 --- a/src/views/h5/customer-center/locale/en-US.ts +++ b/src/views/h5/customer-center/locale/en-US.ts @@ -87,6 +87,12 @@ export default { 'h5.price.goOrderHint': 'Opening place-order…', 'h5.price.empty': 'Enter country and weight to get quotes', 'h5.price.noResult': 'No quotes found. Adjust filters or ask the advisor.', + 'h5.price.noResultTitle': 'No quotes found', + 'h5.price.noResultWithFailures': '{count} misses', + 'h5.price.noResultHint': + 'Try adding a postal code, dimensions, or cargo/warehouse filters; or ask the advisor.', + 'h5.price.failureShow': 'View miss reasons ({productCount} products / {itemCount})', + 'h5.price.failureHide': 'Hide miss reasons', 'h5.price.delivery': 'Transit', 'h5.price.workingDays': 'business days', 'h5.price.chargeableWeight': 'Chargeable wt', @@ -879,7 +885,7 @@ export default { 'h5.invite.tab.share': 'Share invite', 'h5.invite.tab.referees': 'Invited', 'h5.invite.lead': - 'Friends get ¥5 on register. After their orders are delivered, you earn a percent of paid base freight.', + 'Earn freight credit when friends’ deliveries succeed: 5% on 1st order (max ¥50), 3% on orders 2–6 (max ¥30), plus a ¥20 coupon. Friends get ¥5 on sign-up.', 'h5.invite.locked': 'Upgrade to V1 to invite friends', 'h5.invite.code': 'Invite code', 'h5.invite.link': 'Invite link (open in WeChat)', diff --git a/src/views/h5/customer-center/locale/zh-CN.ts b/src/views/h5/customer-center/locale/zh-CN.ts index 69236cc..90ab3f5 100644 --- a/src/views/h5/customer-center/locale/zh-CN.ts +++ b/src/views/h5/customer-center/locale/zh-CN.ts @@ -87,6 +87,12 @@ export default { 'h5.price.goOrderHint': '正在跳转下单…', 'h5.price.empty': '输入国家与重量后查询报价', 'h5.price.noResult': '暂无报价,可调整条件或问问顾问', + 'h5.price.noResultTitle': '查价未出结果', + 'h5.price.noResultWithFailures': '共 {count} 条未命中', + 'h5.price.noResultHint': + '可尝试填写邮编、尺寸或选择货物属性/仓库后重试;也可问问顾问协助查价。', + 'h5.price.failureShow': '查看未命中原因({productCount} 个产品 / {itemCount} 条)', + 'h5.price.failureHide': '收起未命中原因', 'h5.price.delivery': '时效', 'h5.price.workingDays': '工作日', 'h5.price.chargeableWeight': '计费重', @@ -864,7 +870,8 @@ export default { 'h5.invite.title': '邀请好友', 'h5.invite.tab.share': '分享邀请', 'h5.invite.tab.referees': '已邀请好友', - 'h5.invite.lead': '好友注册即得 5 元券;其订单派送成功后,您按实付基础运费获得奖励', + 'h5.invite.lead': + '邀请有奖:好友派送成功后,您得运费余额(首单 5% 最高 50 元,第 2–6 单 3% 最高 30 元),另得 20 元券;好友注册即送 5 元券', 'h5.invite.locked': '升级到 V1 后可邀请好友', 'h5.invite.code': '邀请码', 'h5.invite.link': '邀请链接(微信内打开)', diff --git a/src/views/h5/customer-center/price/index.vue b/src/views/h5/customer-center/price/index.vue index 5ba8af3..a63a9a6 100644 --- a/src/views/h5/customer-center/price/index.vue +++ b/src/views/h5/customer-center/price/index.vue @@ -211,7 +211,7 @@ -
+
-

{{ queryFailureText || t('h5.price.noResult') }}

- +
{{ t('h5.price.noResultTitle') }}
+

+ {{ + queryFailureItems.length + ? t('h5.price.noResultWithFailures', { count: queryFailureItems.length }) + : queryFailureText || t('h5.price.noResult') + }} +

+

{{ t('h5.price.noResultHint') }}

+ +
+ +
    +
  1. +
    + {{ gi + 1 }} +
    + {{ + group.productName || + group.productCode || + t('order.priceCheck.guide.failureUnknownProduct') + }} + + {{ group.productCode }} + +
    +
    +
      +
    • +
      + + {{ t('order.priceCheck.guide.failureCargo', { name: item.cargoTypeName }) }} + + + {{ + t('order.priceCheck.guide.failureWarehouse', { + name: item.warehouseName, + }) + }} + + + {{ + t('order.priceCheck.guide.failureStepLabel', { + step: failureStepLabel(item.step), + }) + }} + +
      +

      {{ item.reason }}

      +
    • +
    +
  2. +
+
+ + {{ t('h5.price.askAdvisor') }}
@@ -596,6 +674,11 @@ import { type RecommendAlternative, type ShipVerdict, } from '@/views/order/priceCheck/recommendChannel'; +import { + groupPriceCheckFailures, + normalizePriceCheckFailures, + type PriceCheckFailureItem, +} from '@/views/order/priceCheck/priceCheckFailures'; import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue'; import H5CompleteContactSheet from '../components/H5CompleteContactSheet.vue'; @@ -662,8 +745,33 @@ const optionsLoading = ref(false); const queryLoading = ref(false); const queried = ref(false); const queryFailureText = ref(''); +const queryFailureItems = ref([]); +const failureExpanded = ref(false); const results = ref([]); const queryHint = ref(null); + +const queryFailureGroups = computed(() => groupPriceCheckFailures(queryFailureItems.value)); + +function failureStepLabel(step: string) { + const key = `order.priceCheck.guide.failureStep.${step}`; + const label = t(key); + return label === key ? step : label; +} + +function applyPriceCheckFailures(failures: unknown[]) { + queryFailureItems.value = normalizePriceCheckFailures((failures ?? []) as any); + queryFailureText.value = queryFailureItems.value.length + ? t('h5.price.noResultWithFailures', { count: queryFailureItems.value.length }) + : t('h5.price.noResult'); + // 未命中较多时默认收起,避免刷屏;少量则直接展开 + failureExpanded.value = queryFailureGroups.value.length > 0 && queryFailureGroups.value.length <= 3; +} + +function clearPriceCheckFailures() { + queryFailureItems.value = []; + queryFailureText.value = ''; + failureExpanded.value = false; +} const sortMode = ref<'recommend' | 'price' | 'time'>('recommend'); const sortModes = ['recommend', 'price', 'time'] as const; @@ -1056,7 +1164,7 @@ async function handleQuery() { queryLoading.value = true; queried.value = true; formCollapsed.value = false; - queryFailureText.value = ''; + clearPriceCheckFailures(); clearRecommendInsight(); aiExpanded.value = false; try { @@ -1079,13 +1187,10 @@ async function handleQuery() { queryHint.value = hint; results.value = processQueryResults(items || []); if (!results.value.length) { - queryFailureText.value = - (failures || []) - .map((f: any) => f.reason || f.Reason || '') - .filter(Boolean) - .join(';') || t('h5.price.noResult'); + applyPriceCheckFailures(failures ?? []); formCollapsed.value = false; } else { + clearPriceCheckFailures(); formCollapsed.value = true; await nextTick(); document @@ -1096,6 +1201,7 @@ async function handleQuery() { } catch (e: any) { results.value = []; queryHint.value = null; + clearPriceCheckFailures(); queryFailureText.value = e?.message || t('h5.price.noResult'); formCollapsed.value = false; } finally { @@ -2444,6 +2550,16 @@ onMounted(() => { } } +.h5-empty--no-quote { + padding: 16px 4px 8px; + text-align: left; + + .h5-empty__icon { + margin-left: auto; + margin-right: auto; + } +} + .h5-empty__icon { width: 40px; height: 40px; @@ -2461,6 +2577,165 @@ onMounted(() => { } } +.h5-empty__badge { + display: inline-flex; + align-items: center; + margin-bottom: 8px; + padding: 2px 8px; + border-radius: 4px; + background: rgba(255, 125, 0, 0.12); + color: #ff7d00; + font-size: 12px; + font-weight: 600; + line-height: 20px; +} + +.h5-empty__title { + margin: 0 0 6px !important; + color: #1d2129 !important; + font-size: 15px !important; + font-weight: 600; + line-height: 1.4 !important; +} + +.h5-empty__hint { + margin: 0 0 12px !important; + color: #86909c !important; + font-size: 12px !important; + line-height: 1.55 !important; +} + +.h5-empty__action { + margin-top: 4px; +} + +.h5-failure { + margin: 0 0 12px; + border: 1px solid #e5e6eb; + border-radius: 10px; + background: #fafbfc; + overflow: hidden; +} + +.h5-failure__toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 10px 12px; + border: none; + background: transparent; + color: #4e5969; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + text-align: left; + cursor: pointer; +} + +.h5-failure__chevron { + flex: none; + color: #c9cdd4; + font-size: 12px; + transition: transform 0.2s ease; + + &.is-open { + transform: rotate(180deg); + } +} + +.h5-failure__list { + margin: 0; + padding: 0 10px 10px; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + max-height: min(42vh, 320px); + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +.h5-failure__product { + padding: 10px 12px; + border: 1px solid #e5e6eb; + border-radius: 8px; + background: #fff; +} + +.h5-failure__product-head { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.h5-failure__index { + flex: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--app-primary-color); + color: #fff; + font-size: 12px; + font-weight: 600; + line-height: 20px; + text-align: center; +} + +.h5-failure__product-title { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + strong { + font-size: 13px; + font-weight: 600; + color: #1d2129; + line-height: 20px; + word-break: break-word; + } +} + +.h5-failure__code { + color: #86909c; + font-size: 12px; + line-height: 18px; +} + +.h5-failure__reasons { + margin: 8px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.h5-failure__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: #86909c; + font-size: 12px; + line-height: 18px; +} + +.h5-failure__step { + color: var(--app-primary-color); + font-weight: 600; +} + +.h5-failure__reason { + margin: 4px 0 0 !important; + color: #4e5969 !important; + font-size: 13px !important; + line-height: 1.55 !important; + word-break: break-word; + text-align: left; +} + .h5-price-empty { margin-top: 12px; } diff --git a/src/views/marketing/marketingGuide/guide-dev.md b/src/views/marketing/marketingGuide/guide-dev.md new file mode 100644 index 0000000..23cf2c7 --- /dev/null +++ b/src/views/marketing/marketingGuide/guide-dev.md @@ -0,0 +1,126 @@ +# 营销中心开发指引 + +> 面向 **开发**。规则白话与例子见同页「运营」Tab。 +> 实现以 `Oms.Application.Shared/Marketing*.cs` 与 `Services/Marketing/*` 为准。 + +--- + +## 1. 边界与分层 + +| 层 | 内容 | +|----|------| +| Domain | `MarketingAggregate` 实体 / 枚举 / `MarketingConsts` | +| Application | 管理端 + 门户 Service;`MarketingBillingCalculator`、`MarketingCouponManager`、`MarketingPointsManager`、`MarketingInviteRewardCalculator` | +| Worker | `MarketingBackgroundWorker`(OMS HostedService,约 1min 一轮) | +| 订单衔接 | 预报锁券;支付写抵扣行+积分;派送成功解锁/分成 | + +--- + +## 2. 计价互斥(支付 / 查价预览) + +`MarketingBillingCalculator`: + +1. 会员折、券抵扣 **只打基础运费**;附加费原价。 +2. `extraDiscountRate` 封顶 **0.05**。 +3. 分别算会员应付与用券应付;**仅当** `payableCoupon < payableMember` 才用券。 +4. **同价保留会员折**(不占券)。 +5. 券门槛按基础运费;抵扣 ≤ 基础运费。 + +费用项:`coupon_discount` / `member_discount`(支付写入负数应收;禁手工加)。 +`PaidBaseFreight` = 基础运费 −(会员折或券);**券抵扣部分不计积分**。 + +### 时序 + +| 时机 | 行为 | +|------|------| +| 预报计费 | `ApplyMarketingSnapshotOnCreateAsync`:快照金额 + `LockForOrder`(约 30min) | +| 入库重算 | `RefreshMarketingPayableAmountAsync`:按新应收刷新,**不重新锁券** | +| 支付 | 最终选券/会员折 → `UpsertMarketingDiscountFeesAsync` → `MarkUsed` → 发积分 | + +--- + +## 3. 积分与等级 + +`MarketingPointsManager`: + +- 发放:支付成功,实付基础运费 × `PointsPerYuan`(默认 1),向下取整;按订单幂等。 +- 扣回:全额退款或已付删单;整笔 earn 回滚;幂等。 +- 定级窗口:**滚动 365 天**流水合计。 +- 升级:支付/正调账可升;**降级**仅 `CanDowngrade` 档,且一次一级。 +- Worker:每天本地 **0 点**全量滚动重算。 + +等级种子 V0–V5 见 SQL 迁移;禁止库级 UNIQUE,业务侧校验。 + +--- + +## 4. 优惠券 + +`MarketingCouponManager` / Template / Grant Service。 + +| 点 | 实现 | +|----|------| +| 渠道 | `newcomer` / `referral` / `festival` / `route` / `manual` / … | +| 预置码 | `NEWCOMER_FIRST/SECOND/STOCK`、`REFERRAL_INVITER/INVITEE` | +| 未解锁 | `NEWCOMER_SECOND` 或 `ValidType=DaysAfterUnlock` → `PendingUnlock` | +| 解锁 | 主+子账号家族 **首单 DeliverySuccess** | +| 锁券 | Locked + LockExpire;支付可换券并释旧锁 | +| 退款还券 | 仅全额退且未过期;**NEWCOMER_FIRST 不还**;部分退不还 | + +批量发券:预览 → 确认词;发后不可撤。 + +--- + +## 5. 邀请分成 + +`MarketingInviteRewardCalculator` + `ReferralCommission*`: + +| 默认(规则字段 ≤0 时回落) | 值 | +|---------------------------|-----| +| 计奖门槛(实付基础运费) | 50 | +| 首单 | 5%,封顶 50 | +| 第 2–6 单 | 3%,封顶 30;注册起 90 天内 | +| 最多计奖单数 | 6 | +| 结算 | T+N 工作日(默认 3,跳过周末) | +| 邀请资格 | 等级 ≥ V1 | + +触发:`mark-delivery-success` → Pending 明细;Worker 到点入运费余额;退款取消 Pending 或扣回 Settled。 +裂变券:被邀请人注册发 INVITEE;其首单派送成功给邀请人 INVITER(审计备注幂等,每下级一次)。 + +--- + +## 6. Background Worker + +`MarketingBackgroundWorker`(约 1 分钟,batch≈50): + +1. 释放过期券锁 +2. 过期 Available 券 +3. 到期前 3 天站内信 +4. 结算到期 Pending 分成 +5. 每日 0 点滚动定级 + +测试环境须确认 HostedService 已注册,否则解锁/结算不跑。 + +--- + +## 7. API 与前端 + +| 类型 | 示例 | +|------|------| +| 管理端 | `/marketing/member-level/*`、`coupon-template/*`、`coupon/grant`、`commission-*`、`referral-tree/*` | +| 门户 | `/marketing/member/*`、`coupon/*`、`referral/*`、`commission/*` | +| 前端路由 | `/marketing/*`、`/user/marketing/*`;指引 `/marketing/guide`(`Marketing.Guide`) | + +Vite:`/marketing/guide` 等 SPA path 须 bypass,勿把页面打到 API。 + +菜单同步:Guide 权限码须 **独立**(不可与父级 `Marketing` 重复)。 + +--- + +## 8. 联调注意 + +| 点 | 说明 | +|----|------| +| 改规则不追溯 | 积分比例、分成比例、门槛 | +| 子账号 | 券在主客户;部分门户接口拒子账号 | +| 重算应收 | 先用 `MarketingFeeHelper` 排除已有营销抵扣行 | +| 支付预览 | 券优惠 ≤ 会员折时标不可用 | diff --git a/src/views/marketing/marketingGuide/guide-ops.md b/src/views/marketing/marketingGuide/guide-ops.md new file mode 100644 index 0000000..ca0293d --- /dev/null +++ b/src/views/marketing/marketingGuide/guide-ops.md @@ -0,0 +1,342 @@ +# 营销中心运营指引 + +> 本文面向 **运营同学**,说明 OMS「会员体系 / 优惠券 / 拉新」怎么配、怎么算、客户会看到什么。 +> API、计价互斥实现、Worker 见同页 **「开发」** Tab。 +> 菜单入口:左侧 **营销**;客户侧在 **个人中心 → 营销中心**。 + +--- + +## 1. 先记住这 5 条总规则 + +整套营销都围着 **「实付基础运费」** 转,附加费一律不参与优惠、不计积分、不计邀请奖励。 + +| # | 规则 | 白话 | +|---|------|------| +| 1 | **只打基础运费** | 燃油、偏远、清关、退件、关税、赔偿等附加费 **原价收**,不能用券、也不能打会员折 | +| 2 | **会员折 与 券 二选一** | 同一单不能叠加;系统自动选 **更省钱** 的那个;若两边省一样多,优先用会员折(不占客户的券) | +| 3 | **一单一张券** | 结算时最多勾选 1 张可用券 | +| 4 | **积分只定级** | 积分不能抵运费、不能兑礼、不能提现;只用来算会员等级 | +| 5 | **改规则不追溯** | 改积分比例、分成比例、等级门槛,只影响之后的新订单 / 新结算,已发生的不重算 | + +**什么叫「实付基础运费」?** + +客户这一单里,真正付出去的「基础运费」金额(已经扣过会员折或券之后的基础运费部分)。 +附加费、券抵扣掉的那一块,都不进积分、也不进邀请奖励。 + +--- + +## 2. 三大板块一眼看懂 + +``` +会员体系 优惠券 拉新(邀请) + │ │ │ + ├ 下单付基础运费 ├ 注册/活动发券 ├ V1 及以上可邀请 + ├ 攒积分定等级 ├ 下单勾选 1 张 ├ 好友注册得 5 元券 + └ 基础运费打折 └ 只抵基础运费 ├ 好友首单派送成功 → 双方发裂变券 + └ 派送成功订单按比例奖运费余额 +``` + +| 板块 | 运营在后台做什么 | 客户在门户看到什么 | +|------|------------------|-------------------| +| **会员体系** | 配等级门槛与折扣、看积分流水、必要时调账 | 我的等级、近 12 个月积分、下一等级还差多少 | +| **优惠券** | 建模板、发布/停发、批量发节日券 | 我的优惠券(可用 / 未解锁 / 已用 / 过期) | +| **拉新** | 配分成规则、查分成明细、审提现、查引荐关系 | 邀请码/链接、下级列表、推荐收益、转余额/提现 | + +--- + +## 3. 会员体系 + +### 3.1 默认等级(V0~V5) + +系统预置六档(可在 **营销 → 会员等级** 调整门槛与折扣,但建议先理解默认再改): + +| 等级 | 名称 | 近 12 个月积分下限 | 基础运费折扣 | 会不会降级 | +|------|------|-------------------|--------------|------------| +| V0 | 普通会员 | 0 | 原价(不打折) | 否(只升不降) | +| V1 | 铜卡会员 | 24,000 | 99 折(少付 1%) | 否 | +| V2 | 银卡会员 | 60,000 | 98 折 | 否 | +| V3 | 金卡会员 | 120,000 | 97 折 | 否 | +| V4 | 钻石会员 | 240,000 | 96 折 | **是**,积分不够会往下降 | +| V5 | 皇冠会员 | 600,000 | 95 折(封顶) | **是** | + +> 折扣率在后台填的是「少付比例」:`0.01` = 99 折,`0.05` = 95 折。系统封顶 **最高 95 折**,不能更低。 + +### 3.2 积分怎么来? + +- **默认**:实付基础运费 **1 元 = 1 积分**(在 **营销 → 积分规则** 可改比例) +- **不计积分**:燃油、偏远、清关等附加费;被优惠券抵掉的金额 +- **退款 / 删单**:对应积分会扣回 +- **定级看什么**:近 **滚动 12 个月** 的积分合计(不是从注册开始的终身累计) +- **升级时机**:支付成功后会立刻按新积分尝试升级;每天凌晨再跑一轮滚动定级 + +### 3.3 升降级规则(很重要) + +- **V0~V3**:**只升不降**。哪怕后面 12 个月发货变少、滚动积分掉下去,也 **不会** 自动降回更低档。 +- **V4 / V5**:勾了「允许降级」。每天凌晨若滚动积分不够本档门槛,会 **一次只降一级**(例如 V5 → V4,不会一次掉到 V3)。 +- 改积分规则比例:**不追溯** 历史订单。 + +### 3.4 算账例子 + +**例 A:铜卡下单** + +- 基础运费标价 200 元,燃油 30 元,客户是 V1(99 折) +- 会员折只打基础运费:`200 × 1% = 2` 元 +- 应付 ≈ `200 - 2 + 30 = 228` 元 +- 积分:按实付基础运费约 198 分(附加费 30 不计) + +**例 B:会员折 vs 券,取更优** + +- 基础运费 200,客户 V3(97 折)→ 会员折省 `200 × 3% = 6` 元 +- 手里有一张满减 15 元券,门槛 0 +- 用券省 15 元 > 会员折省 6 元 → **这单用券,会员折不用** +- 若券只剩 5 元面额,会员折更划算 → **用会员折,券留着** + +**例 C:滚动定级** + +- 某客户近 12 个月积了 125,000 分 → 到 V3 金卡 +- 一年后老订单滚出窗口,近 12 个月只剩 50,000 分 +- 若仍是 V3:因为 V3 保级,**继续金卡** +- 若已是 V5:会逐级往下降,直到积分够得上的那一档 + +### 3.5 运营常做的事 + +| 菜单 | 做什么 | +|------|--------| +| **会员等级** | 看/改各档门槛、折扣、权益文案;有客户占用的等级不能随便删 | +| **积分规则** | 改「每元基础运费积几分」;保存前系统会提醒:只影响之后支付的订单 | +| **积分流水** | 查某客户发放/扣回/调账;人工调账须写清原因(不少于 10 字) | + +--- + +## 4. 优惠券 + +### 4.1 券只能干什么? + +- **只抵基础运费**,附加费不能抵 +- **门槛**按基础运费判断(「满 150」= 这单基础运费 ≥ 150) +- **不找零、不冲附加费**:面额再大,最多把基础运费抵到 0 +- 与会员折扣 **互斥取更优**(见上一节) +- 券包挂在 **主客户** 上;子账号下单用主账号的券 +- **不可转让**;发券后 **不可撤回**(只能等过期或客户用掉) + +### 4.2 券类型(建模板时选) + +| 类型 | 客户感知 | 配置要点 | +|------|----------|----------| +| **满减券** | 满 X 减 Y | 填面额 + 使用门槛 | +| **立减券 / 运费券** | 直接减 Y | 填面额;门槛可填 0 | +| **折扣券** | 打几折 | 填折扣率,如 `0.95` = 95 折;可设最高抵扣封顶 | + +### 4.3 发放类型(决定「怎么发到客户手里」) + +| 发放类型 | 谁触发发放 | 典型用途 | +|----------|------------|----------| +| **新人券** | 客户注册成功自动发(状态须为「发放中」) | 首单券、第二单券、备货券 | +| **裂变券** | 邀请链路自动发 | 被邀请人注册券、邀请人有礼券 | +| **节日券 / 线路定向券 / 手动发放** | 运营在 **批量发券** 里选人发放 | 大促、指定国家线路激励 | +| **客户自领** | 客户主动领取(若已开放) | 活动页领券 | + +**线路定向券**:在模板里填适用国家代码,如 `MY,SG`;空 = 不限国家。 + +### 4.4 系统预置的新人 / 裂变券(务必熟悉) + +| 模板编码 | 名称 | 面额 / 门槛 | 怎么拿到 | 有效期 | +|----------|------|-------------|---------|--------| +| NEWCOMER_FIRST | 新人首单券 | 15 元,无门槛 | 注册自动发,立即可用 | 领取后 15 天 | +| NEWCOMER_SECOND | 新人第二单券 | 15 元,满 150 | 注册时发成 **未解锁**;**首单派送成功** 后解锁 | 解锁后 30 天 | +| NEWCOMER_STOCK | 新人备货券 | 20 元,满 200 | 注册自动发,立即可用 | 领取后 60 天 | +| REFERRAL_INVITEE | 邀请注册加赠券 | 5 元,无门槛 | **通过邀请链接注册** 即发 | 领取后 30 天 | +| REFERRAL_INVITER | 邀请有礼券 | 20 元,无门槛 | 被邀请人 **首单派送成功** 后发给邀请人 | 领取后 30 天 | + +**第二单券客诉高频点:** +客户说「我券包里有一张用不了」→ 多半是 **未解锁**。告诉客户:先把第一单等到 **派送成功**,第二单券才会变成可用,且要用时基础运费需满 150。 + +### 4.5 有效期三种写法 + +| 有效期类型 | 含义 | +|------------|------| +| **固定日期** | 指定开始~结束时间,过了就不能用 | +| **领取后 N 天** | 从领到那天起算 N 天 | +| **解锁后 N 天** | 从解锁那天起算 N 天(第二单券用这个) | + +到期前约 **3 天** 会给客户站内信提醒;即将过期的券在门户会标红。 + +### 4.6 券状态(批量发券 / 发放记录里) + +| 状态 | 含义 | +|------|------| +| 可用 | 可在下单结算勾选 | +| 未解锁 | 等首单派送成功(或满足解锁条件) | +| 预占中 | 某张订单锁定了这张券(约 30 分钟),支付完成会变已使用;超时释放 | +| 已使用 | 已核销 | +| 已过期 | 过了有效期 | + +### 4.7 算账例子 + +**例 D:满减券** + +- 基础运费 180,附加费 40,券「满 150 减 15」 +- 门槛看 180 ≥ 150 → 可用;抵 15 +- 应付 = `(180 - 15) + 40 = 205` +- 积分按实付基础运费约 165 计(15 元被券抵掉的部分不计积分) + +**例 E:折扣券封顶** + +- 基础运费 1,000,95 折券,最高抵扣 30 +- 打折本应省 50,但封顶 30 → 只减 30 + +**例 F:新人注册当天券包** + +- 普通注册:首单券 15 + 备货券 20 + 第二单券(未解锁) +- 带邀请码注册:上面三张 **再加** 5 元邀请注册券 + +### 4.8 运营怎么发节日券(推荐步骤) + +1. **营销 → 券模板**:新建 → 填名称/面额/门槛/有效期/发放类型(节日券)→ 保存为草稿 +2. 点 **发布**(客户侧才能真正领到;发布前再核对面额与库存) +3. **营销 → 批量发券**:选模板 → 粘贴客户编号(每行一个,或逗号分隔;子账号会归到主客户)→ 每人张数 1~10 +4. 先点 **预览**(看人数、总张数、预估最大抵扣)→ 输入确认词「确认发放」→ 发放 +5. 到 **发放记录** 核对状态 + +> 停发模板:以后不能再新发,但 **已经发到客户手里的券仍可继续用到过期**。 + +--- + +## 5. 拉新(邀请与分成) + +拉新 = **邀请关系绑定** + **裂变发券** + **按派送成功订单奖运费余额**。 + +### 5.1 谁可以邀请? + +- 默认要求邀请人会员等级 **≥ V1(铜卡)** +- V0 普通会员:门户「邀请好友」会提示先升级;分享出去的链接对别人可能无效或无邀请资格 +- 资格门槛可在 **营销 → 分成规则** 的「邀请资格最低等级」调整 + +### 5.2 客户怎么邀请? + +1. 复制邀请码 / 邀请链接 / 话术,或保存海报 +2. 好友在微信里打开链接,完成注册 → **自动绑定** 一级引荐关系 +3. 好友下单并 **派送成功** 后,邀请人开始拿奖励 + +只统计 **一级下级**(你邀请的人);下级再邀请的人,不算在你名下。 + +### 5.3 券奖励(裂变) + +| 对象 | 何时 | 得到什么 | +|------|------|----------| +| 被邀请人(新客) | 注册成功 | 5 元无门槛券(另有普通新人券包) | +| 邀请人(老客) | 该新客 **首单派送成功** | 20 元无门槛「邀请有礼券」(每个被邀请人最多触发一次) | + +### 5.4 运费余额奖励(默认规则) + +计奖基数 = 被邀请人该单的 **实付基础运费**(同样不含附加费)。 +触发时点 = 订单状态变为 **派送成功**(不是下单、也不是支付瞬间)。 + +| 被邀请人第几单(派送成功且计次) | 比例 | 单笔封顶 | 额外限制 | +|----------------------------------|------|----------|----------| +| 第 1 单 | 5% | 50 元 | 实付基础运费须 ≥ 门槛(默认 **50 元**) | +| 第 2~6 单 | 3% | 30 元 | 须在新客 **注册后 90 天内** 派送成功;最多计 **6** 单 | +| 第 7 单及以后 | — | — | 不再计奖 | + +入账方式: + +1. 派送成功 → 生成 **待结算** 明细 +2. **T+3 个工作日**(跳过周六日)后自动入 **运费余额**(可用来继续下单) +3. 若订单退款:奖励会扣回 / 取消,且该单一般不再计入「第几单」 + +客户也可在「推荐收益」里把已入账佣金 **转账户余额** 或 **申请提现**(提现需运营在 **提现审核** 处理)。 + +### 5.5 算账例子 + +**例 G:首单奖励** + +- 新客首单派送成功,实付基础运费 800 元 +- 奖励 = `800 × 5% = 40` 元(未超 50 封顶) +- 若基础运费 1,200 → `1200 × 5% = 60`,封顶后只奖 **50** 元 +- 若基础运费只有 40(低于 50 门槛)→ **不计奖** + +**例 H:后续单 + 时间窗** + +- 新客 1 月 1 日注册;4 月 15 日才完成第 2 单派送成功 +- 注册后第 104 天 > 90 天窗口 → **第 2 单不计奖** +- 若 3 月 1 日(第 59 天)派送成功,实付基础运费 1,000 → `1000 × 3% = 30`,刚好封顶 30 元 + +**例 I:T+3 工作日** + +- 周五派送成功 → 下周一、二、三共 3 个工作日 → 大约下周三入账(具体以系统「计划结算」时间为准) + +### 5.6 引荐关系改绑 + +- 菜单:**营销 → 引荐关系** +- 改绑只影响 **之后的新订单**;历史已结算分成 **不回溯** +- 须填写不少于 20 字的原因(进审计),并二次确认 + +### 5.7 提现审核注意 + +- 核对申请金额与客户佣金余额 +- 驳回必须写原因(客户门户可见,不少于 5 字) +- **不要** 一键全部通过;逐笔核对 + +--- + +## 6. 推荐运营配置顺序 + +``` +① 会员等级(确认 V0~V5 门槛与折扣、V4/V5 是否允许降级) +② 积分规则(每元积几分,默认 1) +③ 券模板(新人券 / 裂变券保持「发放中」;再按活动建节日券) +④ 分成规则(邀请门槛、比例、封顶、90 天窗口、T+N 工作日) +⑤ 活动时:批量发券 → 发放记录核对 +⑥ 日常:积分流水 / 分成明细 / 提现审核 / 引荐关系 +``` + +新人券、裂变券若误点成「停发」或改成「草稿」,新注册客户就 **领不到** 对应券,客诉会立刻上来——改状态前请再确认。 + +--- + +## 7. 各菜单速查 + +| 菜单 | 路径 | 运营用途 | +|------|------|----------| +| 新手指引 | `/marketing/guide` | 本文档 | +| 会员等级 | `/marketing/memberLevel` | 定级门槛与基础运费折扣 | +| 积分规则 | `/marketing/pointsRule` | 积分比例 | +| 积分流水 | `/marketing/pointsFlow` | 查账、人工调积分 | +| 券模板 | `/marketing/couponTemplate` | 建券、发布、停发 | +| 批量发券 | `/marketing/couponGrant` | 节日/定向批量发放与记录 | +| 分成规则 | `/marketing/commissionRule` | 邀请奖励比例与窗口 | +| 分成明细 | `/marketing/commissionDetail` | 查待结算/已结算,人工调佣金 | +| 提现审核 | `/marketing/commissionWithdraw` | 审提现 | +| 引荐关系 | `/marketing/referralTree` | 查下级、改绑 | + +客户门户: + +| 页面 | 路径 | +|------|------| +| 邀请好友 | `/user/marketing/invite` | +| 我的等级 | `/user/marketing/member` | +| 我的优惠券 | `/user/marketing/coupons` | +| 推荐收益 | `/user/marketing/commission` | + +--- + +## 8. 常见客诉怎么回 + +| 客户说法 | 常见原因 | 怎么处理 / 怎么解释 | +|----------|----------|---------------------| +| 「我有会员等级,为什么附加费没打折?」 | 折扣只打基础运费 | 说明燃油/偏远等始终原价 | +| 「会员折和券能不能一起用?」 | 系统设计互斥 | 说明每单取更省的那个;可帮客户对比两笔金额 | +| 「第二单券为什么是灰的?」 | 未解锁 | 等首单 **派送成功**;并用时基础运费满 150 | +| 「积分怎么变少了?」 | 滚动 12 个月 / 退款扣回 | 查积分流水;说明不是终身累计 | +| 「我邀请了人怎么没钱?」 | 未派送成功 / 未满门槛 / 超 6 单 / 超 90 天 / 自己是 V0 | 对分成明细看「第几单、实付基数、状态」 | +| 「奖励显示待结算很久」 | T+3 工作日 | 看明细「计划结算」日期;周末不计入工作日 | +| 「为什么改了分成比例,旧单没变?」 | 改规则不追溯 | 正常现象,只影响新产生的明细 | +| 「子账号看不到券」 | 券在主账号 | 用主账号登录查看;子账号下单时仍可用主账号券包 | + +--- + +## 9. 一句话心智模型 + +> **客户多付基础运费 → 攒滚动积分升会员 → 基础运费更便宜;** +> **券和会员折只能选更划算的那个;** +> **拉新靠「派送成功」计奖,钱进运费余额,附加费全程不参与。** diff --git a/src/views/marketing/marketingGuide/index.vue b/src/views/marketing/marketingGuide/index.vue new file mode 100644 index 0000000..db22a56 --- /dev/null +++ b/src/views/marketing/marketingGuide/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/views/order/orderGuide/guide-dev.md b/src/views/order/orderGuide/guide-dev.md new file mode 100644 index 0000000..5b25853 --- /dev/null +++ b/src/views/order/orderGuide/guide-dev.md @@ -0,0 +1,361 @@ +# 订单中心开发指引 + +> 面向 **开发**。运营话术见同页「运营」Tab。 +> **本文重点是下单(创建包裹)逻辑**:`CreateParcelCoreAsync` → Outbox 后置 → `ProcessForecastBillingForBatchAsync`。 +> 实现以当前 `oms0414-back` 为准。 + +--- + +## 1. 下单心智模型(先读) + +确认下单拆成 **两段**: + +| 段 | 何时 | 做什么 | +|----|------|--------| +| **A. HTTP 事务** | `POST /order/parcel/create` 同步 | 校验 → 落库草稿 → 写 Outbox → **立刻返回**(常仍是 `draft` +「预报处理中」) | +| **B. 预报消费端** | Commit 后异步(或队列关闭时同步) | 计重 → forecast 应收 → **尝试排货** → 营销锁券 → `predeclared` → 有渠道才建轨迹 | + +旧说法「创建只写应收、排货只在入库」**已过时**:预报阶段就会 `TryConfirmServiceChannelAndPayableAsync`(失败不挡 `predeclared`)。 + +```mermaid +flowchart TD + A[POST /order/parcel/create] --> B{RequestId 已存在?} + B -->|是| Z0[返回已有订单] + B -->|否| C[Normalize + 客户解析 + 硬校验] + C --> D[产品 + 快照 + 黑名单] + D --> E[EvaluatePreCommit 拦截] + E -->|命中| X0[抛错 不落库] + E --> F[Insert 订单+子表] + F --> G{SaveAsDraft?} + G -->|是| H[仅 9610 Outbox + 揽收] --> Z1[返回草稿] + G -->|否| I[IsRemote + draft + 处理中文案] + I --> J[Outbox: 9610 / 预报计费 / 面单 / OrderCreate 触发] + J --> K[揽收可选] + K --> Z2[返回 预报已入队] + J -.-> L[消费端 ProcessForecastBilling] + L --> M[计重+应收+尝试排货+营销] + M -->|成功| N[predeclared] + M -->|失败| O[draft + PredeclareFailReason] + N --> P{排货成功且有 ChannelCode?} + P -->|是| Q[PublishForecastTrack] + P -->|否| R[无轨迹 等入库再排] +``` + +**主文件:** `OrderService.ParcelBilling.cs`(算价辅助 `ParcelBillingCalculator.cs`)。 + +--- + +## 2. 入口与幂等 + +| 入口 | 方法 | +|------|------| +| PC/H5 | `POST /order/parcel/create` → `CreateParcelAsync` `[UnitOfWork]` → `CreateParcelCoreAsync` | +| JSON 批量 | `POST /order/parcel/batch-create` | +| Excel | `POST /order/upload` → 映射后调核心 | +| OpenAPI | `CreateParcelForCustomerOpenApiAsync`:同客户相同 `clientNo` 直接返回已有单 | +| MQ | `parcel-create` / `import-row` 等同核心 | + +**RequestId 幂等:** 已存在则 `BuildParcelCreateOutputAsync`,不再建单、不再发事件。 + +--- + +## 3. 阶段 A:HTTP 事务内(落库前 + 落库) + +### 3.1 总序 + +``` +1 RequestId 幂等 +2 NormalizeParcelCreateInput +3 ResolveEffectiveCustomerAsync → 写回 input.CustomerId +4 ValidateParcelCreateInputAsync +5 销售产品启用 + EnsureAdditionalServicesAllowed +6 ResolveSnapshotBindingAsync(bindTime=Now) +7 解析 Country + IsBlacklistHitAsync → 命中抛错 +8 EvaluatePreCommitAsync(仅拦截类)→ 命中抛错、不 Insert +9 ResolveServiceNoAsync + BuildDraftOrderEntity + Insert +10 BuildInitialWeightRows + SaveParcelChildrenAsync +11 SaveAsDraft?→ 后置不含预报/面单/触发 → 返回 +12 确认:IsRemoteHitAsync;OrderStatus=draft;PredeclareFailReason=处理中 +13 RunParcelCreateSideEffectsAsync(includeForecastAndLabel: true) +14 BuildParcelCreateOutput;ForecastBillingQueued;TryAttachPickup… +``` + +**分界:** 黑名单 / 拦截 / 快照缺失等在 **Insert 之前**失败 → 无订单。 +计重/应收/排货在 **预报消费端**;失败则订单留 `draft` + 原因。 + +### 3.2 客户解析 `ResolveEffectiveCustomerAsync` + +```mermaid +flowchart TD + C0[ResolveEffectiveCustomer] --> C1{登录身份} + C1 -->|客户账号| C2[忽略入参 CustomerId
强制会话客户] + C1 -->|管理/运营| C3{CustomerId > 0?} + C3 -->|否| E1[客户ID不能为空] + C3 -->|是| C4[查库 Status=Normal] + C4 -->|否| E2[客户不存在或不可用] + C4 --> C5[写回 input.CustomerId] +``` + +### 3.3 规范化 `NormalizeParcelCreateInput` + +| 字段 | 处理 | +|------|------| +| `TrackNo` | 强制 `null`(禁止客户端传入) | +| `ServiceNo` | 强制 `null`(系统生成) | +| `ClientNo` / `RequestId` | Trim;空 → null | +| `CargoAttribute` | `MapCargoTypeToAttribute` 规范化 | + +### 3.4 硬校验 `ValidateParcelCreateInputAsync` + +| 项 | 规则 | 失败文案 | +|----|------|----------| +| 客户单号 | 非空;同 CustomerId 唯一 | 客户单号不能为空 / 已存在 | +| 客户 | CustomerId > 0 且 Normal | 客户ID不能为空 / 不可用 | +| 产品 | ProductCode 非空 | 产品代码不能为空 | +| 仓库 | WarehouseId > 0 且存在 | 仓库ID不能为空 / 不存在 | +| 件重尺 | 长宽高齐全且 >0,实重 >0 | 件重尺必须大于0 | +| 货物属性 | `general` / `electronics` / `sensitive` | 货物属性不合法 | +| 出口报关 | 空或合法枚举;**9610 汇总**须资质+平台码 | 见方法内文案 | +| 申报 | 币种、Quantity>0、Items 非空且行完整 | 申报相关 | +| 收件人 | 姓名、国家、邮编、地址1 | 收件人信息不完整 | +| 发件人 | 姓名非空 | 发件人姓名不能为空 | + +### 3.5 产品与快照(仍在落库前) + +```mermaid +flowchart TD + P1[GetByProductCode] --> P2{存在且启用?} + P2 -->|否| E1[销售产品不存在或未启用] + P2 --> P3[ResolveSnapshotBindingAsync] + P3 --> P4[rules 生效发布 / LIVE] + P3 --> P5[price 生效发布 / LIVE] + P4 -->|LIVE 无 WeightRuleId| E2[未命中规则快照] + P5 -->|LIVE 无 Prices| E3[未命中价格快照] + P3 --> P6[写入 RulesVersion / PriceVersion] + P6 --> P7[黑名单 IsBlacklistHitAsync] + P7 -->|命中| E4[收件地址命中黑名单区域] +``` + +**选取:** `PublishTime <= bindTime` 且 Status=生效的最新记录;无则 `LIVE-{productId}` + 现库拼装,再 `KpiAreaPackService.HydrateBindingAsync`。 + +### 3.6 落库草稿 + +| 步骤 | 内容 | +|------|------| +| 运单号 | `IDE` + `yyMMddHHmmss` + 4 位随机 | +| `t_order` | 产品、国家、快照版本;**此时不写 ChannelCode** | +| 重量 | `forecast/receive/ship/downstream` 四行,同套长宽厚实重;`Cubic=L×W×H/1e6` | +| 子表 | sender / recipient / declaration | + +确认下单额外:同步算 **IsRemote**(不拦单);状态保持 `draft` + 处理中文案。 + +--- + +## 4. 阶段 A 后置:`RunParcelCreateSideEffectsAsync` + +有环境事务 → **Outbox**(Commit 后投递);无事务(部分店铺创单)→ **同步**执行。 + +| 顺序 | 事件 / 任务 | 仅保存草稿 | 确认下单 | +|------|-------------|------------|----------| +| 1 | `Customs9610Upsert` | ✓ | ✓ | +| 2 | `EnqueueForecastBillingAsync`(payload 含 CouponInstanceId + Authorization) | | ✓ | +| 3 | `LabelGenerate` | | ✓ | +| 4 | `AutoTriggerFire(OrderCreate)` | | ✓ | + +- **轨迹不在此列**(等预报排货出 `ChannelCode`)。 +- 队列关闭:预报可在创单路径内直接 `ProcessForecastBillingForBatchAsync`。 +- 上门揽收:`TryAttachPickupAfterParcelCreateAsync`,失败只写 `PickupCreateError`,**不回滚订单**。 + +**自动触发:** + +| 时机 | 行为 | +|------|------| +| 落库前 `EvaluatePreCommitAsync` | 仅 **拦截**;命中整单失败 | +| 落库后 Outbox OrderCreate | **跳过拦截**;警告 / 问题件 / 打标 | + +--- + +## 5. 阶段 B:预报计费(下单算价核心) + +`ProcessForecastBillingForBatchAsync`(批次 `ForecastBilling` 或同步调用)。 + +### 5.1 幂等与跳过 + +- 已删除 / 已付款 → return +- 已 `predeclared` 且无 `PredeclareFailReason` → return(幂等) +- 订单尚未可见 → 抛错让总线重试(防 Ack 后永远处理中) + +### 5.2 子流程 + +```mermaid +flowchart TD + T1[解析快照+地址+IsRemote] --> T2[ApplyChargeableWeightAsync forecast行] + T2 --> T3[SyncWeightRows 同步四行] + T3 --> T4[ResolveOrderBillingContext → zoneKeys] + T4 -->|zone 空| E1[价格分区未匹配 → draft失败] + T4 --> T5[UpsertReceivableFeesAsync forecast] + T5 -->|抛错| E2[draft + 原因] + T5 --> T6[TryConfirmServiceChannelAndPayableAsync forecast] + T6 -->|失败| T7[不挡预报 可有 AllocationPendingReason] + T6 -->|成功| T8[写入 ChannelCode 等] + T7 --> T9[ApplyMarketingSnapshotOnCreate 锁券] + T8 --> T9 + T9 --> T10[CaptureForecastBillingSnapshot] + T10 --> T11[status=predeclared 清空失败原因] + T11 --> T12{allocated && ChannelCode?} + T12 -->|是| T13[PublishForecastTrack AppendInitial] + T12 -->|否| T14[跳过轨迹] + T13 --> T15[操作日志] + T14 --> T15 +``` + +### 5.3 计重 `ApplyChargeableWeightAsync` + +```mermaid +flowchart TD + W1[取 WeightRuleId] --> W2{有规则?} + W2 -->|否| E[未配置计重规则] + W2 --> W3[规则明细 优先快照] + W3 --> W4[体积重 vw=L×W×H/泡比] + W4 --> W5[按 Id 排序逐条 Condition gw/vw] + W5 -->|命中| W6[raw=gw 或 vw] + W6 --> W7[ApplyRounding 进位区间] + W7 --> W8[ChargeableWeight=ceil raw/unit×unit] + W8 --> W9[写 forecast.VolumetricWeight/ChargeableWeight] +``` + +| 运算 | 规则 | +|------|------| +| Cubic | `L×W×H/1_000_000`(6 位) | +| 体积重 | `L×W×H/VolumetricFactor`(3 位) | +| 进位区间 | **左开右闭** `(start,end]` | +| 失败 | 无规则 / 条件均未命中 / 进位区间未匹配 | + +### 5.4 价格分区 → zoneKeys + +- 优先快照内 PriceAreaInfos;否则查库 KPI pack +- 地址匹配:国/省/市 + 邮编(**邮编 range 为闭区间**,见渠道开发指引) +- 无命中 → 预报失败「价格分区未匹配」 + +### 5.5 应收 `UpsertReceivableFeesAsync`(forecast) + +遍历卖价快照 `Status=1` 价格项 → `MatchPriceDetail` → `CalculateDetailAmount` → 写 `OrderFee` receivable。 + +**MatchPriceDetail:** + +```mermaid +flowchart TD + M0[遍历 Details] --> M1{分区 ∩ zoneKeys?} + M1 -->|否| M0 + M1 --> M2{偏远 zone 匹配?} + M2 --> M3{货物属性?} + M3 --> M4{仓库因素?} + M4 --> M5{有重量因素?} + M5 -->|否| M6[可作无重量候选] + M5 -->|是| M7[解析重量 左开右闭] + M7 --> M8{计费重落在区间?} + M8 -->|是| M9[取区间最窄者] + M0 -->|结束| M10[bestMatch 或 null] +``` + +重量文案:`0.5` → `(0,0.5]`;`0,0.5` / `0-0.5` → `(0,0.5]`。 + +**CalculateDetailAmount 公式:** + +| 类型 | 逻辑 | +|------|------| +| `total` | 客户组价 ?? CalculatedValue | +| `unit` | 单价 × ChargeableWeight | +| `continue` | 上档 total 作 base + 续重,或单行首续重 | +| `percent_declared` | 申报货值 × 费率 | +| `percent_freight` | 运费基数百分比(两趟计算第二趟) | + +有启用项但全部匹配失败且金额 0 → 抛「卖价未匹配…」。 + +### 5.6 排货(预报阶段) + +`TryConfirmServiceChannelAndPayableAsync(stage=forecast)`: + +- 失败 **不** `MarkParcelDraftFailed`,订单仍可 `predeclared` +- 成功写 `ChannelCode`、下游字段、应付费用(当前 stage 重写 payable,不堆叠) +- 渠道买价用 **`PublishTime<=Now` 当前生效版**,非创单绑定时刻 +- Scheme:`costLowest` / `timeFastest` / `overallBest` / `designated`(`AllocationResolver`) + +> 方法 XML 若仍写「创建不调用」,以本消费端调用为准。 + +### 5.7 营销与轨迹 + +| 步骤 | 说明 | +|------|------| +| `ApplyMarketingSnapshotOnCreateAsync` | 会员折/券试算写入订单字段;`LockForOrder`≈30min;失败仅日志 | +| `CaptureForecastBillingSnapshotAsync` | 固化 ForecastReceivable/Payable,入库勿覆盖 | +| `PublishForecastTrackAsync` | 仅排货成功且有 ChannelCode;`AppendInitialTrack=true`;Authorization 来自创单捕获 | + +--- + +## 6. 下单相关状态与字段 + +| 时刻 | OrderStatus | 关键字段 | +|------|-------------|---------| +| 确认下单 API 返回 | `draft` | `PredeclareFailReason=预报计费/排货处理中` | +| 预报成功 | `predeclared` | 清空失败原因;可能仍有 `AllocationPendingReason` | +| 预报失败 | `draft` | `PredeclareFailReason=具体错误` | + +运单号格式:`IDE` + `yyMMddHHmmss` + 4 位随机。 + +--- + +## 7. 下单失败速查 + +| 关键词 / 现象 | 阶段 | 排查 | +|---------------|------|------| +| 客户单号已存在 | HTTP | 同客户 ClientNo | +| 销售产品未启用 | HTTP | 产品状态 | +| 未命中价格/规则快照 | HTTP | 是否发布;LIVE 缺项 | +| 黑名单 | HTTP | 分区明细 + 产品绑定 | +| 一直「处理中」 | 队列 | 预报消费者 / Outbox | +| 计重/分区/卖价文案 | 预报 | 件重尺、邮编、矩阵 | +| predeclared 无轨迹 | 预报 | 排货失败无 ChannelCode;Authorization | +| 轨迹 401 | 预报异步 | payload Authorization | + +--- + +## 8. 查价 vs 下单(对照) + +| | 查价 | 确认下单 | +|--|------|----------| +| 接口 | `POST /order/price-check` | `POST /order/parcel/create` | +| 落库 | 否 | 是 | +| 尺寸 | 可空估算 | 必须 >0 | +| 邮编/黑名单 | 无邮编可跳过 | 邮编必填,黑名单硬拦 | +| 计费 | 参考价 + quoteLevel | forecast 应收 + 尝试排货 | +| 实现 | `OrderService.ParcelQuote.cs` | 本文 §3–§5 | + +--- + +## 9. 下单之后(简述,非本文重点) + +| 环节 | 要点 | +|------|------| +| 入库 | 实测重算 inbound 应收 + 再排货;双成功 → `pendingPayment` | +| 付款 | 营销抵扣行落库、核销券、积分;→ `pendingShip` | +| 出库 | 写 ship 件重尺 → `pendingOnline`,**不重算应收** | + +细节可再开运营 Tab 或后续专章;排货 Scheme / 渠道买价见 [渠道开发指引](/channel/guide)。 + +--- + +## 10. 代码索引(下单) + +| 主题 | 位置 | +|------|------| +| 创单主流程 | `CreateParcelCoreAsync` / `RunParcelCreateSideEffectsAsync` | +| 校验 | `ValidateParcelCreateInputAsync` / `NormalizeParcelCreateInput` | +| 快照 | `ResolveSnapshotBindingAsync` / LIVE Build | +| 预报计费 | `ProcessForecastBillingForBatchAsync` / `EnqueueForecastBillingAsync` | +| 计重应收 | `ApplyChargeableWeightAsync` / `UpsertReceivableFeesAsync` | +| 明细匹配 | `ParcelBillingCalculator.MatchPriceDetail` / `CalculateDetailAmount` | +| 排货 | `TryConfirmServiceChannelAndPayableAsync` / `AllocationResolver` | +| 轨迹 | `PublishForecastTrackAsync` | +| 批次消费 | `OrderBatchOrderActionHandler`(ForecastBilling) | +| Outbox | `Messaging/ParcelCreate/*`、`Messaging/OrderSideEffects/*` | diff --git a/src/views/order/orderGuide/guide-ops.md b/src/views/order/orderGuide/guide-ops.md new file mode 100644 index 0000000..2af6590 --- /dev/null +++ b/src/views/order/orderGuide/guide-ops.md @@ -0,0 +1,280 @@ +# 订单中心运营指引 + +> 本文面向 **运营、客服** 同学,说明查价、下单、入库、付款、出库怎么走,以及常见报错怎么排查。 +> 接口、Outbox、计费代码见同页 **「开发」** Tab;渠道配置见 [渠道指引](/channel/guide)。 + +--- + +## 1. 订单模块管什么? + +订单模块负责客户从「问多少钱」到「货发出去」的全过程: + +| 环节 | 运营日常动作 | 客户感知 | +|------|--------------|----------| +| **查价** | 帮客户试运费、对比线路 | 「我要查价」参考价 | +| **下单** | 单笔下单 / Excel 导入 / 代客下单 | 生成运单,进入预报 | +| **入库** | 仓库扫描收货、量体积重 | 按实测重算应收;系统尝试排货 | +| **付款** | 催付、查费用明细 | 扣余额 / 充值后付款 | +| **出库** | 扫描出库 | 进入待上网,等轨迹 | + +**一句话:** 下单时先按 **预报件重尺** 算出客户应收;入库后再按 **实测** 重算,并尽量自动选好下游渠道。 + +--- + +## 2. 订单状态(务必记住) + +| 状态(界面常见说法) | 含义 | 运营要注意 | +|----------------------|------|------------| +| **草稿** | ① 刚提交、预报还在处理中;或 ② 预报计费失败留下的单 | 处理中:稍等刷新;失败:看「预报失败原因」 | +| **已预报** | 预报计费成功,已有预报应收,还没收货 | 等仓库入库 | +| **待排货** | 已入库、应收已算,但 **自动选渠道失败** | 看「待排货原因」;补渠道买价/排货配置后重算应付 | +| **待支付** | 入库排货成功(或手动重算应付成功),等客户付钱 | 催付;可核对费用是否合理 | +| **待发货** | 已付款,等出库 | 可安排出库扫描 | +| **待上网** | 已出库,等服务商上网轨迹 | 关注轨迹是否正常更新 | + +``` +下单成功 → 已预报 →(入库)→ 待支付 或 待排货 + ↓(付款) + 待发货 →(出库)→ 待上网 → …后续轨迹状态 +``` + +--- + +## 3. 全链路怎么走(对照例子) + +### 3.1 幸福路径示例 + +假设客户要发 **马来西亚**,销售产品选「MY 特惠线」,预报:长 30cm、宽 20cm、高 10cm、实重 0.8kg。 + +1. **查价**:目的国 MY + 实重 0.8 → 看到参考运费(可能标「估算」,入库后可能变) +2. **下单**:填齐收件人、申报、件重尺 → 提交成功(见下一节「下单成功后」)→ 很快变成 **已预报**,有预报运费 +3. **入库**:仓库称重 0.95kg、量体积变了 → 系统按实测重算应收 → 自动选渠道成功 → **待支付** +4. **付款**:客户付清 → **待发货** +5. **出库**:扫描出库 → **待上网**,之后看轨迹 + +### 3.2 下单成功后系统会做什么? + +这里说的「确认下单」(不是「仅保存草稿」)。页面提示成功后,系统大致按下面顺序干活——有的当场做,有的进队列稍后完成。 + +#### A. 提交当时(订单已经生成) + +| 动作 | 运营能看到什么 | +|------|----------------| +| 生成运单号、写入订单与收发件/申报/件重尺 | 「我的订单」里能搜到这票 | +| 先记成 **草稿**,失败原因类似「预报计费/排货处理中」 | 列表可能短暂显示处理中;刷新后应变 **已预报** | +| 判断是否 **偏远** | 订单上有偏远标记(不拦下单) | +| 绑定当前销售产品的 **规则/价格发布快照** | 之后算价按这次绑定的版本,不跟你事后改配置实时变 | + +若配置了 **上门揽收** 且地址齐全:会尝试创建揽收任务。揽收失败 **只记错误提示,不会把整张订单撤销**。 + +#### B. 后台自动排队做的事(提交后很快执行) + +这些一般不需要运营手工点;失败时订单上会留下原因或日志。 + +| 动作 | 说明 | 失败时 | +|------|------|--------| +| **9610 报关资料同步**(若走跨境/平台相关) | 按订单补 9610 侧数据 | **不阻断**下单;缺资料以后再补 | +| **预报计费** | 算计费重 → 算 **预报应收** → 尝试排货与成本 | 失败则停在 **草稿**,看「预报失败原因」 | +| **营销优惠落地** | 若下单勾了券:锁定券、写入会员折/券抵扣后的应付 | 营销失败一般 **不挡**预报成功(会打日志) | +| **生成面单 PDF** | 预报成功链路里异步出面单 | 面单失败 **不挡**订单;可稍后重打 | +| **自动触发(下单时机)** | 按渠道里配置的规则:警告通知、打问题件等 | 单条规则失败不影响整单;**拦截类**在落库前已拦过,不会建出单 | + +> **拦截类自动触发**发生在「点下单、还没真正建成单」之前:命中则直接报错,**不会留下订单**。 +> 建成单之后的自动触发,主要做 **警告 / 问题件**(以及规则允许的其它动作),不再用拦截挡掉整单。 + +#### C. 预报计费成功之后(真正变成「已预报」) + +| 动作 | 说明 | +|------|------| +| 订单状态改为 **已预报**,清空「处理中/失败」原因 | 客户与客服以这个状态为准 | +| 写入预报阶段的 **应收费用** | 「费用明细」里能看到预报运费等 | +| **尝试排货**(选服务渠道、算公司成本) | 排货失败 **不阻止**变成已预报;入库时还会再试(开发细节见「开发」Tab) | +| 写一条操作日志(预报计费/排货完成) | 便于审计 | +| **创建轨迹订单**(仅当预报阶段排货成功且已有渠道) | 轨迹中心开始能查到这票;没排到渠道则等入库排货成功后再建轨迹 | + +**例:客户刚下完单就问「怎么还是草稿?」** +→ 多半还在预报队列里。等几秒刷新;若一直草稿,打开订单看 **预报失败原因**(分区、计重、卖价等),对症改地址或找渠道同事查配置。 + +**例:已预报但轨迹中心没有?** +→ 常见是预报时 **还没排到渠道**。等仓库入库排货成功后再看;也可在轨迹侧按运单刷新(需已有渠道信息)。 + +#### D. 不会在「下单成功」当下做的事 + +| 动作 | 何时才做 | +|------|----------| +| 按入库实测重算应收 | **入库扫描** 之后 | +| 积分到账、邀请分成计奖 | **付款成功 / 派送成功** 等后续节点(见营销指引) | +| 出库、上网轨迹 | **出库** 及服务商轨迹拉取之后 | + +#### E. 「仅保存草稿」时少做很多事 + +若点的是保存草稿:只落库订单资料(可含 9610、揽收相关),**不做**预报计费、面单、下单自动触发。 +之后在订单里再「确认预报 / 完成预报」时,才走上面的预报计费等步骤。 + +### 3.3 查价 vs 下单,差在哪? + +| | 查价 | 下单 | +|--|------|------| +| 目的 | 心里有数 | 真正建单 | +| 尺寸 | 可以不填(按实重大致估) | **长宽高实重都必须 > 0** | +| 邮编 | 可不填(不校验黑名单) | **必须有邮编**;命中黑名单直接拒单 | +| 是否落库 | 否 | 是,有运单号 | + +**对客户话术:** 查价仅供参考;最终以入库实测计费为准。 + +--- + +## 4. 下单前必填与硬规则 + +### 4.1 必填清单 + +| 项目 | 要求 | +|------|------| +| 客户单号 | 必填;**同一客户下不能重复** | +| 销售产品 | 必选且产品已启用 | +| 仓库 | 必选 | +| 件重尺 | 长、宽、高、实重都 > 0 | +| 货物属性 | 普货 / 带电 / 敏感 等(按系统选项) | +| 收件人 | 姓名、国家、邮编、地址 1 必填 | +| 发件人 | 姓名必填 | +| 申报明细 | 至少一行;品名、单价、单重、数量完整 | +| 申报币种 | 必填 | + +### 4.2 运单号 + +- 系统自动生成,一般以 **IDE** 开头(如 `IDE2607221859123456`) +- 客户单号是客户自己的编号;运单号是系统给客户查件用的号 + +### 4.3 黑名单 vs 偏远(别混) + +| | 黑名单 | 偏远 | +|--|--------|------| +| 命中后 | **下单失败**,建不了单 | 仍可下单,订单打「偏远」标记 | +| 运营动作 | 换地址,或找渠道同事改黑名单分区 | 留意是否有偏远附加费 | + +**例:** 某邮编在黑名单 → 客户提示「收件地址命中黑名单」→ 请客户换邮编/城市,或核对渠道里该产品是否绑错黑名单区。 + +--- + +## 5. 计费怎么理解(举例) + +### 5.1 只影响「卖给客户多少钱」的几件事 + +1. **销售产品**是否启用、是否已 **发布** 卖价与规则 +2. **计费重**:实重和体积重取规则允许的那个,再进位 +3. **价格分区**:目的国 + 省/市 + 邮编 落在哪一区 +4. **货物属性、仓库、客户组**(若卖价矩阵按这些区分) + +营销折扣(会员折、优惠券)另见 [营销指引](/marketing/guide):只打基础运费,与券互斥取更优。 + +### 5.2 计费重例子 + +产品计重规则:体积重 = 长×宽×高 ÷ 6000;实重与体积重取大;再按 0.5kg 进位。 + +- 件:30×20×10 cm,实重 0.8kg +- 体积重 = 30×20×10 / 6000 = 1.0kg +- 取大 → 1.0kg;若进位规则按 0.5 → 计费重 **1.0kg** + +若入库后实重变成 1.2kg,体积重仍 1.0 → 计费重变 **1.5kg**(举例),应收会比预报高——这是正常现象,要跟客户讲清。 + +### 5.3 邮编分区例子 + +价格分区配置:马来西亚邮编 **(50000, 60000]** 属「西马 A 区」。 + +- 邮编 50000 → **不在**区间内(左开)→ 可能分区未匹配 +- 邮编 50001~60000 → 命中西马 A 区 + +分区没配到、或产品没关联价格分区 → 下单报 **「价格分区未匹配」**。 + +--- + +## 6. 入库与「待排货」 + +### 6.1 入库做了什么? + +1. 写入库实测件重尺 +2. **重算客户应收**(入库阶段费用) +3. **尝试自动排货**:从销售产品的排货列表里选一条服务渠道,并算公司成本(应付) + +| 结果 | 订单状态 | 含义 | +|------|----------|------| +| 排货成功 | **待支付** | 可以催客户付款 | +| 排货失败 | **待排货** | 货已收、钱也能算给客户看,但还没定走哪条下游渠道 | + +### 6.2 排货怎么选渠道?(运营配置向) + +销售产品 → 排货规则里常见三种: + +| 策略 | 白话 | +|------|------| +| **成本最低**(默认) | 在可用渠道里选成本最低的 | +| **时效最快** | 选时效最短的 | +| **综合最优** | 按配置的权重打分选 | + +**例:** 排货列表有渠道 A、B;A 买价算出来 12 元,B 是 15 元;策略是成本最低 → 选 A。 + +### 6.3 待排货常见原因 + +| 现象 | 常见原因 | 运营怎么处理 | +|------|----------|--------------| +| 待排货 | 排货列表为空 / 渠道未启用 | 去渠道中心补排货、启用渠道 | +| 待排货 | 渠道 **买价** 未发布或算不出 | 补买价矩阵并发布 | +| 待排货 | 渠道限制了客户白名单,当前客户不在 | 改白名单或换渠道 | +| 待排货 | 实测重/分区导致买价行匹配不到 | 核对买价重量段与分区 | + +处理完后,在订单侧做 **重算应付**(或按你们现场按钮名称操作),成功后应变为 **待支付**。 + +--- + +## 7. 常见报错 ↔ 怎么回客户 / 怎么查 + +| 客户或系统提示 | 可能原因 | 运营动作 | +|----------------|----------|----------| +| 客户单号已存在 | 同客户重复用同一个客户单号 | 换客户单号,或查是否已下过 | +| 销售产品不存在或未启用 | 产品停用 / 编码错 | 查销售产品状态 | +| 未命中价格/规则快照 | 改过配置 **没发布** | 渠道 → 销售产品 → 发布 rules / price | +| 收件地址命中黑名单 | 地址在禁运区 | 换地址,或调整黑名单 | +| 价格分区未匹配 | 邮编/国家不在价格分区 | 补分区明细或核对地址 | +| 未配置计重规则 | 产品没绑计重 / 未发布 | 产品配置 + 发布规则 | +| 卖价未匹配到有效价格 | 重量段、货物属性、分区对不上卖价表 | 查卖价矩阵 | +| 一直待排货 | 见上一节 | 先看订单上的待排货原因 | +| 查价有价、下单失败 | 查价未校验邮编/黑名单;下单更严 | 用完整地址再试下单 | + +**草稿单:** 计费失败时可能留下草稿,打开订单看 **预报失败原因**,按上表对号入座。 + +--- + +## 8. 各菜单干什么 + +| 菜单 | 路径 | 运营用途 | +|------|------|----------| +| 新手指引 | `/order/guide` | 本文 | +| 我要查价 | `/order/priceCheck` | 试运费、看对客说明 | +| 我要下单 | `/order/placeOrder` | 单笔 / 批量 Excel | +| 我的订单 | `/order/myOrders` | 查状态、费用、付款、拦截 | +| 运单管理 | `/order/waybillManage` | 后台运单处理(按权限) | +| 模板字段映射 | `/order/templateMapping` | 客户 Excel 列怎么对到系统字段 | +| 批量任务 | `/order/batchJobs` | 大批量导入进度与失败行 | +| 店铺授权 / 店铺订单 | `/order/platformStore*` | 平台店拉取与转单(若已开通) | + +仓库侧入库、出库在 **操作** 菜单(入库扫描、出库扫描等)。 + +--- + +## 9. 推荐日常工作流 + +``` +① 新产品上线前:先在「渠道」配齐分区/计重/卖价/排货并发布(见渠道指引) +② 用「我要查价」自测几个国家+重量 +③ 「我要下单」下测试单 → 确认已预报 +④ 仓库入库 → 确认待支付(若待排货则先排障) +⑤ 付款 → 出库 → 轨迹中心看是否上网 +⑥ 客诉对照 §7;费用争议核对预报 vs 入库实测件重尺 +``` + +--- + +## 10. 一句话心智模型 + +> **查价是参考,下单按预报计费,入库按实测改价并自动选渠道;选不了渠道就进「待排货」,不是丢单。** +> 改渠道配置一定要 **发布**,否则订单仍按旧规则算。 diff --git a/src/views/order/orderGuide/guide.md b/src/views/order/orderGuide/guide.md deleted file mode 100644 index a5f882a..0000000 --- a/src/views/order/orderGuide/guide.md +++ /dev/null @@ -1,583 +0,0 @@ -# 订单中心新手指引 - -> 本文面向第一次接触 **订单模块(OMS Order)** 的运营、客服、产品与开发同学。 -> **第三章** 以 `POST /order/parcel/create` 为核心,用流程图拆解 `CreateParcelCoreAsync` 的**业务校验、分区运算、计重算价、排货选渠**全链路。 - ---- - -## 1. 系统是什么? - -**订单模块**负责:查价试算、小包预报(创建订单)、订单全生命周期、Excel 导入、批量异步任务。 - -后端入口:`OrderService`(`Oms.Application.Services.Orders`),Swagger 分组 **OMS-Order**。 - -**心智模型:** 创建订单 = **预报应收流水线** — 绑快照 → 校验地址 → 计重 → 匹配分区 → **仅写应收(forecast)** → 状态 `predeclared`。**排货与渠道买价在入库环节处理**(失败则 `pendingAllocation` 待排货,不阻断收货与应收)。 - -**全链路:** 查价(参考)→ 下单(预报应收)→ 入库(实测 + 应收 + 可选排货)→ 付款 → 出库(发货实测 + outbound 应收)。 - ---- - -## 2. 核心概念速查 - -| 概念 | 说明 | -|------|------| -| **ProductCode** | 客户选的运输方式,对应销售产品 | -| **ChannelCode** | 服务渠道,**系统排货自动写入**,客户不手选 | -| **Rules 快照** | 计重/黑名单/偏远/排货/providers,版本 `SalesProductRulesVersion` | -| **Price 快照** | 卖价矩阵 + 价格分区明细,版本 `SalesProductPriceVersion` | -| **zoneKeys** | 价格分区匹配后生成的「分区键」列表,用于价格矩阵行匹配 | -| **forecast 重量** | 预报阶段计重行,应收费用用它 | -| **downstream 重量** | 下游计重行,**排货应付费用用它**(可与 forecast 相同) | -| **ChargingStage** | `forecast` / `inbound` / `outbound`,费用按阶段区分 | -| **AllocationPendingReason** | 入库排货/渠道买价未匹配时的原因(`pendingAllocation` 状态展示) | - -### 订单状态(生命周期相关) - -| 状态 | 含义 | -|------|------| -| `draft` | 草稿;预报计费失败时保留,并写 `PredeclareFailReason` | -| `predeclared` | **创建成功**后的默认状态(仅预报应收,未排货) | -| `pendingAllocation` | **待排货**:已入库、应收已算,但排货/渠道买价未成功 | -| `pendingPayment` | 待支付(入库排货成功或手动重算应付成功后) | -| `pendingShip` | 待发货(已付款) | -| `pendingOnline` | 待上网(已出库) | - -### 运单号(ServiceNo) - -| 项 | 规则 | -|----|------| -| 前缀 | **`IDE`** | -| 格式 | `IDE` + **`yyMMddHHmmss`**(年份取后两位,如 2026 → `26`)+ 4 位随机数 | -| 示例 | `IDE2607221859123456` | -| 自定义 | 请求体可传 `serviceNo`,须唯一;否则服务端自动生成 | - ---- - -## 3. 创建订单接口全链路(重点) - -### 3.1 API 与代码入口 - -| 项目 | 值 | -|------|-----| -| **HTTP** | `POST /order/parcel/create` | -| **方法** | `OrderService.CreateParcelAsync` → `CreateParcelCoreAsync` | -| **文件** | `OrderService.ParcelBilling.cs` | -| **算价引擎** | `ParcelBillingCalculator.cs` | -| **排货选渠** | `AllocationResolver.cs` | - -以下入口最终都调用同一核心方法: - -| 入口 | API / 动作 | -|------|------------| -| 页面下单 | `POST /order/parcel/create` | -| JSON 批量 | `POST /order/parcel/batch-create`(≤2000) | -| Excel 导入 | `POST /order/upload` → 行映射后调用 | -| MQ Worker | `parcel-create` / `import-row` | - ---- - -### 3.2 总览流程图 - -```mermaid -flowchart TD - A[POST /order/parcel/create] --> B{RequestId 已存在?} - B -->|是| Z1[返回已有订单 BuildParcelCreateOutput] - B -->|否| C[NormalizeParcelCreateInput
清空 TrackNo/ServiceNo] - C --> D[ResolveEffectiveCustomer 解析客户] - D --> E[ValidateParcelCreateInputAsync
业务校验] - E -->|失败| X1[抛 BusinessException
不落库] - E --> F[加载 SalesProduct 须启用] - F -->|失败| X1 - F --> G[ResolveSnapshotBindingAsync
绑 rules+price 快照] - G -->|失败| X1 - G --> H[解析目的国 Country] - H --> I{IsBlacklistHitAsync?} - I -->|命中| X1 - I --> J[生成 ServiceNo IDE+yyMMddHHmmss+随机] - J --> K[Insert t_order 草稿] - K --> L[SaveParcelChildren
收发件/申报/4行重量] - L --> M{预报计费 try} - M --> N[偏远判定 IsRemote] - N --> O[ApplyChargeableWeightAsync 计重] - O --> P[ResolveOrderBillingContext 分区] - P --> Q[UpsertReceivableFeesAsync 应收 forecast] - Q --> S[OrderStatus=predeclared] - S --> T[轨迹 Upsert + 自动触发] - T --> Z2[返回 OrderParcelCreateOutput] - M -->|异常| U[MarkParcelDraftFailedAsync
draft + PredeclareFailReason] - U --> X2[抛 BusinessException] -``` - -> **关键分界:** 黑名单校验在 **Insert 之前**,失败完全不落库;计重/分区/算价/排货在 **Insert 之后** 的 `try` 块内,失败会保留 `draft` 订单并记录失败原因。 - ---- - -### 3.3 阶段一:入参规范化与业务校验(落库前) - -#### 3.3.0 客户身份解析 `ResolveEffectiveCustomerAsync` - -在规范化与校验之前,系统会先解析「这笔单属于哪个客户」: - -```mermaid -flowchart TD - C0[ResolveEffectiveCustomerAsync] --> C1{当前登录身份} - C1 -->|客户账号| C2[忽略入参 CustomerId
强制使用会话客户] - C1 -->|管理员/运营| C3{入参 CustomerId > 0?} - C3 -->|否| CE1[客户ID不能为空] - C3 -->|是| C4[按 CustomerId 查库] - C4 --> C5{客户存在且 Status=Normal?} - C5 -->|否| CE2[客户不存在或状态不可用] - C5 --> C6[写回 input.CustomerId] -``` - -> 客户门户下单时无需手填 `CustomerId`;后台代客下单必须显式传入有效客户 ID。 - -#### 3.3.1 规范化 `NormalizeParcelCreateInput` - -| 字段 | 处理 | -|------|------| -| `TrackNo` | 强制置 `null`(不允许客户端传入) | -| `ServiceNo` | 强制置 `null`(系统生成) | -| `ClientNo` | Trim;空则 `null` | -| `RequestId` | Trim;用于幂等 | - -#### 3.3.2 业务校验 `ValidateParcelCreateInputAsync` - -```mermaid -flowchart TD - V0[开始校验] --> V1{ClientNo 非空?} - V1 -->|否| E1[客户单号不能为空] - V1 --> V2{CustomerId > 0?} - V2 -->|否| E2[客户ID不能为空] - V2 --> V3{同客户 ClientNo 唯一?} - V3 -->|重复| E3[客户单号已存在] - V3 --> V4{ProductCode 非空?} - V4 -->|否| E4[产品代码不能为空] - V4 --> V5{WarehouseId > 0?} - V5 -->|否| E5[仓库ID不能为空] - V5 --> V6{长宽厚实重均 > 0?} - V6 -->|否| E6[件重尺必须大于0] - V6 --> V7{CargoAttribute 合法?} - V7 -->|否| E7[货物属性不合法
general/electronics/sensitive] - V7 --> V8{ExportCustomsMode 合法或空?} - V8 -->|否| E8[出口报关方式不合法] - V8 --> V9{DeclaredCurrency 非空?} - V9 -->|否| E9[申报币种不能为空] - V9 --> V10{Quantity > 0?} - V10 -->|否| E10[包裹数量不能为空] - V10 --> V11{收件人完整?} - V11 -->|否| E11[收件人信息不完整
姓名/国家/邮编/地址1] - V11 --> V12{发件人姓名非空?} - V12 -->|否| E12[发件人姓名不能为空] - V12 --> V13{申报明细 Items 非空?} - V13 -->|否| E13[申报明细不能为空] - V13 --> V14[逐行校验申报品
EnName/UnitPrice/UnitWeight/Quantity] - V14 --> V15{仓库存在?} - V15 -->|否| E15[仓库不存在] - V15 --> V16{客户存在且状态正常?} - V16 -->|否| E16[客户不存在或状态不可用] - V16 --> OK[校验通过] -``` - -| 校验项 | 规则 | 失败文案 | -|--------|------|----------| -| 幂等 | `RequestId` 已有订单则直接返回 | — | -| 客户单号 | 非空;同 `CustomerId` 下唯一 | 客户单号不能为空 / 已存在 | -| 产品 | `ProductCode` 非空 | 产品代码不能为空 | -| 仓库 | `WarehouseId > 0` 且记录存在 | 仓库ID不能为空 / 仓库不存在 | -| 件重尺 | 长/宽/高/实重均 > 0 | 件重尺必须大于0 | -| 货物属性 | `general` / `electronics` / `sensitive` | 货物属性不合法 | -| 申报 | 币种、数量、明细行必填完整 | 见上 | -| 收件人 | 姓名、国家、邮编、地址1 | 收件人信息不完整 | -| 客户 | 存在且 `Status=Normal` | 客户不存在或状态不可用 | - -#### 3.3.3 产品与快照(仍在落库前) - -```mermaid -flowchart TD - P1[GetByProductCode] --> P2{产品存在且 IsActive?} - P2 -->|否| E[销售产品不存在或未启用] - P2 --> P3[ResolveSnapshotBindingAsync bindTime=Now] - P3 --> P4[取 rules 生效发布记录] - P3 --> P5[取 price 生效发布记录] - P4 -->|无| P4L[BuildLiveRulesSnapshot 内存拼装] - P4 -->|有| P4S[反序列化 rules Content] - P5 -->|无| P5L[BuildLivePriceSnapshot] - P5 -->|有| P5S[反序列化 price Content] - P5L --> P5C{Prices.Count>0?} - P5C -->|否| E2[未命中价格快照版本] - P4L --> P4R{WeightRuleId 有值?} - P4R -->|否| E3[未命中规则快照版本] - P3 --> P6[写入绑定版本号
RulesVersion / PriceVersion] - P6 --> P7[解析目的国 CountryCode] - P7 --> P8{黑名单命中?} - P8 -->|是| E4[收件地址命中黑名单区域] -``` - -**快照选取规则:** `PublishTime <= bindTime` 且 `Status=生效` 的**最新**发布记录;无记录则 `LIVE-{productId}`,价格侧必须有启用价格项,规则侧 LIVE 时必须有 `WeightRuleId`。 - ---- - -### 3.4 阶段二:落库草稿 - -| 步骤 | 动作 | 写入表/字段 | -|------|------|-------------| -| 1 | `ResolveServiceNoAsync` | 运单号 **`IDE{yyMMddHHmmss}{4位随机}`** | -| 2 | `BuildDraftOrderEntity` | `t_order`:产品、国家、快照版本、初始 `draft`;**创建阶段不写 ChannelCode 排货结果** | -| 3 | `BuildInitialWeightRows` | `t_order_weight` 四行:`forecast` / `receive` / `ship` / `downstream` | -| 4 | `SaveParcelChildren` | `t_order_sender`、`t_order_recipient`、`t_order_declaration` | - -**初始重量行:** 四行复制同一套长宽厚实重;`Cubic = L×W×H/1,000,000`;后续计重结果通过 `SyncWeightRows` 同步到全部四行。 - ---- - -### 3.5 阶段三:预报计费(try 块内) - -#### 3.5.1 子流程总图 - -```mermaid -flowchart TD - T1[偏远 IsRemoteHitAsync] --> T2[ApplyChargeableWeightAsync
仅算 forecast 行] - T2 --> T3[SyncWeightRows 同步四行重量] - T3 --> T4[ResolvePriceZoneKeysAsync] - T4 --> T5{zoneKeys 非空?} - T5 -->|否| E1[价格分区未匹配] - T5 --> T6[UpsertReceivableFeesAsync
ChargingStage=forecast] - T6 --> T8[status=predeclared] -``` - -> **创建阶段不算应付、不排货。** 渠道与买价在 **入库** `ApplyReceiveBillingByWaybillAsync` 中通过 `TryConfirmServiceChannelAndPayableAsync` 尝试;失败写入 `AllocationPendingReason`,订单进 **`pendingAllocation`**,仍保留入库实测与 inbound 应收。 - ---- - -#### 3.5.2 偏远判定 `IsRemoteHitAsync` - -与黑名单逻辑对称,但**不阻断创建**,只写 `order.IsRemote`: - -1. 解析有效 `RemoteAreaId`(产品 → 规则快照 → 快照内 Product); -2. 优先用快照内 `RemoteAreaInfos` 匹配; -3. 否则查库 `t_kpi_area_info`; -4. 匹配规则:国/省/市相等 + 邮编区间 **左开右闭** `(left, right]`。 - ---- - -#### 3.5.3 计重运算 `ApplyChargeableWeightAsync` - -```mermaid -flowchart TD - W1[取 WeightRuleId] --> W2{有计重规则?} - W2 -->|否| E1[未配置计重规则] - W2 --> W3[加载规则明细 ruleInfos
优先快照 WeightRuleInfos] - W3 --> W4[CalculateRawBillableWeight] - W4 --> W5[逐条规则按 Id 排序] - W5 --> W6[体积重 vw = L×W×H / 泡比因子] - W6 --> W7{Condition 命中?
gw/vw 表达式} - W7 -->|否| W5 - W7 -->|是| W8[raw = BillableWeight 为 gw 或 vw] - W8 --> W9[ApplyRounding 进位] - W9 --> W10[命中进位区间 Start~End] - W10 --> W11[ChargeableWeight = ceil raw/unit × unit] - W11 --> W12[写 forecast.VolumetricWeight / ChargeableWeight] -``` - -| 运算 | 公式/规则 | -|------|-----------| -| 体积 | `Cubic = L×W×H / 1,000,000`(保留 6 位) | -| 体积重 | `vw = L×W×H / VolumetricFactor`(保留 3 位) | -| 条件 | `Condition` 表达式,变量 `gw`/`vw`,如 `gw>=vw` | -| 原始计费重 | `BillableWeight=gw` 取实重,否则取体积重 | -| 进位 | 找 `StartValue ≤ raw ≤ EndValue` 的区间,`ceil(raw/Public)×Public` | - -**失败文案:** 未配置计重规则 / 计重进位 / 计重规则条件均未命中 / 计重进位区间未匹配。 - ---- - -#### 3.5.4 价格分区匹配 `ResolvePriceZoneKeysAsync` - -```mermaid -flowchart TD - Z1{price 快照含 PriceAreaInfos?} - Z1 -->|是| Z2[快照内逐条匹配国省市邮编] - Z1 -->|否| Z3[ResolveEffectivePriceAreaId] - Z3 --> Z4[查 t_kpi_area + info 明细] - Z2 --> Z5{命中一条?} - Z4 --> Z5 - Z5 -->|否| Z6[返回 null → 创建失败] - Z5 --> Z7[BuildPriceZoneKeys] - Z7 --> Z8[生成 zoneKeys 列表
分区名/Id/省/市等] -``` - -**地址匹配规则(黑名单/偏远/价格分区通用):** - -| 维度 | 规则 | -|------|------| -| 国家 | 配置了则必须相等(忽略大小写) | -| 省/市 | 配置了则必须相等 | -| 邮编 | 配置了左右端点则必须落在区间内;数字按数值比,否则按字符串比 | -| 邮编区间 | **左开右闭** `(left, right]` | - -**失败文案:** `价格分区未匹配(规则快照)` - ---- - -#### 3.5.5 应收费用 `UpsertReceivableFeesAsync` - -输入:`price 快照`、`forecast.ChargeableWeight`、`zoneKeys`、`CargoAttribute`、`CustomerGroup`、`chargingStage=forecast`。 - -```mermaid -flowchart TD - R1[遍历卖价快照中 Status=1 的价格项] --> R2[MatchPriceDetail 匹配明细行] - R2 --> R3{分区因素匹配 zoneKeys?} - R3 --> R4{货物属性匹配?} - R4 --> R5{仓库因素匹配?} - R5 --> R6{重量区间匹配?
左开右闭} - R6 -->|命中| R7[CalculateDetailAmount] - R7 --> R8{公式类型} - R8 -->|total| R9[客户组价或 CalculatedValue] - R8 -->|unit| R10[单价 × 计费重] - R8 -->|continue| R11[首重价 + 续重 × 续单价] - R9 --> R12[写入 OrderFee type=receivable] - R10 --> R12 - R11 --> R12 - R2 -->|无匹配| R1 - R12 --> R13{有费用行 或 无启用项?} - R13 -->|有启用项但零匹配| E[产品卖价快照未配置或未匹配到有效价格] -``` - -| 价格明细匹配 | 说明 | -|--------------|------| -| 分区因素 | `FactorValuesJson` 中「分区」须与 `zoneKeys` 任一相等 | -| 货物属性 | 因素含货物类型时须匹配 `general/electronics/sensitive` | -| 重量段 | `(start, end]`,多段命中取**区间最窄**的一条 | -| 客户组价 | `CustomerGroupValues` 按客户组取价,否则 `CalculatedValue` | - -#### 3.5.5.1 价格明细匹配 `MatchPriceDetail`(应收/应付共用) - -```mermaid -flowchart TD - M0[遍历价格项全部 Details] --> M1{分区因素与 zoneKeys 相交?} - M1 -->|否| M0 - M1 --> M2{货物类型因素匹配?
无因素则跳过} - M2 -->|否| M0 - M2 --> M3{仓库因素匹配?
创建订单时通常无仓库因素} - M3 -->|否| M0 - M3 --> M4{明细含重量因素?} - M4 -->|否| M5[暂记为候选 bestMatch] - M4 -->|是| M6[解析重量区间 左开右闭] - M6 --> M7{chargeableWeight 在区间内?} - M7 -->|否| M0 - M7 --> M8[区间宽度 = end - start] - M8 --> M9{比当前 best 更窄?} - M9 -->|是| M10[更新 bestMatch] - M9 -->|否| M0 - M5 --> M0 - M10 --> M0 - M0 -->|遍历结束| M11[返回 bestMatch 或 null] -``` - -**重量区间解析规则:** - -| 配置写法 | 解析结果 | 示例 | -|----------|----------|------| -| 单值 `0.5` | `(0, 0.5]` | 0.5kg 以内 | -| 区间 `0,0.5` 或 `0-0.5` | `(0, 0.5]` | 同上 | -| 区间 `0.5,1` | `(0.5, 1]` | 0.5kg 以上至 1kg | - -#### 3.5.5.2 金额计算公式 `CalculateDetailAmount` - -匹配到明细行后,按 `CalculationFormula` 计算金额: - -```mermaid -flowchart TD - F0[命中明细行] --> F1{CalculationFormula} - F1 -->|total| F2[客户组价 ?? CalculatedValue] - F1 -->|unit| F3[单价 × 计费重
round 4 位小数] - F1 -->|continue| F4{能关联上一档 total 价?} - F4 -->|是| F5[basePrice + 计费重-区间起点 × 续单价] - F4 -->|否| F6[首重价 + ceil溢出/续重单位 × 续单价] - F1 -->|其他| F7[CalculatedValue 或 0] -``` - -| 公式类型 | 计算逻辑 | 展示公式示例 | -|----------|----------|--------------| -| `total` | 固定总价;优先读 `CustomerGroupValues[客户组]` | `12.5` | -| `unit` | `单价 × ChargeableWeight` | `3.2*1.5` | -| `continue`(关联上档) | 找同分区上一重量档的 `total` 价为 base,超出部分 × 续单价 | `10+(1.5-0.5)*8` | -| `continue`(单行首续重) | `首重价 + ceil((计费重-首重)/续重单位) × 续单价` | `10+2*5` | - -> **应收**遍历卖价快照全部 `Status=1` 价格项,每项独立匹配明细;**应付**对渠道买价快照做同样逻辑。若存在启用项但全部匹配失败且金额为 0,则抛错。 - ---- - -#### 3.5.6 入库排货与应付(创建时不执行) - -排货逻辑在 **入库收货** 时调用 `TryConfirmServiceChannelAndPayableAsync`(失败不抛异常): - -```mermaid -flowchart TD - A1[入库实测件重尺 receive 行] --> A2[重算 inbound 应收] - A2 --> A3[TryConfirmServiceChannelAndPayableAsync] - A3 -->|成功| A4[OrderStatus=pendingPayment
清空 AllocationPendingReason] - A3 -->|失败| A5[OrderStatus=pendingAllocation
写 AllocationPendingReason] -``` - -| Scheme | 选渠规则 | -|--------|----------| -| `costLowest`(默认) | 应付总额最小,同价按序号 | -| `timeFastest` | 渠道 `MinTime` 最小 | -| `overallBest` | 服务商 `Weight`(评分)最大 | - -**注意:** 入库排货应付使用 **`receive` 行**的 `ChargeableWeight`(实测后重算)。手动「重算应付」成功也会清原因并改为 `pendingPayment`。 - -**写入订单字段(排货成功时):** `ChannelCode`、`DownstreamSupplierCode`、`DownstreamAppKey`、`ChannelForecastCode` 等。 - ---- - -### 3.6 阶段四:成功收尾与失败处理 - -#### 成功 - -| 步骤 | 说明 | -|------|------| -| 状态 | `OrderStatus = predeclared`,清空 `PredeclareFailReason` | -| 轨迹 | `EnqueueSmallBagTrackUpsertAsync` 异步小包轨迹 | -| 触发 | `AutoTriggerRuntimeService.FireAsync(OrderCreate)`,失败**不影响**主流程 | -| 返回 | `OrderParcelCreateOutput`(运单号、费用摘要等) | - -#### 失败 `MarkParcelDraftFailedAsync` - -| 场景 | 订单状态 | 字段 | -|------|----------|------| -| 计重/分区/应收/排货任一抛错 | 保持 `draft` | `PredeclareFailReason = 异常信息` | - -> 落库前校验失败(黑名单、快照缺失等):**不 Insert**,直接 `BusinessException`。 -> 落库后计费失败:订单已存在,状态 `draft`,便于排查与重试。 - ---- - -### 3.7 创建过程数据流一览 - -```mermaid -flowchart LR - subgraph Input - I1[OrderParcelCreateInput] - end - subgraph Snapshot - S1[Rules Snapshot] - S2[Price Snapshot] - S3[Channel Price Snapshot] - end - subgraph Compute - C1[ChargeableWeight] - C2[zoneKeys] - C3[Receivable Fees] - C4[Payable Fees] - end - subgraph DB - D1[t_order] - D2[t_order_weight x4] - D3[t_order_fee] - end - I1 --> S1 - I1 --> S2 - S1 --> C1 - C1 --> C2 - S2 --> C2 - C2 --> C3 - S1 --> C4 - S3 --> C4 - C3 --> D3 - C4 --> D3 - I1 --> D1 - I1 --> D2 - C1 --> D2 -``` - ---- - -### 3.8 常见失败原因速查(创建/预报) - -| 报错关键词 | 排查方向 | -|------------|----------| -| 销售产品不存在或未启用 | 渠道 → 销售产品状态 | -| 未命中价格/规则快照 | 是否发布;LIVE 时是否有价格项/计重规则 | -| 收件地址命中黑名单 | 黑名单分区明细 + 产品关联 | -| 价格分区未匹配 | 价格分区是否覆盖目的邮编/省市 | -| 未配置计重规则/进位 | 产品配置 + 规则发布快照 | -| 计重规则条件均未命中 | 件重尺与规则 Condition | -| 卖价未匹配到有效价格 | 卖价矩阵分区/重量段/货物属性 | -| 卖价未匹配到有效价格 | 卖价矩阵分区/重量段/货物属性 | -| 入库排货失败 | 订单为 **待排货**,查 `AllocationPendingReason`;补排货规则/渠道买价后重算应付 | -| 客户单号已存在 | 同客户重复下单 | - ---- - -## 4. 查价 vs 创建 vs 入库 vs 出库 - -### 4.1 环节对比 - -| 对比 | 查价 | 下单(预报) | 入库 | 出库 | -|------|------|--------------|------|------| -| 接口 | `POST /order/price-check` | `POST /order/parcel/create` | `POST /operation/inbound/create` | `POST /operation/outbound/create` | -| 落库 | 否 | 是 | 写入库记录 + 订单 | 写出库记录 + 订单 | -| 尺寸 | **可选**(全空按实重估算) | **必填** 长/宽/高/实重 > 0 | 实测必填 | 实测必填 | -| 邮编/黑名单 | 无邮编则**跳过**黑名单/偏远 | **必填邮编**,黑名单**硬拦截** | 用订单已有地址 | — | -| 计费 | 参考价,`queryHint` + `quoteLevel` | 仅 **forecast 应收** | **inbound 应收** + 可选排货应付 | **outbound 应收** | -| 排货 | 不执行 | **不执行** | 可选;失败 → `pendingAllocation` | 不执行 | -| 状态 | — | `predeclared` | `pendingPayment` 或 `pendingAllocation` | `pendingOnline`(须已付款) | - -### 4.2 查价宽松规则(对客) - -- **必填**:目的国 + 实重 > 0。 -- **尺寸**:全空或三者齐全;部分填写会报错。 -- **未填邮编**:不校验黑名单;分区无匹配时可 **zone 兜底**(取最低价分区,标记为估算)。 -- **未选货物属性/仓库**:按 **全部启用产品 × 全部仓库 × 货物类型展开**(general/electronics/sensitive)试算。 -- 响应 **`queryHint.disclaimers`**、每行 **`quoteLevel`**(`exact` / `estimated`)提示「仅供参考,入库实测后可能调整」。 - -### 4.3 共用校验 `ParcelBillingValidators` - -| 方法 | 用途 | -|------|------| -| `EnsurePriceCheckDimensions` | 查价:尺寸全空或齐全 | -| `EnsurePositiveDimensions` | 下单/入库/出库:件重尺均 > 0 | -| `EnsureInboundOrderStatus` | 入库:仅 `predeclared` / `pendingAllocation` | - ---- - -## 5. 其他菜单(简表) - -| 菜单 | 路径 | 功能 | -|------|------|------| -| 新手指引 | `/order/guide` | 本文档 | -| 我要查价 | `/order/priceCheck` | 运费试算 | -| 我要下单 | `/order/placeOrder` | 单笔/Excel 导入 | -| 我的订单 | `/order/myOrders` | 列表/付款/拦截/费用 | -| 模板字段映射 | `/order/templateMapping` | 自定义 Excel 列映射 | -| 批量任务 | `/order/batchJobs` | MQ 批处理监控 | - ---- - -## 6. 后端代码索引 - -| 主题 | 文件 / 方法 | -|------|-------------| -| 创建主流程 | `OrderService.ParcelBilling.cs` → `CreateParcelCoreAsync` | -| 入参校验 | `ValidateParcelCreateInputAsync` + `ParcelBillingValidators` | -| 快照绑定 | `ResolveSnapshotBindingAsync` / `ResolveOrderBillingContextAsync` | -| 计重 | `ApplyChargeableWeightAsync` + `ParcelBillingCalculator` | -| 应收 | `UpsertReceivableFeesAsync` | -| 入库排货 | `ApplyReceiveBillingByWaybillAsync` → `TryConfirmServiceChannelAndPayableAsync` | -| 出库 | `ApplyShipBillingByWaybillAsync` | -| 查价 | `OrderService.ParcelQuote.cs` + `PriceCheckHintBuilder` | -| 运单号 | `ResolveServiceNoAsync` → `IDE{yyMMddHHmmss}{随机}` | -| API 路由 | `OrderService.cs` → `CreateParcelAsync` | - ---- - -## 7. 推荐阅读顺序 - -1. 本文 **§3 创建流程** + **§4 全链路对比**; -2. **渠道新手指引**(快照发布、排货配置、渠道买价); -3. 用 **我要查价** 看参考价,再 **我要下单** 下测试单; -4. 运营 **入库/出库** 扫描,在「我的订单」核对状态与费用; -5. 预报失败对照 **§3.8** 与 `PredeclareFailReason`;待排货对照 `AllocationPendingReason`。 diff --git a/src/views/order/orderGuide/index.vue b/src/views/order/orderGuide/index.vue index fb25403..8a12df2 100644 --- a/src/views/order/orderGuide/index.vue +++ b/src/views/order/orderGuide/index.vue @@ -1,6 +1,7 @@ - - diff --git a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue index 09fe03b..fa3e3eb 100644 --- a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue +++ b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue @@ -248,6 +248,18 @@ const submitOkText = computed(() => { return t('order.platformStoreOrder.createLogistics.submit') }) +/** 打开即固定高度,避免 loading→内容 从小变大 */ +const modalBodyStyle = computed(() => { + const h = isFulfill.value ? 'calc(100vh - 168px)' : 'min(680px, calc(100vh - 168px))' + return { + padding: '12px 16px', + height: h, + maxHeight: h, + overflow: 'hidden', + boxSizing: 'border-box' as const, + } +}) + async function onFooterOk() { const ok = await handleSubmit() if (ok) close() @@ -1045,16 +1057,15 @@ watch(inboundMethod, (method) => { :title="title" :width="isFulfill ? '1360px' : '1080px'" :top="isFulfill ? '20px' : '6vh'" + modal-class="psc-create-logistics-modal" unmount-on-close - :body-style="{ - padding: '12px 16px', - maxHeight: isFulfill ? 'calc(100vh - 140px)' : 'calc(100vh - 160px)', - overflow: isFulfill ? 'hidden' : 'auto', - }" + :body-style="modalBodyStyle" @cancel="close" > -
{{ t('common.loading') }}
-
+
+
+ {{ t('common.loading') }} +

{{ isFulfillWms @@ -1779,18 +1790,26 @@ watch(inboundMethod, (method) => { diff --git a/src/views/site/GradientWaves.vue b/src/views/site/GradientWaves.vue new file mode 100644 index 0000000..6725dc5 --- /dev/null +++ b/src/views/site/GradientWaves.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/src/views/site/SiteAdvisorShell.vue b/src/views/site/SiteAdvisorShell.vue index 8439d57..ea6bac0 100644 --- a/src/views/site/SiteAdvisorShell.vue +++ b/src/views/site/SiteAdvisorShell.vue @@ -1,9 +1,33 @@