Merge pull request 'feat: 官网海洋蓝波浪首页与指南分受众,并同步业务页优化' (#9) from test into master
All checks were successful
Build and Deploy Front / deploy (push) Successful in 1m43s

Reviewed-on: #9
This commit is contained in:
zzh 2026-10-21 18:18:26 +08:00
commit 3661c0e86e
53 changed files with 4963 additions and 1924 deletions

View File

@ -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",

8
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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 = {

1
src/components.d.ts vendored
View File

@ -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']

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import { ref } from 'vue';
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
defineOptions({
name: 'GuideAudienceTabs',
});
defineProps<{
opsContent: string;
devContent: string;
}>();
const activeTab = ref<'ops' | 'dev'>('ops');
</script>
<template>
<div class="guide-audience-page">
<div class="guide-audience-card">
<a-tabs v-model:active-key="activeTab" type="rounded" class="guide-audience-tabs">
<a-tab-pane key="ops" title="运营">
<div class="guide-pane">
<MarkdownViewer :content="opsContent" />
</div>
</a-tab-pane>
<a-tab-pane key="dev" title="开发">
<div class="guide-pane">
<MarkdownViewer :content="devContent" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<style lang="less" scoped>
.guide-audience-page {
height: 100%;
width: 100%;
padding: 16px;
background: var(--app-page-bg);
overflow: hidden;
box-sizing: border-box;
}
.guide-audience-card {
height: 100%;
background: var(--app-card-bg);
border-radius: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-audience-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:deep(.arco-tabs-nav) {
padding: 8px 16px 0;
flex-shrink: 0;
}
:deep(.arco-tabs-content) {
flex: 1;
min-height: 0;
padding-top: 0;
}
:deep(.arco-tabs-content-list),
:deep(.arco-tabs-pane) {
height: 100%;
}
:deep(.arco-tabs-pane) {
overflow: hidden;
}
}
.guide-pane {
height: 100%;
min-height: 0;
overflow: auto;
padding: 8px 24px 24px;
box-sizing: border-box;
}
</style>

View File

@ -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) {
@ -2046,6 +2060,7 @@
};
removeFilterIconFromColumns();
syncHybridHeaders();
applySelectedRowStyles();
remasureActionColumnFromDom();
// remasure/updateSettings 后 HOT 会按原生滚动条再次缩短 clone,需再对齐一次
scheduleCloneOverlaySync();
@ -2443,15 +2458,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);
});
};

View File

@ -968,6 +968,7 @@ const pathToRouteNameMap: Record<string, string> = {
'/channel/autoTrigger': 'AutoTrigger',
'/channel/issueTemplate': 'IssueTemplate',
'/channel/guide': 'ChannelGuide',
'/marketing/guide': 'MarketingGuide',
'/order/guide': 'OrderGuide',
// Track 相关路由
'/track/guide': 'TrackGuide',

View File

@ -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.',
@ -1058,6 +1059,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',

View File

@ -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 折。',
@ -1047,6 +1048,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': '商品明细',

View File

@ -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',

View File

@ -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,
};
}

View File

@ -119,6 +119,7 @@ export const PATH_TO_LOCALE_KEY: Record<string, string> = {
'/track/guide': 'menu.track.trackGuide',
'/order/guide': 'menu.order.orderGuide',
'/channel/guide': 'menu.channel.channelGuide',
'/marketing/guide': 'menu.marketing.marketingGuide',
}
type MenuIconItem = {

View File

@ -114,6 +114,10 @@ const routeNameMap: Record<string, string> = {
'/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',

View File

@ -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 只进调试面板,**不会**写回下游接口配置;改配置请走智能填充 + 保存发布。
---

View File

@ -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. 一句话心智模型
> **上游是公司普通话,下游是服务商方言;映射是翻译器,发布才上岗。**
> 排错先看 **请求日志里真正发出的那一包**,再回头改树和脚本。

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import guideContent from './guide.md?raw';
import GuideAudienceTabs from '@/components/GuideAudienceTabs/index.vue';
import opsContent from './guide-ops.md?raw';
import devContent from './guide-dev.md?raw';
defineOptions({
name: 'ApiDebugger',
@ -8,41 +9,5 @@
</script>
<template>
<div class="api-debugger-page">
<div class="api-debugger-card">
<div class="guide-pane">
<MarkdownViewer :content="guideContent" />
</div>
</div>
</div>
<GuideAudienceTabs :ops-content="opsContent" :dev-content="devContent" />
</template>
<style lang="less" scoped>
.api-debugger-page {
height: 100%;
width: 100%;
padding: 16px;
background: var(--app-page-bg);
overflow: hidden;
box-sizing: border-box;
}
.api-debugger-card {
height: 100%;
background: var(--app-card-bg);
border-radius: 4px;
border: 1px solid var(--color-border-2);
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-pane {
height: 100%;
min-height: 0;
overflow: auto;
padding: 8px 24px 24px;
box-sizing: border-box;
}
</style>

View File

@ -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 | 排货列表、渠道启用、买价发布、公式条件、客户限制 |

View File

@ -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. 一句话心智模型
> **销售产品决定客户怎么买、卖多少钱;服务渠道决定货怎么走、成本多少;排货把两者在入库时接上。**
> **改配置不发布 = 等于没改。**

View File

@ -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` |

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import guideContent from './guide.md?raw';
import GuideAudienceTabs from '@/components/GuideAudienceTabs/index.vue';
import opsContent from './guide-ops.md?raw';
import devContent from './guide-dev.md?raw';
defineOptions({
name: 'ChannelGuide',
@ -8,40 +9,5 @@
</script>
<template>
<div class="channel-guide-page">
<div class="channel-guide-card">
<div class="guide-pane">
<MarkdownViewer :content="guideContent" />
</div>
</div>
</div>
<GuideAudienceTabs :ops-content="opsContent" :dev-content="devContent" />
</template>
<style lang="less" scoped>
.channel-guide-page {
height: 100%;
width: 100%;
padding: 16px;
background: var(--app-page-bg);
overflow: hidden;
box-sizing: border-box;
}
.channel-guide-card {
height: 100%;
background: var(--app-card-bg);
border-radius: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 24px;
box-sizing: border-box;
}
</style>

View File

@ -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)',

View File

@ -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': '邀请链接(微信内打开)',

View File

@ -211,7 +211,7 @@
</div>
<a-spin :loading="queryLoading" class="h5-price-spin">
<div v-if="!sortedResults.length && !queryLoading" class="h5-empty">
<div v-if="!sortedResults.length && !queryLoading" class="h5-empty h5-empty--no-quote">
<div class="h5-empty__icon" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<circle cx="28" cy="28" r="14" fill="none" stroke="currentColor" stroke-width="3" />
@ -219,8 +219,86 @@
<path d="M22 28h12M28 22v12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
</div>
<p>{{ queryFailureText || t('h5.price.noResult') }}</p>
<a-button type="outline" size="small" @click="askAdvisorEmpty">
<div class="h5-empty__badge">{{ t('h5.price.noResultTitle') }}</div>
<p class="h5-empty__title">
{{
queryFailureItems.length
? t('h5.price.noResultWithFailures', { count: queryFailureItems.length })
: queryFailureText || t('h5.price.noResult')
}}
</p>
<p class="h5-empty__hint">{{ t('h5.price.noResultHint') }}</p>
<div v-if="queryFailureGroups.length" class="h5-failure">
<button
type="button"
class="h5-failure__toggle"
:aria-expanded="failureExpanded"
@click="failureExpanded = !failureExpanded"
>
<span>
{{
failureExpanded
? t('h5.price.failureHide')
: t('h5.price.failureShow', {
productCount: queryFailureGroups.length,
itemCount: queryFailureItems.length,
})
}}
</span>
<icon-down class="h5-failure__chevron" :class="{ 'is-open': failureExpanded }" />
</button>
<ol v-show="failureExpanded" class="h5-failure__list">
<li
v-for="(group, gi) in queryFailureGroups"
:key="group.key"
class="h5-failure__product"
>
<div class="h5-failure__product-head">
<span class="h5-failure__index">{{ gi + 1 }}</span>
<div class="h5-failure__product-title">
<strong>{{
group.productName ||
group.productCode ||
t('order.priceCheck.guide.failureUnknownProduct')
}}</strong>
<span
v-if="group.productCode && group.productName"
class="h5-failure__code"
>
{{ group.productCode }}
</span>
</div>
</div>
<ul class="h5-failure__reasons">
<li v-for="(item, ii) in group.items" :key="`${group.key}-${ii}`">
<div class="h5-failure__meta">
<span v-if="item.cargoTypeName">
{{ t('order.priceCheck.guide.failureCargo', { name: item.cargoTypeName }) }}
</span>
<span v-if="item.warehouseName">
{{
t('order.priceCheck.guide.failureWarehouse', {
name: item.warehouseName,
})
}}
</span>
<span v-if="item.step" class="h5-failure__step">
{{
t('order.priceCheck.guide.failureStepLabel', {
step: failureStepLabel(item.step),
})
}}
</span>
</div>
<p class="h5-failure__reason">{{ item.reason }}</p>
</li>
</ul>
</li>
</ol>
</div>
<a-button size="small" class="h5-empty__action" @click="askAdvisorEmpty">
{{ t('h5.price.askAdvisor') }}
</a-button>
</div>
@ -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<PriceCheckFailureItem[]>([]);
const failureExpanded = ref(false);
const results = ref<any[]>([]);
const queryHint = ref<PriceCheckQueryHint | null>(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;
}

View File

@ -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` 排除已有营销抵扣行 |
| 支付预览 | 券优惠 ≤ 会员折时标不可用 |

View File

@ -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. 一句话心智模型
> **客户多付基础运费 → 攒滚动积分升会员 → 基础运费更便宜;**
> **券和会员折只能选更划算的那个;**
> **拉新靠「派送成功」计奖,钱进运费余额,附加费全程不参与。**

View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
import GuideAudienceTabs from '@/components/GuideAudienceTabs/index.vue';
import opsContent from './guide-ops.md?raw';
import devContent from './guide-dev.md?raw';
defineOptions({
name: 'MarketingGuide',
});
</script>
<template>
<GuideAudienceTabs :ops-content="opsContent" :dev-content="devContent" />
</template>

View File

@ -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<br/>强制会话客户]
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/*` |

View File

@ -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. 一句话心智模型
> **查价是参考,下单按预报计费,入库按实测改价并自动选渠道;选不了渠道就进「待排货」,不是丢单。**
> 改渠道配置一定要 **发布**,否则订单仍按旧规则算。

View File

@ -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<br/>清空 TrackNo/ServiceNo]
C --> D[ResolveEffectiveCustomer 解析客户]
D --> E[ValidateParcelCreateInputAsync<br/>业务校验]
E -->|失败| X1[抛 BusinessException<br/>不落库]
E --> F[加载 SalesProduct 须启用]
F -->|失败| X1
F --> G[ResolveSnapshotBindingAsync<br/>绑 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<br/>收发件/申报/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<br/>draft + PredeclareFailReason]
U --> X2[抛 BusinessException]
```
> **关键分界:** 黑名单校验在 **Insert 之前**,失败完全不落库;计重/分区/算价/排货在 **Insert 之后** 的 `try` 块内,失败会保留 `draft` 订单并记录失败原因。
---
### 3.3 阶段一:入参规范化与业务校验(落库前)
#### 3.3.0 客户身份解析 `ResolveEffectiveCustomerAsync`
在规范化与校验之前,系统会先解析「这笔单属于哪个客户」:
```mermaid
flowchart TD
C0[ResolveEffectiveCustomerAsync] --> C1{当前登录身份}
C1 -->|客户账号| C2[忽略入参 CustomerId<br/>强制使用会话客户]
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[货物属性不合法<br/>general/electronics/sensitive]
V7 --> V8{ExportCustomsMode 合法或空?}
V8 -->|否| E8[出口报关方式不合法]
V8 --> V9{DeclaredCurrency 非空?}
V9 -->|否| E9[申报币种不能为空]
V9 --> V10{Quantity > 0?}
V10 -->|否| E10[包裹数量不能为空]
V10 --> V11{收件人完整?}
V11 -->|否| E11[收件人信息不完整<br/>姓名/国家/邮编/地址1]
V11 --> V12{发件人姓名非空?}
V12 -->|否| E12[发件人姓名不能为空]
V12 --> V13{申报明细 Items 非空?}
V13 -->|否| E13[申报明细不能为空]
V13 --> V14[逐行校验申报品<br/>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[写入绑定版本号<br/>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<br/>仅算 forecast 行]
T2 --> T3[SyncWeightRows 同步四行重量]
T3 --> T4[ResolvePriceZoneKeysAsync]
T4 --> T5{zoneKeys 非空?}
T5 -->|否| E1[价格分区未匹配]
T5 --> T6[UpsertReceivableFeesAsync<br/>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<br/>优先快照 WeightRuleInfos]
W3 --> W4[CalculateRawBillableWeight]
W4 --> W5[逐条规则按 Id 排序]
W5 --> W6[体积重 vw = L×W×H / 泡比因子]
W6 --> W7{Condition 命中?<br/>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 列表<br/>分区名/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{重量区间匹配?<br/>左开右闭}
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{货物类型因素匹配?<br/>无因素则跳过}
M2 -->|否| M0
M2 --> M3{仓库因素匹配?<br/>创建订单时通常无仓库因素}
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[单价 × 计费重<br/>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<br/>清空 AllocationPendingReason]
A3 -->|失败| A5[OrderStatus=pendingAllocation<br/>写 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`。

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import guideContent from './guide.md?raw';
import GuideAudienceTabs from '@/components/GuideAudienceTabs/index.vue';
import opsContent from './guide-ops.md?raw';
import devContent from './guide-dev.md?raw';
defineOptions({
name: 'OrderGuide',
@ -8,40 +9,5 @@
</script>
<template>
<div class="order-guide-page">
<div class="order-guide-card">
<div class="guide-pane">
<MarkdownViewer :content="guideContent" />
</div>
</div>
</div>
<GuideAudienceTabs :ops-content="opsContent" :dev-content="devContent" />
</template>
<style lang="less" scoped>
.order-guide-page {
height: 100%;
width: 100%;
padding: 16px;
background: var(--app-page-bg);
overflow: hidden;
box-sizing: border-box;
}
.order-guide-card {
height: 100%;
background: var(--app-card-bg);
border-radius: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 24px;
box-sizing: border-box;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -82,7 +82,15 @@ const fulfillStatus = ref<string>(resolveFulfillStatusFromQuery())
const platformCode = ref<string>(resolvePlatformFromQuery())
const keyword = ref('')
const summary = ref<Record<string, number>>({})
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
type WarehouseOption = {
label: string
value: number
recipient?: string
contactPhone?: string
shippingAddress?: string
remark?: string
}
const warehouseOptions = ref<WarehouseOption[]>([])
const warehouseNameMap = computed(() => buildWarehouseNameMap(warehouseOptions.value))
const productOptions = ref<Array<{ label: string; value: string }>>([])
const wmsProductOptions = ref<Array<{ label: string; value: string }>>([])
@ -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
})

View File

@ -0,0 +1,495 @@
<template>
<div ref="containerRef" class="gradient-waves" aria-hidden="true" />
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { Renderer, Program, Mesh, Triangle } from 'ogl';
export type GradientWavesDetail = 'low' | 'medium' | 'high';
interface GradientWavesProps {
horizonColor?: string;
waveColor?: string;
crestColor?: string;
speed?: number;
amplitude?: number;
waveScale?: number;
waveRatio?: number;
swell?: number;
turbulence?: number;
tilt?: number;
zoom?: number;
height?: number;
fogDepth?: number;
detail?: GradientWavesDetail;
brightness?: number;
opacity?: number;
mouseInteraction?: boolean;
parallaxStrength?: number;
grain?: boolean;
grainIntensity?: number;
/** Cap canvas DPR (retina 2–3 is expensive for raymarch). */
maxDpr?: number;
/** Draw buffer scale 0–1; CSS stretches to full size (softer, cheaper). */
renderScale?: number;
/** Target animation FPS; lower = less GPU. */
fps?: number;
}
const hexToRgb = (hex: string): [number, number, number] => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return [1, 1, 1];
return [
parseInt(result[1], 16) / 255,
parseInt(result[2], 16) / 255,
parseInt(result[3], 16) / 255,
];
};
const detailToSteps = (detail: GradientWavesDetail): number => {
if (detail === 'low') return 40.0;
if (detail === 'high') return 110.0;
return 70.0;
};
const vertex = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const fragment = `#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
uniform float uSpeed;
uniform float uAmplitude;
uniform float uWaveScale;
uniform float uWaveRatio;
uniform float uSwell;
uniform float uTurbulence;
uniform float uTilt;
uniform float uZoom;
uniform float uHeight;
uniform float uFogDepth;
uniform float uSteps;
uniform float uBrightness;
uniform float uOpacity;
uniform float uGrain;
uniform float uGrainIntensity;
uniform vec2 uMouse;
uniform float uParallax;
uniform bool uEnableMouse;
uniform vec3 uHorizonColor;
uniform vec3 uWaveColor;
uniform vec3 uCrestColor;
out vec4 fragColor;
const float MAX_DIST = 20000.0;
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float plasma(vec3 r, vec2 freq, vec4 tc) {
float mx = r.x + tc.x;
mx += uSwell * sin((r.y + mx) / 20.0 + tc.y);
float my = r.y - tc.z;
my += uTurbulence * cos(r.x / 23.0 + tc.w);
return r.z - (sin(mx * freq.x) * uAmplitude + sin(my * freq.y) * uAmplitude + uHeight);
}
float raymarch(vec3 pos, vec3 dir, vec2 freq, vec4 tc) {
float dist = 0.0;
for (int i = 0; i < 128; i++) {
if (float(i) >= uSteps) break;
float dscene = plasma(pos + dist * dir, freq, tc);
if (abs(dscene) < 0.1) break;
dist += 0.9 * dscene;
if (!(abs(dist) < MAX_DIST)) return MAX_DIST;
}
return dist;
}
void main() {
float T = iTime * uSpeed;
vec2 freq = vec2(uWaveScale / 7.0, (uWaveScale * uWaveRatio) / 3.0);
vec4 tc = vec4(T / 0.130, T / 0.810, T / 0.200, T / 0.710);
float c, s;
float vfov = (3.14159 / 2.3) / max(uZoom, 0.05);
vec3 cam = vec3(0.0, 0.0, 30.0);
vec2 uv = (gl_FragCoord.xy / iResolution.xy) - 0.5;
uv.x *= iResolution.x / iResolution.y;
uv.y *= -1.0;
vec3 dir = vec3(0.0, 0.0, -1.0);
float ulen = length(uv);
float xrot = vfov * ulen;
c = cos(xrot); s = sin(xrot);
dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
vec2 nuv = ulen > 1e-5 ? uv / ulen : vec2(1.0, 0.0);
c = nuv.x; s = nuv.y;
dir = mat3(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0) * dir;
c = cos(uTilt); s = sin(uTilt);
dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
if (uEnableMouse) {
float yaw = (uMouse.x - 0.5) * uParallax * 0.4;
float pitch = (uMouse.y - 0.5) * uParallax * 0.4;
c = cos(yaw); s = sin(yaw);
dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
c = cos(pitch); s = sin(pitch);
dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
}
float dist = raymarch(cam, dir, freq, tc);
vec3 pos = cam + dist * dir;
float t = clamp(uFogDepth / max(dist, 0.001), 0.0, 1.0);
vec3 body = mix(uWaveColor, uCrestColor, clamp(pos.z * 0.08 + 0.5, 0.0, 1.0));
vec3 col = mix(uHorizonColor, body, t);
col *= uBrightness;
col = clamp(col, 0.0, 1.0);
float alpha = clamp(t, 0.0, 1.0) * uOpacity;
if (uGrain > 0.5) {
float g = hash21(gl_FragCoord.xy + mod(iTime, 64.0) * 11.0);
alpha += (g - 0.5) * uGrainIntensity;
}
alpha = clamp(alpha, 0.0, 1.0);
fragColor = vec4(col * alpha, alpha);
}
`;
interface ScalarUniform {
value: number;
}
interface Vec2Uniform {
value: Float32Array;
}
interface Vec3Uniform {
value: Float32Array;
}
interface BoolUniform {
value: boolean;
}
interface MainUniforms {
iTime: ScalarUniform;
iResolution: Vec2Uniform;
uSpeed: ScalarUniform;
uAmplitude: ScalarUniform;
uWaveScale: ScalarUniform;
uWaveRatio: ScalarUniform;
uSwell: ScalarUniform;
uTurbulence: ScalarUniform;
uTilt: ScalarUniform;
uZoom: ScalarUniform;
uHeight: ScalarUniform;
uFogDepth: ScalarUniform;
uSteps: ScalarUniform;
uBrightness: ScalarUniform;
uOpacity: ScalarUniform;
uGrain: ScalarUniform;
uGrainIntensity: ScalarUniform;
uMouse: Vec2Uniform;
uParallax: ScalarUniform;
uEnableMouse: BoolUniform;
uHorizonColor: Vec3Uniform;
uWaveColor: Vec3Uniform;
uCrestColor: Vec3Uniform;
}
const props = withDefaults(defineProps<GradientWavesProps>(), {
horizonColor: '#10B981',
waveColor: '#84CC16',
crestColor: '#FFFFFF',
speed: 0.4,
amplitude: 2.5,
waveScale: 0.6,
waveRatio: 0.9,
swell: 35,
turbulence: 20,
tilt: 1.11,
zoom: 1.0,
height: 5.5,
fogDepth: 15,
detail: 'medium',
brightness: 1.0,
opacity: 1.0,
mouseInteraction: true,
parallaxStrength: 0.5,
grain: true,
grainIntensity: 0.05,
maxDpr: 1.25,
renderScale: 0.75,
fps: 30,
});
const containerRef = ref<HTMLDivElement | null>(null);
let program: MainUniforms | null = null;
let teardown: (() => void) | null = null;
const syncUniforms = () => {
if (!program) return;
const u = program;
u.uSpeed.value = props.speed;
u.uAmplitude.value = props.amplitude;
u.uWaveScale.value = props.waveScale;
u.uWaveRatio.value = props.waveRatio;
u.uSwell.value = props.swell;
u.uTurbulence.value = props.turbulence;
u.uTilt.value = props.tilt;
u.uZoom.value = props.zoom;
u.uHeight.value = props.height;
u.uFogDepth.value = props.fogDepth;
u.uSteps.value = detailToSteps(props.detail);
u.uBrightness.value = props.brightness;
u.uOpacity.value = props.opacity;
u.uGrain.value = props.grain ? 1.0 : 0.0;
u.uGrainIntensity.value = props.grainIntensity;
u.uParallax.value = props.parallaxStrength;
u.uEnableMouse.value = props.mouseInteraction;
const hc = u.uHorizonColor.value;
const wc = u.uWaveColor.value;
const cc = u.uCrestColor.value;
const h = hexToRgb(props.horizonColor);
const w = hexToRgb(props.waveColor);
const cr = hexToRgb(props.crestColor);
hc[0] = h[0];
hc[1] = h[1];
hc[2] = h[2];
wc[0] = w[0];
wc[1] = w[1];
wc[2] = w[2];
cc[0] = cr[0];
cc[1] = cr[1];
cc[2] = cr[2];
};
watch(
() => [
props.horizonColor,
props.waveColor,
props.crestColor,
props.speed,
props.amplitude,
props.waveScale,
props.waveRatio,
props.swell,
props.turbulence,
props.tilt,
props.zoom,
props.height,
props.fogDepth,
props.detail,
props.brightness,
props.opacity,
props.grain,
props.grainIntensity,
props.mouseInteraction,
props.parallaxStrength,
],
() => syncUniforms()
);
onMounted(() => {
const container = containerRef.value;
if (!container) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const dprCap = Math.max(1, props.maxDpr);
const scale = Math.min(1, Math.max(0.4, props.renderScale));
const targetFps = reduceMotion ? 0 : Math.max(12, Math.min(60, props.fps));
const frameMs = targetFps > 0 ? 1000 / targetFps : 0;
const renderer = new Renderer({
webgl: 2,
alpha: true,
premultipliedAlpha: true,
antialias: false,
dpr: Math.min(window.devicePixelRatio || 1, dprCap),
});
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
const canvas = gl.canvas as HTMLCanvasElement;
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';
canvas.style.pointerEvents = 'none';
container.appendChild(canvas);
const geometry = new Triangle(gl);
const localProgram = new Program(gl, {
vertex,
fragment,
uniforms: {
iTime: { value: 0 },
iResolution: { value: new Float32Array([1, 1]) },
uSpeed: { value: 0.4 },
uAmplitude: { value: 2.5 },
uWaveScale: { value: 0.6 },
uWaveRatio: { value: 0.9 },
uSwell: { value: 35 },
uTurbulence: { value: 20 },
uTilt: { value: 1.11 },
uZoom: { value: 1.0 },
uHeight: { value: 5.5 },
uFogDepth: { value: 15 },
uSteps: { value: 70.0 },
uBrightness: { value: 1.0 },
uOpacity: { value: 1.0 },
uGrain: { value: 1.0 },
uGrainIntensity: { value: 0.05 },
uMouse: { value: new Float32Array([0.5, 0.5]) },
uParallax: { value: 0.5 },
uEnableMouse: { value: true },
uHorizonColor: { value: new Float32Array([1, 1, 1]) },
uWaveColor: { value: new Float32Array([1, 1, 1]) },
uCrestColor: { value: new Float32Array([1, 1, 1]) },
},
});
const mu = localProgram.uniforms as unknown as MainUniforms;
program = mu;
const mesh = new Mesh(gl, { geometry, program: localProgram });
const setSize = () => {
const rect = container.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width * scale));
const h = Math.max(1, Math.floor(rect.height * scale));
renderer.setSize(w, h);
const res = mu.iResolution.value;
res[0] = gl.drawingBufferWidth;
res[1] = gl.drawingBufferHeight;
renderer.render({ scene: mesh });
};
const ro = new ResizeObserver(setSize);
ro.observe(container);
setSize();
const currentMouse: [number, number] = [0.5, 0.5];
const targetMouse: [number, number] = [0.5, 0.5];
// Window-level so parallax works behind UI
const onPointerMove = (e: PointerEvent) => {
if (!props.mouseInteraction) return;
const rect = container.getBoundingClientRect();
targetMouse[0] = (e.clientX - rect.left) / Math.max(1, rect.width);
targetMouse[1] = 1.0 - (e.clientY - rect.top) / Math.max(1, rect.height);
};
const onPointerLeave = () => {
targetMouse[0] = 0.5;
targetMouse[1] = 0.5;
};
if (props.mouseInteraction && !reduceMotion) {
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('blur', onPointerLeave);
}
let raf = 0;
let lastFrame = 0;
let isVisible = true;
let isPageVisible = !document.hidden;
const t0 = performance.now();
const paint = (t: number) => {
mu.iTime.value = (t - t0) * 0.001;
if (props.mouseInteraction) {
currentMouse[0] += 0.05 * (targetMouse[0] - currentMouse[0]);
currentMouse[1] += 0.05 * (targetMouse[1] - currentMouse[1]);
}
const m = mu.uMouse.value;
m[0] = currentMouse[0];
m[1] = currentMouse[1];
renderer.render({ scene: mesh });
};
const loop = (t: number) => {
raf = requestAnimationFrame(loop);
if (frameMs > 0 && t - lastFrame < frameMs) return;
lastFrame = t;
paint(t);
};
const tryStart = () => {
if (reduceMotion || targetFps <= 0) {
paint(performance.now());
return;
}
if (isVisible && isPageVisible && raf === 0) raf = requestAnimationFrame(loop);
};
const tryStop = () => {
if (raf !== 0) {
cancelAnimationFrame(raf);
raf = 0;
}
};
const io = new IntersectionObserver(
([entry]) => {
isVisible = entry.isIntersecting;
if (isVisible) tryStart();
else tryStop();
},
{ threshold: 0 }
);
io.observe(container);
const onVisibility = () => {
isPageVisible = !document.hidden;
if (isPageVisible) tryStart();
else tryStop();
};
document.addEventListener('visibilitychange', onVisibility);
tryStart();
syncUniforms();
teardown = () => {
tryStop();
ro.disconnect();
io.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('blur', onPointerLeave);
program = null;
try {
container.removeChild(canvas);
} catch {
/* canvas already detached */
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
});
onUnmounted(() => {
teardown?.();
teardown = null;
});
</script>
<style scoped>
.gradient-waves {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.gradient-waves :deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
</style>

View File

@ -1,9 +1,33 @@
<template>
<div id="ai-advisor-shell" :class="{ 'ai-history-open': historyOpen }">
<div class="ai-shell-bg" aria-hidden="true">
<GradientWaves
class="ai-shell-bg__waves"
horizon-color="#023e8a"
wave-color="#0077b6"
crest-color="#caf0f8"
:speed="0.34"
:amplitude="3.3"
:wave-scale="0.72"
:wave-ratio="0.82"
:swell="42"
:turbulence="26"
:tilt="1.08"
:zoom="0.92"
:height="4.8"
:fog-depth="22"
detail="low"
:brightness="1.08"
:opacity="1.0"
:mouse-interaction="true"
:parallax-strength="0.4"
:grain="false"
:fps="30"
:render-scale="0.72"
:max-dpr="1.25"
/>
<div class="ai-shell-bg__veil" aria-hidden="true"></div>
</div>
<SiteStageMaps :is-zh="isZh" :coverage-open="coverageOpen" :active-cover="activeCover" />
<div class="ai-advisor-layout">
<div class="ai-advisor-app">
<div v-if="chatting" class="ai-stage-tools">
@ -32,7 +56,20 @@
<div class="ai-welcome__deck">
<div class="ai-welcome__intro">
<h2 class="ai-welcome-title" :aria-label="shortTitle ? copy.titleShort : copy.title">
IDE<span class="ai-welcome-title__dot" aria-hidden="true">.</span>{{ shortTitle ? copy.titleRestShort : copy.titleRest }}
<span class="ai-welcome-title__brand" aria-hidden="true">
IDE<span class="ai-welcome-title__dot">.</span>
</span>
<TextType
as="span"
class-name="ai-welcome-title__type"
:text="shortTitle ? copy.titleRestShort : copy.titleRest"
:typing-speed="75"
:pause-duration="1500"
:show-cursor="true"
:loop="false"
cursor-character="|"
cursor-class-name="ai-welcome-title__cursor"
/>
</h2>
<p class="ai-welcome-sub">{{ copy.sub }}</p>
</div>
@ -144,7 +181,7 @@
{{ copy.routesBack }}
</button>
<button
v-for="intent in SITE_INTENTS"
v-for="intent in SITE_FOLLOWUP_INTENTS"
:key="'fu-' + intent.id"
type="button"
class="ai-followup-chip"
@ -215,15 +252,17 @@
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import SiteComposer from './SiteComposer.vue';
import SitePartnerMarquee from './SitePartnerMarquee.vue';
import SiteMessageCard from './SiteMessageCard.vue';
import SiteStageMaps from './SiteStageMaps.vue';
import SiteIllusIcon from './SiteIllusIcon.vue';
import { HOME_DOORS, HERO_STATS, SITE_INTENTS, WELCOME_COPY } from './lib/site-welcome';
import TextType from './TextType.vue';
import { HOME_DOORS, HERO_STATS, SITE_FOLLOWUP_INTENTS, WELCOME_COPY } from './lib/site-welcome';
import { ROUTE_DEST_PRESETS } from './lib/portal-catalog';
const GradientWaves = defineAsyncComponent(() => import('./GradientWaves.vue'));
export type SiteChatMessage = {
role: 'user' | 'assistant';
text?: string;
@ -249,8 +288,6 @@
threads: SiteChatThread[];
activeThreadId: string;
routesPending: boolean;
activeCover: string;
coverageOpen: boolean;
}>();
const emit = defineEmits<{

View File

@ -17,6 +17,18 @@
<stop stop-color="#5EC4D6" />
<stop offset="1" stop-color="#054F74" />
</linearGradient>
<linearGradient v-else-if="name === 'growth'" :id="gid" x1="14" y1="12" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#5EC4D6" />
<stop offset="1" stop-color="#054F74" />
</linearGradient>
<linearGradient v-else-if="name === 'coin'" :id="gid" x1="14" y1="14" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0C56A" />
<stop offset="1" stop-color="#C7923E" />
</linearGradient>
<linearGradient v-else-if="name === 'coupon'" :id="gid" x1="12" y1="14" x2="42" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0A090" />
<stop offset="1" stop-color="#E07A5F" />
</linearGradient>
<linearGradient v-else :id="gid" x1="20" y1="11" x2="34" y2="26" gradientUnits="userSpaceOnUse">
<stop stop-color="#7DCFDD" />
<stop offset="1" stop-color="#2A9BB5" />
@ -64,6 +76,39 @@
<circle cx="37.5" cy="34.2" r="7.6" fill="#2A9BB5" />
<path d="M34.6 34.4l1.8 1.8 4.2-4.4" stroke="#fff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" />
</template>
<template v-else-if="name === 'growth'">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#054F74" opacity="0.22" />
<rect x="12" y="12" width="30" height="28" rx="4" :fill="fill" />
<rect x="15.5" y="15" width="23" height="3.2" rx="1.6" fill="#fff" opacity="0.35" />
<rect x="17" y="32" width="5" height="5.5" rx="1" fill="#E6F6FA" />
<rect x="24.5" y="27" width="5" height="10.5" rx="1" fill="#9ED7E4" />
<rect x="32" y="21.5" width="5" height="16" rx="1" fill="#fff" />
<path d="M17.5 26.5l6.2-5.2 5.4 3.4 7.2-7.6" stroke="#FFF8EC" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="36.3" cy="17.1" r="2.1" fill="#E0B35A" />
</template>
<template v-else-if="name === 'coin'">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#C7923E" opacity="0.28" />
<ellipse cx="31.5" cy="30.5" rx="11.2" ry="11.2" fill="#B58232" />
<ellipse cx="31.5" cy="29.2" rx="11.2" ry="11.2" :fill="fill" />
<ellipse cx="31.5" cy="29.2" rx="7.2" ry="7.2" fill="#FFF8EC" opacity="0.22" />
<path d="M31.5 23.4v11.6M28.6 26.2h5.1c1.2 0 2.2.9 2.2 2.1s-1 2.1-2.2 2.1h-5.4" stroke="#FFF8EC" stroke-width="1.8" stroke-linecap="round" />
<ellipse cx="20.2" cy="24.8" rx="9.4" ry="9.4" fill="#B58232" />
<ellipse cx="20.2" cy="23.6" rx="9.4" ry="9.4" :fill="fill" />
<ellipse cx="20.2" cy="23.6" rx="5.8" ry="5.8" fill="#FFF8EC" opacity="0.28" />
<path d="M20.2 18.8v9.6M17.8 21.2h4.2c1 0 1.8.7 1.8 1.7s-.8 1.7-1.8 1.7h-4.4" stroke="#FFF8EC" stroke-width="1.6" stroke-linecap="round" />
</template>
<template v-else-if="name === 'coupon'">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#E07A5F" opacity="0.28" />
<path
d="M11.5 20.5c0-1.7 1.4-3 3-3h25c1.7 0 3 1.3 3 3v4.2c-1.6.4-2.7 1.8-2.7 3.5s1.1 3.1 2.7 3.5V36c0 1.7-1.3 3-3 3h-25c-1.6 0-3-1.3-3-3v-4.3c1.6-.4 2.7-1.8 2.7-3.5s-1.1-3.1-2.7-3.5v-4.2z"
:fill="fill"
/>
<path d="M22.5 17.5v21.5" stroke="#fff" stroke-width="1.8" stroke-dasharray="2.6 2.4" opacity="0.85" />
<rect x="26.5" y="23.2" width="11.5" height="2.4" rx="1.2" fill="#fff" opacity="0.92" />
<rect x="26.5" y="28.2" width="8" height="2.2" rx="1.1" fill="#fff" opacity="0.7" />
<circle cx="17.2" cy="28.5" r="3.2" fill="#FFF8EC" />
<path d="M17.2 26.6v3.8M15.6 28.5h3.2" stroke="#E07A5F" stroke-width="1.4" stroke-linecap="round" />
</template>
<template v-else>
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#2A9BB5" opacity="0.28" />
<circle cx="27" cy="18.2" r="7.2" :fill="fill" />
@ -80,7 +125,7 @@
import { computed, useId } from 'vue';
const props = defineProps<{
name: 'quote' | 'track' | 'hard' | 'clearance' | 'human';
name: 'quote' | 'track' | 'hard' | 'clearance' | 'human' | 'growth' | 'coin' | 'coupon';
}>();
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '');

View File

@ -223,6 +223,101 @@
],
};
}
if (card.type === 'member-rules') {
return {
kicker: isZh ? '会员成长' : 'Member levels',
title: isZh ? '发货越多,基础运费越省' : 'Ship more — pay less on base freight',
body: isZh
? '近 12 个月实付基础运费积分定级(约 1 元 = 1 分)。折扣只打基础运费,附加费原价;与优惠券二选一,系统自动选更省的。'
: 'Rolling 12-month points from paid base freight (~¥1 = 1 pt). Discount hits base freight only; surcharges full price. Member rate vs coupon — system picks the cheaper one.',
rows: [
{ label: isZh ? 'V0 普通' : 'V0 Standard', value: isZh ? '原价' : 'List rate' },
{ label: isZh ? 'V1 铜卡 · 2.4万+' : 'V1 Copper · 24k+', value: isZh ? '99 折' : '1% off' },
{ label: isZh ? 'V2 银卡 · 6万+' : 'V2 Silver · 60k+', value: isZh ? '98 折' : '2% off' },
{ label: isZh ? 'V3 金卡 · 12万+' : 'V3 Gold · 120k+', value: isZh ? '97 折' : '3% off' },
{ label: isZh ? 'V4 钻石 · 24万+' : 'V4 Diamond · 240k+', value: isZh ? '96 折 · 可降级' : '4% off · can drop' },
{ label: isZh ? 'V5 皇冠 · 60万+' : 'V5 Crown · 600k+', value: isZh ? '95 折封顶 · 可降级' : '5% off max · can drop' },
],
items: [
{
title: isZh ? 'V0~V3 只升不降' : 'V0–V3 never auto-downgrade',
hint: isZh
? '后面发货少了也不会自动掉档。V4 / V5 积分不够时每天凌晨最多降一级。'
: 'Lower volume will not auto-drop you. V4/V5 may drop one level per night if points fall short.',
},
{
title: isZh ? '积分不能抵运费' : 'Points are not cash',
hint: isZh ? '只用来定级,不能兑礼、提现。券抵掉的金额不计积分。' : 'Levels only — no redeem or withdraw. Coupon-covered freight earns no points.',
},
],
actions: [
{ key: 'reg', href: officialLoginUrl(), label: register, primary: true },
{ key: 'login', href: officialLoginUrl(), label: login },
],
};
}
if (card.type === 'invite-rules') {
return {
kicker: isZh ? '邀新分成' : 'Invite & earn',
title: isZh ? '好友发货,你拿运费余额' : 'Friends ship — you earn freight credit',
body: isZh
? '默认铜卡及以上可邀。好友经你的链接注册并派送成功后计奖;只算一级下级。奖励入运费余额,可继续下单。'
: 'Copper+ can invite by default. Rewards after friends register via your link and deliver. Direct referrals only. Credit lands as freight balance.',
items: [
{
title: isZh ? '第 1 单:实付基础运费 5%,封顶 50 元' : '1st delivery: 5% of paid base freight, cap ¥50',
hint: isZh ? '该单实付基础运费须 ≥ 50 元才计奖。' : 'Paid base freight on that order must be ≥ ¥50.',
},
{
title: isZh ? '第 2~6 单:各 3%,封顶 30 元' : '2nd–6th: 3% each, cap ¥30',
hint: isZh ? '须在新客注册后 90 天内派送成功;第 7 单起不再分成。' : 'Must deliver within 90 days of signup; no share from the 7th order on.',
},
{
title: isZh ? '券奖励:你拿 20 元,对方拿 5 元' : 'Coupons: ¥20 for you, ¥5 for them',
hint: isZh
? '对方注册多一张 5 元券;其首单派送成功后你再得 20 元无门槛券。分成 T+3 工作日入账。'
: 'Invitee gets ¥5 at signup; you get ¥20 after their first delivery. Commission posts T+3 business days.',
},
],
actions: [
{ key: 'reg', href: officialLoginUrl(), label: register, primary: true },
{ key: 'human', intent: 'human', label: human },
],
};
}
if (card.type === 'newcomer-rules') {
return {
kicker: isZh ? '新人福利' : 'New-seller perks',
title: isZh ? '注册即发券,首单就能用' : 'Coupons on signup — usable on first ship',
body: isZh
? '券只抵基础运费,不找零;与会员折二选一。普通注册三张;走邀请链接再加一张 5 元。'
: 'Coupons cut base freight only, no change; mutually exclusive with member rate. Standard pack is three; invite link adds ¥5.',
items: [
{
title: isZh ? '新人首单券 · 15 元' : 'First-ship coupon · ¥15',
hint: isZh ? '无门槛,注册后立即可用,领取后 15 天有效。' : 'No minimum — ready after signup, valid 15 days from claim.',
},
{
title: isZh ? '新人备货券 · 20 元' : 'Stock-up coupon · ¥20',
hint: isZh ? '基础运费满 200 可用,领取后 60 天。' : 'Needs ¥200+ base freight; valid 60 days from claim.',
},
{
title: isZh ? '新人第二单券 · 15 元' : 'Second-ship coupon · ¥15',
hint: isZh
? '注册时未解锁;首单派送成功后解锁,满 150 可用,解锁后 30 天。'
: 'Locked at signup; unlocks after first delivery. Needs ¥150+ base freight; 30 days after unlock.',
},
{
title: isZh ? '邀请注册加赠 · 5 元' : 'Invite bonus · ¥5',
hint: isZh ? '仅通过好友邀请链接注册才有,无门槛,领取后 30 天。' : 'Only via an invite link — no minimum, 30 days from claim.',
},
],
actions: [
{ key: 'reg', href: officialLoginUrl(), label: register, primary: true },
{ key: 'login', href: officialLoginUrl(), label: login },
],
};
}
return { body: '', actions: [] };
});

View File

@ -1,74 +0,0 @@
<template>
<div ref="mapsRef" class="ai-stage-maps" aria-hidden="true"></div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { loadSiteMapVendors } from './loadVendors';
import { buildCoverageMarkers } from './lib/site-cover';
type GlobeApi = {
setMarkers: (list: unknown[]) => void;
focusMarker: (id: string) => void;
resetView: () => void;
setActive: (active: boolean) => void;
};
const props = defineProps<{
isZh: boolean;
coverageOpen: boolean;
activeCover: string;
}>();
const mapsRef = ref<HTMLElement | null>(null);
let globe: GlobeApi | null = null;
let cancelled = false;
function applyCoverage() {
if (!globe) return;
globe.setActive(props.coverageOpen);
if (props.coverageOpen) {
globe.setMarkers(buildCoverageMarkers(props.isZh));
if (props.activeCover) globe.focusMarker(props.activeCover);
else globe.resetView();
} else {
globe.setMarkers([]);
globe.resetView();
}
}
async function mountGlobe() {
if (!window.matchMedia('(min-width: 900px)').matches) return;
await loadSiteMapVendors();
if (cancelled || !mapsRef.value) return;
const { render } = await import('./lib/stage-globe.js');
if (cancelled || !mapsRef.value) return;
const cnHost = document.querySelector('.ai-welcome-plane') as HTMLElement | null;
globe = render(mapsRef.value, { lang: props.isZh ? 'zh' : 'en', cnHost }) || null;
applyCoverage();
}
onMounted(() => {
void mountGlobe();
});
watch(
() => props.isZh,
() => {
globe = null;
if (mapsRef.value) mapsRef.value.replaceChildren();
document.querySelectorAll('.ai-welcome-plane .ai-stage-map').forEach((node) => node.remove());
void mountGlobe();
}
);
watch(
() => [props.coverageOpen, props.activeCover],
() => applyCoverage()
);
onBeforeUnmount(() => {
cancelled = true;
globe = null;
});
</script>

220
src/views/site/TextType.vue Normal file
View File

@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed, useTemplateRef } from 'vue';
import { gsap } from 'gsap';
interface TextTypeProps {
className?: string;
showCursor?: boolean;
hideCursorWhileTyping?: boolean;
cursorCharacter?: string;
cursorBlinkDuration?: number;
cursorClassName?: string;
text: string | string[];
as?: string;
typingSpeed?: number;
initialDelay?: number;
pauseDuration?: number;
deletingSpeed?: number;
loop?: boolean;
textColors?: string[];
variableSpeed?: { min: number; max: number };
onSentenceComplete?: (sentence: string, index: number) => void;
startOnVisible?: boolean;
reverseMode?: boolean;
}
const props = withDefaults(defineProps<TextTypeProps>(), {
as: 'div',
typingSpeed: 50,
initialDelay: 0,
pauseDuration: 2000,
deletingSpeed: 30,
loop: true,
className: '',
showCursor: true,
hideCursorWhileTyping: false,
cursorCharacter: '|',
cursorBlinkDuration: 0.5,
cursorClassName: '',
textColors: () => [],
startOnVisible: false,
reverseMode: false,
});
const displayedText = ref('');
const currentCharIndex = ref(0);
const isDeleting = ref(false);
const currentTextIndex = ref(0);
const isVisible = ref(!props.startOnVisible);
const cursorRef = useTemplateRef<HTMLElement>('cursorRef');
const containerRef = useTemplateRef<HTMLElement>('containerRef');
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]));
const hideCursor = computed(() => {
if (!props.hideCursorWhileTyping) return false;
const current = textArray.value[currentTextIndex.value] ?? '';
return currentCharIndex.value < current.length || isDeleting.value;
});
const getRandomSpeed = () => {
if (!props.variableSpeed) return props.typingSpeed;
const { min, max } = props.variableSpeed;
return Math.random() * (max - min) + min;
};
const getCurrentTextColor = () => {
if (!props.textColors.length) return undefined;
return props.textColors[currentTextIndex.value % props.textColors.length];
};
let timeout: ReturnType<typeof setTimeout> | null = null;
let cursorTween: gsap.core.Tween | null = null;
let observer: IntersectionObserver | null = null;
const clearTimeoutIfNeeded = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
const resetAnimation = () => {
clearTimeoutIfNeeded();
displayedText.value = '';
currentCharIndex.value = 0;
isDeleting.value = false;
currentTextIndex.value = 0;
};
const executeTypingAnimation = () => {
const currentText = textArray.value[currentTextIndex.value] ?? '';
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText;
if (isDeleting.value) {
if (displayedText.value === '') {
isDeleting.value = false;
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) return;
props.onSentenceComplete?.(textArray.value[currentTextIndex.value], currentTextIndex.value);
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length;
currentCharIndex.value = 0;
} else {
timeout = setTimeout(() => {
displayedText.value = displayedText.value.slice(0, -1);
}, props.deletingSpeed);
}
} else if (currentCharIndex.value < processedText.length) {
timeout = setTimeout(
() => {
displayedText.value += processedText[currentCharIndex.value] ?? '';
currentCharIndex.value += 1;
},
props.variableSpeed ? getRandomSpeed() : props.typingSpeed
);
} else if (textArray.value.length > 1) {
timeout = setTimeout(() => {
isDeleting.value = true;
}, props.pauseDuration);
}
};
watch(
() => props.text,
() => {
resetAnimation();
}
);
watch(
[displayedText, currentCharIndex, isDeleting, isVisible],
() => {
if (!isVisible.value) return;
clearTimeoutIfNeeded();
if (currentCharIndex.value === 0 && !isDeleting.value && displayedText.value === '') {
timeout = setTimeout(() => {
executeTypingAnimation();
}, props.initialDelay);
} else {
executeTypingAnimation();
}
},
{ immediate: true }
);
onMounted(() => {
if (props.showCursor && cursorRef.value) {
gsap.set(cursorRef.value, { opacity: 1 });
cursorTween = gsap.to(cursorRef.value, {
opacity: 0,
duration: props.cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: 'power2.inOut',
});
}
if (props.startOnVisible && containerRef.value) {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) isVisible.value = true;
});
},
{ threshold: 0.1 }
);
observer.observe(containerRef.value);
}
});
onBeforeUnmount(() => {
clearTimeoutIfNeeded();
cursorTween?.kill();
observer?.disconnect();
});
</script>
<template>
<component
:is="as"
ref="containerRef"
:class="['text-type', className]"
v-bind="$attrs"
>
<span class="text-type__content" :style="getCurrentTextColor() ? { color: getCurrentTextColor() } : undefined">
{{ displayedText }}
</span>
<span
v-if="showCursor"
ref="cursorRef"
class="text-type__cursor"
:class="[cursorClassName, { 'text-type__cursor--hidden': hideCursor }]"
>
{{ cursorCharacter }}
</span>
</component>
</template>
<style scoped>
.text-type {
display: inline-block;
white-space: pre-wrap;
letter-spacing: inherit;
}
.text-type__content {
display: inline;
}
.text-type__cursor {
display: inline-block;
margin-left: 0.08em;
opacity: 1;
}
.text-type__cursor--hidden {
visibility: hidden;
}
</style>

View File

@ -32,8 +32,6 @@
:threads="threads"
:active-thread-id="activeThreadId"
:routes-pending="routesPending"
:active-cover="activeCover"
:coverage-open="coverageOpen"
@submit="onSubmit"
@intent="advisor.askIntent"
@human="onNav('contact')"
@ -120,8 +118,6 @@
const routesDest = ref('all');
const draft = ref('');
const historyOpen = ref(false);
const coverageOpen = ref(false);
const activeCover = ref('');
const wecomOpen = ref(false);
const isZh = computed(() => lang.value === 'zh');
const loginUrl = computed(() => officialLoginUrl());

View File

@ -1,117 +0,0 @@
export const COVER_REGIONS = [
{ id: 'eu', dest: { zh: '欧盟', en: 'European Union' }, label: { zh: '欧盟 27 国 + 英国', en: 'EU 27 + UK' }, expand: true },
{ id: 'us', dest: { zh: '美国', en: 'United States' }, label: { zh: '美国', en: 'United States' } },
{ id: 'mx', dest: { zh: '墨西哥', en: 'Mexico' }, label: { zh: '墨西哥', en: 'Mexico' } },
{ id: 'ca', dest: { zh: '加拿大', en: 'Canada' }, label: { zh: '加拿大', en: 'Canada' } },
{ id: 'au', dest: { zh: '澳大利亚', en: 'Australia' }, label: { zh: '澳大利亚', en: 'Australia' } },
{ id: 'nz', dest: { zh: '新西兰', en: 'New Zealand' }, label: { zh: '新西兰', en: 'New Zealand' } },
];
export const COVER_EU = [
{ mapId: 'de', zh: '德国', en: 'Germany' },
{ mapId: 'fr', zh: '法国', en: 'France' },
{ mapId: 'it', zh: '意大利', en: 'Italy' },
{ mapId: 'es', zh: '西班牙', en: 'Spain' },
{ mapId: 'nl', zh: '荷兰', en: 'Netherlands' },
{ mapId: 'be', zh: '比利时', en: 'Belgium' },
{ mapId: 'at', zh: '奥地利', en: 'Austria' },
{ mapId: 'ie', zh: '爱尔兰', en: 'Ireland' },
{ mapId: 'gb', zh: '英国', en: 'United Kingdom' },
{ mapId: 'pt', zh: '葡萄牙', en: 'Portugal' },
{ mapId: 'gr', zh: '希腊', en: 'Greece' },
{ mapId: 'se', zh: '瑞典', en: 'Sweden' },
{ mapId: 'dk', zh: '丹麦', en: 'Denmark' },
{ mapId: 'fi', zh: '芬兰', en: 'Finland' },
{ mapId: 'pl', zh: '波兰', en: 'Poland' },
{ mapId: 'cz', zh: '捷克', en: 'Czechia' },
{ mapId: 'hu', zh: '匈牙利', en: 'Hungary' },
{ mapId: 'ro', zh: '罗马尼亚', en: 'Romania' },
{ mapId: 'bg', zh: '保加利亚', en: 'Bulgaria' },
{ mapId: 'sk', zh: '斯洛伐克', en: 'Slovakia' },
{ mapId: 'si', zh: '斯洛文尼亚', en: 'Slovenia' },
{ mapId: 'hr', zh: '克罗地亚', en: 'Croatia' },
{ mapId: 'lt', zh: '立陶宛', en: 'Lithuania' },
{ mapId: 'lv', zh: '拉脱维亚', en: 'Latvia' },
{ mapId: 'ee', zh: '爱沙尼亚', en: 'Estonia' },
{ mapId: 'lu', zh: '卢森堡', en: 'Luxembourg' },
{ mapId: 'mt', zh: '马耳他', en: 'Malta' },
{ mapId: 'cy', zh: '塞浦路斯', en: 'Cyprus' },
];
export const COVER_SEA = [
{ mapId: 'my', zh: '马来西亚', en: 'Malaysia' },
{ mapId: 'sg', zh: '新加坡', en: 'Singapore' },
{ mapId: 'ph', zh: '菲律宾', en: 'Philippines' },
{ mapId: 'th', zh: '泰国', en: 'Thailand' },
{ mapId: 'vn', zh: '越南', en: 'Vietnam' },
{ mapId: 'id', zh: '印度尼西亚', en: 'Indonesia' },
];
export const MAP_COORDS: Record<string, [number, number]> = {
eu: [10.5, 50.5],
us: [-98.5, 39.8],
mx: [-102.5, 23.6],
ca: [-106.3, 56.1],
au: [134.5, -25.7],
nz: [174.8, -41.3],
de: [10.45, 51.16],
fr: [2.35, 46.85],
it: [12.5, 42.85],
es: [-3.7, 40.4],
nl: [5.3, 52.15],
be: [4.35, 50.85],
at: [14.55, 47.52],
ie: [-7.7, 53.35],
gb: [-2.5, 54.0],
pt: [-8.6, 39.4],
gr: [23.73, 37.98],
se: [18.07, 59.33],
dk: [12.57, 55.68],
fi: [24.94, 60.17],
pl: [21.01, 52.23],
cz: [14.44, 50.08],
hu: [19.05, 47.5],
ro: [24.97, 45.94],
bg: [23.32, 42.7],
sk: [17.11, 48.15],
si: [14.51, 46.05],
hr: [15.98, 45.81],
lt: [25.28, 54.69],
lv: [24.11, 56.95],
ee: [24.75, 59.44],
lu: [6.13, 49.61],
mt: [14.51, 35.9],
cy: [33.38, 35.18],
my: [101.98, 4.21],
sg: [103.82, 1.35],
ph: [121.77, 12.88],
th: [100.5, 13.75],
vn: [105.85, 21.03],
id: [113.92, -0.79],
};
const MAP_REGION_EU = new Set(['eu', ...COVER_EU.map((item) => item.mapId)]);
const MAP_REGION_AM = new Set(['us', 'mx', 'ca']);
const MAP_REGION_AU = new Set(['au', 'nz']);
const MAP_REGION_SEA = new Set(COVER_SEA.map((item) => item.mapId));
export function mapRegionForId(mapId: string) {
if (MAP_REGION_EU.has(mapId)) return 'eu';
if (MAP_REGION_AM.has(mapId)) return 'am';
if (MAP_REGION_AU.has(mapId)) return 'au';
if (MAP_REGION_SEA.has(mapId)) return 'sea';
return 'cn';
}
export function buildCoverageMarkers(isZh: boolean) {
const markers: { id: string; coords: [number, number]; label: string; regionId: string }[] = [];
const push = (id: string, label: string) => {
const coords = MAP_COORDS[id];
if (!coords) return;
markers.push({ id, coords, label, regionId: mapRegionForId(id) });
};
COVER_REGIONS.forEach((region) => push(region.id, isZh ? region.label.zh : region.label.en));
COVER_EU.forEach((item) => push(item.mapId, isZh ? item.zh : item.en));
COVER_SEA.forEach((item) => push(item.mapId, isZh ? item.zh : item.en));
return markers;
}

View File

@ -40,6 +40,9 @@ export function detectIntent(text: string) {
if (/price|cost|quote|报价|价格|多少钱/.test(lower)) return 'quote';
if (/human|人工|顾问|电话|联系我们|联系/.test(lower)) return 'human';
if (/who are you|about ide|你们是谁|关于我们|关于|公司|靠谱/.test(lower)) return 'company';
if (/会员|等级|成长|v0|v1|v5|积分定级|运费折扣/.test(lower)) return 'member';
if (/邀新|邀请|分成|裂变|拉新|referral|invite/.test(lower)) return 'invite';
if (/新人|注册福利|首单券|第二单|备货券|newcomer/.test(lower)) return 'newcomer';
return null;
}
@ -177,6 +180,39 @@ export function replyForIntent(
},
};
}
if (intentId === 'member') {
return {
flow: null,
reply: {
text: zh
? '会员按近 12 个月发货积分定级,折扣只打基础运费,最高 95 折。细则如下。'
: 'Levels use rolling 12-month shipping points. Discounts apply to base freight only — up to 95%. Rules below.',
card: { type: 'member-rules' },
},
};
}
if (intentId === 'invite') {
return {
flow: null,
reply: {
text: zh
? '铜卡及以上可邀新:好友派送成功后你拿运费余额分成,另有券奖励。规则如下。'
: 'Copper+ members can invite. After friends deliver, you earn freight credit plus coupons. Rules below.',
card: { type: 'invite-rules' },
},
};
}
if (intentId === 'newcomer') {
return {
flow: null,
reply: {
text: zh
? '注册自动发新人券包;经邀请链接再多一张 5 元券。怎么用看下面。'
: 'Register for a starter coupon pack; invite links add an extra ¥5. How to use them is below.',
card: { type: 'newcomer-rules' },
},
};
}
return { flow: null, reply: fallback(lang) };
}

View File

@ -150,8 +150,58 @@ export const SITE_INTENTS = [
label: '给我一个专属顾问,别拉群',
},
},
{
id: 'member',
icon: 'growth',
followup: false,
en: {
title: 'Member levels',
hint: 'Ship more, unlock up to 95% list rate on base freight',
label: 'How do member levels and freight discounts work?',
},
zh: {
title: '会员成长',
hint: '多发货升级,基础运费最高 95 折',
label: '会员等级怎么升?运费折扣怎么算?',
},
},
{
id: 'invite',
icon: 'coin',
followup: false,
en: {
title: 'Invite & earn',
hint: 'Friends ship — you get freight credit and coupons',
label: 'How does invite rewards and commission work?',
},
zh: {
title: '邀新分成',
hint: '好友发货,你拿运费余额奖励和券',
label: '邀请好友能拿什么奖励?分成怎么算?',
},
},
{
id: 'newcomer',
icon: 'coupon',
followup: false,
en: {
title: 'New-seller perks',
hint: 'Register for starter coupons — first and second ship',
label: 'What coupons do new sellers get after registering?',
},
zh: {
title: '新人福利',
hint: '注册就发券,首单、第二单都能用',
label: '新注册能领哪些券?怎么用?',
},
},
] as const;
/** Bottom follow-up chips — exclude marketing home doors to avoid clutter */
export const SITE_FOLLOWUP_INTENTS = SITE_INTENTS.filter(
(item) => !('followup' in item && item.followup === false),
);
export const HOME_DOORS = [
{
id: 'hard',
@ -174,6 +224,27 @@ export const HOME_DOORS = [
zh: { title: '出口报关 / 退税', hint: '正式报关、退税,或没有专票怎么出。' },
en: { title: 'Export paperwork', hint: 'Declaration, VAT refund, or shipping without invoices.' },
},
{
id: 'member',
icon: 'growth',
intentId: 'member' as const,
zh: { title: '会员成长体系', hint: '近 12 个月发货积分定级,基础运费最高 95 折。' },
en: { title: 'Member growth', hint: 'Rolling 12-month shipping points — up to 95% on base freight.' },
},
{
id: 'invite',
icon: 'coin',
intentId: 'invite' as const,
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 20 元券。' },
en: { title: 'Invite & earn', hint: 'When friends deliver, you get freight credit + a ¥20 coupon.' },
},
{
id: 'newcomer',
icon: 'coupon',
intentId: 'newcomer' as const,
zh: { title: '新人注册福利', hint: '注册送券:首单、备货、第二单都有。' },
en: { title: 'New-seller coupons', hint: 'Sign up for first-ship, stock-up, and second-ship coupons.' },
},
] as const;
export const SITE_PARTNERS = [

View File

@ -1,18 +0,0 @@
export function loadSiteScript(src: string) {
const exists = document.querySelector(`script[data-official-vendor][src="${src}"]`);
if (exists) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = false;
script.dataset.officialVendor = '1';
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);
});
}
export async function loadSiteMapVendors() {
await loadSiteScript('/site/vendor/d3/d3.min.js');
await loadSiteScript('/site/vendor/topojson/topojson-client.min.js');
}

View File

@ -28,12 +28,12 @@
);
--ai-glass-panel: linear-gradient(180deg, #ffffff 0%, var(--ai-bg-soft) 100%);
--ai-glass-shadow: none;
--ai-action: #054f74;
--ai-action-hover: #054b6e;
--ai-action-rgb: 5, 79, 116;
--ai-sky: #2a9bb5;
--ai-sky-hover: #238aa2;
--ai-sky-rgb: 42, 155, 181;
--ai-action: #023e8a;
--ai-action-hover: #012a74;
--ai-action-rgb: 2, 62, 138;
--ai-sky: #00b4d8;
--ai-sky-hover: #0096c7;
--ai-sky-rgb: 0, 180, 216;
--ai-sky-soft: color-mix(in srgb, var(--ai-sky) 14%, #fff);
--ai-gold: #c7923e;
--ai-gold-hover: #b58232;
@ -42,7 +42,7 @@
--ai-primary-soft: #e0edf2;
--ai-primary-mid: color-mix(in srgb, var(--ai-action) 16%, #fff);
--ai-primary-softer: color-mix(in srgb, var(--ai-action) 10%, #fff);
--ai-navy-rgb: var(--kc-primary-rgb, 5, 79, 116);
--ai-navy-rgb: var(--kc-primary-rgb, 2, 62, 138);
--ai-accent-rgb: var(--ai-action-rgb);
--ai-canvas: var(--ai-bg);
--ai-header-bg: color-mix(in srgb, var(--ai-bg) 94%, transparent);
@ -55,25 +55,27 @@
--ai-routes-panel-w: 1080px;
--ai-acrylic-bg: rgba(255, 255, 255, 0.32);
--ai-acrylic-bg-primary: color-mix(in srgb, var(--ai-gold-soft) 42%, rgba(255, 255, 255, 0.38));
/* Ocean glass — cool tint aligned with GradientWaves */
--ai-glass-fill: linear-gradient(
165deg,
rgba(255, 255, 255, 0.5) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.36) 100%
color-mix(in srgb, var(--ai-sky) 10%, rgba(255, 255, 255, 0.46)) 0%,
rgba(255, 255, 255, 0.16) 48%,
color-mix(in srgb, var(--ai-action) 8%, rgba(255, 255, 255, 0.3)) 100%
);
--ai-glass-fill-strong: linear-gradient(
145deg,
rgba(255, 255, 255, 0.62) 0%,
rgba(255, 255, 255, 0.3) 52%,
rgba(255, 255, 255, 0.42) 100%
color-mix(in srgb, var(--ai-sky) 12%, rgba(255, 255, 255, 0.56)) 0%,
rgba(255, 255, 255, 0.24) 50%,
color-mix(in srgb, var(--ai-action) 10%, rgba(255, 255, 255, 0.36)) 100%
);
--ai-glass-border: rgba(255, 255, 255, 0.8);
--ai-glass-border: color-mix(in srgb, var(--ai-sky) 18%, rgba(255, 255, 255, 0.72));
/* Homepage forces blur off (paint budget) */
--ai-glass-blur: none;
--ai-glass-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 10px 28px rgba(15, 23, 42, 0.05),
0 0 0 1px rgba(18, 24, 32, 0.035);
inset 0 1px 0 rgba(255, 255, 255, 0.88),
inset 0 -1px 0 color-mix(in srgb, var(--ai-sky) 12%, transparent),
0 10px 28px rgba(2, 62, 138, 0.06),
0 0 0 1px rgba(2, 62, 138, 0.04);
--ai-hairline: linear-gradient(
90deg,
transparent 0%,
@ -537,7 +539,7 @@ body.command-home #header .logo img {
body.command-home #header .logo h1,
body.command-home #header .brand-lockup h1,
body.command-home #header .brand-lockup .brand-name {
color: var(--kc-primary, #054f74);
color: var(--kc-primary, #023e8a);
font-size: 1.45rem;
}
@ -2481,13 +2483,23 @@ body.command-home #navbar .site-nav-avatar:hover {
width: auto;
max-width: 100%;
margin: 0 auto 14px;
font-size: clamp(1.12rem, 1.6vw + 0.6rem, 2.05rem);
font-size: clamp(1.55rem, 2.2vw + 0.85rem, 2.65rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.22;
white-space: nowrap;
text-align: center;
color: var(--kc-primary, #054f74);
color: var(--kc-primary, #023e8a);
}
.ai-welcome-title span,
.ai-welcome-title .text-type,
.ai-welcome-title .text-type__content,
.ai-welcome-title .text-type__cursor {
font-size: inherit;
font-weight: inherit;
font-family: inherit;
letter-spacing: inherit;
}
.ai-welcome-title__dot {
@ -2562,18 +2574,29 @@ body.command-home #navbar .site-nav-avatar:hover {
.ai-welcome__command .ai-intent-door::before {
display: block;
opacity: 0.8;
opacity: 0.55;
mix-blend-mode: soft-light;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--ai-sky) 14%, rgba(255, 255, 255, 0.55)) 0%,
rgba(255, 255, 255, 0) 42%
),
radial-gradient(
92% 72% at 10% -8%,
rgba(255, 255, 255, 0.7),
transparent 58%
);
}
.ai-welcome__command .ai-intent-door::after {
display: block;
opacity: 0.55;
filter: blur(14px);
opacity: 0.42;
filter: blur(16px);
background: radial-gradient(
circle at 36% 36%,
rgba(255, 255, 255, 0.78),
rgba(255, 255, 255, 0.12) 46%,
color-mix(in srgb, var(--ai-sky) 22%, rgba(255, 255, 255, 0.65)),
rgba(255, 255, 255, 0.08) 46%,
transparent 72%
);
}
@ -2593,12 +2616,12 @@ body.command-home #navbar .site-nav-avatar:hover {
position: relative;
padding: 18px 16px 18px 18px;
background: var(--ai-glass-fill-strong);
border-color: rgba(255, 255, 255, 0.9);
border-color: color-mix(in srgb, var(--ai-sky) 22%, rgba(255, 255, 255, 0.82));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
inset 0 -1px 0 rgba(255, 255, 255, 0.22),
0 14px 34px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(18, 24, 32, 0.045);
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 color-mix(in srgb, var(--ai-sky) 14%, transparent),
0 14px 34px rgba(2, 62, 138, 0.08),
0 0 0 1px rgba(2, 62, 138, 0.05);
}
.ai-welcome__command .ai-intent-door.is-primary .ai-intent-door__icon {
@ -2619,18 +2642,18 @@ body.command-home #navbar .site-nav-avatar:hover {
}
.ai-welcome__command .ai-intent-door:hover {
border-color: rgba(255, 255, 255, 0.95);
border-color: color-mix(in srgb, var(--ai-sky) 28%, rgba(255, 255, 255, 0.88));
background:
linear-gradient(
165deg,
rgba(255, 255, 255, 0.58) 0%,
rgba(255, 255, 255, 0.28) 50%,
rgba(255, 255, 255, 0.4) 100%
color-mix(in srgb, var(--ai-sky) 14%, rgba(255, 255, 255, 0.52)) 0%,
rgba(255, 255, 255, 0.22) 50%,
color-mix(in srgb, var(--ai-action) 10%, rgba(255, 255, 255, 0.34)) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
0 14px 32px rgba(15, 23, 42, 0.07),
0 0 0 1px rgba(18, 24, 32, 0.04);
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 14px 32px rgba(2, 62, 138, 0.08),
0 0 0 1px rgba(2, 62, 138, 0.05);
transform: translateY(-1px);
}
@ -2638,14 +2661,14 @@ body.command-home #navbar .site-nav-avatar:hover {
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.66) 0%,
rgba(255, 255, 255, 0.36) 100%
color-mix(in srgb, var(--ai-sky) 16%, rgba(255, 255, 255, 0.6)) 0%,
color-mix(in srgb, var(--ai-action) 10%, rgba(255, 255, 255, 0.32)) 100%
);
border-color: rgba(255, 255, 255, 0.96);
border-color: color-mix(in srgb, var(--ai-sky) 30%, rgba(255, 255, 255, 0.9));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
0 16px 36px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(18, 24, 32, 0.045);
inset 0 1px 0 rgba(255, 255, 255, 0.94),
0 16px 36px rgba(2, 62, 138, 0.09),
0 0 0 1px rgba(2, 62, 138, 0.055);
}
.ai-welcome__command .ai-intent-door__title {
@ -2898,7 +2921,7 @@ body.command-home a.ai-welcome-cta:focus {
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
color: var(--kc-primary, #054f74);
color: var(--kc-primary, #023e8a);
}
.ai-welcome-stats__label {
@ -3630,18 +3653,29 @@ body.command-home a.ai-welcome-cta:focus {
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid var(--ai-border-soft);
background: color-mix(in srgb, #fff 78%, var(--ai-bg));
border: 1px solid var(--ai-glass-border);
background: color-mix(in srgb, var(--ai-sky) 8%, rgba(255, 255, 255, 0.38));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
0 4px 12px rgba(2, 62, 138, 0.04);
color: var(--ai-text-2);
font-size: 0.86rem;
font-weight: 650;
cursor: pointer;
transition:
color 0.18s var(--ai-ease),
border-color 0.18s var(--ai-ease),
background 0.18s var(--ai-ease),
box-shadow 0.18s var(--ai-ease);
}
.ai-home-dest:hover {
color: var(--ai-action);
border-color: color-mix(in srgb, var(--ai-action) 28%, #fff);
background: #fff;
border-color: color-mix(in srgb, var(--ai-sky) 32%, rgba(255, 255, 255, 0.78));
background: color-mix(in srgb, var(--ai-sky) 12%, rgba(255, 255, 255, 0.55));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.85),
0 6px 16px rgba(2, 62, 138, 0.07);
}
.ai-home-price-hint {
@ -3656,10 +3690,20 @@ body.command-home a.ai-welcome-cta:focus {
min-height: 72px;
padding: 12px 14px 12px 18px;
border-radius: 16px;
border-color: var(--ai-glass-border);
background: var(--ai-glass-fill);
backdrop-filter: var(--ai-glass-blur);
-webkit-backdrop-filter: var(--ai-glass-blur);
box-shadow: var(--ai-glass-shadow);
}
.ai-composer-wrap.is-welcome .ai-composer::before {
opacity: 0.75;
opacity: 0.55;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--ai-sky) 12%, rgba(255, 255, 255, 0.5)) 0%,
rgba(255, 255, 255, 0) 40%
);
}
.ai-composer-dock {
@ -3691,7 +3735,7 @@ body.command-home a.ai-welcome-cta:focus {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.78);
background: rgba(5, 79, 116, 0.92);
background: rgba(2, 62, 138, 0.92);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
@ -3701,7 +3745,7 @@ body.command-home a.ai-welcome-cta:focus {
pointer-events: none;
transform: translate(-50%, 6px);
opacity: 0;
box-shadow: 0 10px 28px rgba(5, 79, 116, 0.22);
box-shadow: 0 10px 28px rgba(2, 62, 138, 0.22);
transition:
opacity 0.28s var(--ai-ease),
transform 0.28s var(--ai-ease);
@ -3715,7 +3759,7 @@ body.command-home a.ai-welcome-cta:focus {
width: 10px;
height: 10px;
margin-left: -5px;
background: rgba(5, 79, 116, 0.92);
background: rgba(2, 62, 138, 0.92);
border-right: 1px solid rgba(255, 255, 255, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
transform: rotate(45deg) translateY(-4px);
@ -3740,7 +3784,7 @@ body.command-home a.ai-welcome-cta:focus {
box-shadow: none;
}
45% {
box-shadow: 0 0 0 3px rgba(5, 79, 116, 0.18);
box-shadow: 0 0 0 3px rgba(2, 62, 138, 0.18);
}
}
@ -4167,7 +4211,7 @@ body.command-home a.ai-welcome-cta:focus {
}
.ai-welcome-title {
font-size: clamp(1rem, 4.5vw, 1.4rem);
font-size: clamp(1.25rem, 5vw, 1.75rem);
}
.ai-welcome-sub {

View File

@ -0,0 +1,137 @@
# 轨迹中心开发指引
> 面向 **开发**。运营日常见同页「运营」Tab。
> **OMS 与 TrackCenter 是两个系统**:OMS 通过 HTTP/`Oms:TrackService` 调用轨迹中心;拉取/推送主逻辑在 TrackCenter(`tracks-master`)。
---
## 1. 架构
```
OMS 订单事件
→ Outbox(TrackUpsert / LifecycleAppend)
→ HTTP TrackCenter ProviderService
├── Order/UpsertSmallBag (建/更新轨迹订单)
└── Track/Upsert (追加生命周期节点)
→(TrackCenter 内)拉取服务商 / 标准化 / 推送业务方 / 预警
```
配置:`Oms:TrackService`(`BaseUrl`、`AppKey`、`AppSecret`)。
---
## 2. OMS 侧:何时 Upsert / Append
实现主要在 `OrderService.ParcelBilling.cs` 及 Messaging Handler。
| 触发 | OMS 方法 / 事件 | 说明 |
|------|-----------------|------|
| 预报排货成功 | `PublishForecastTrackAsync` | `AppendInitialTrackOnSuccess=true`(初始节点) |
| 入库排货成功 | `EnqueueSmallBagTrackUpsertAsync` | 默认不强制初始节点 |
| TrackNo 变化 | `Enqueue…WhenTrackNoChanged` / 同步 Upsert | 常带初始节点 |
| 入库扫描 | `OrderLifecycleTrackAppendIntegrationEvent` | 入库生命周期码 |
| 出库扫描 | 同上 | 出库生命周期码 |
| 手工/调试/批量 | 对应 Order API | 按入参 |
**要点:**
- 创单 HTTP **不直接**发轨迹;须预报(或入库)排货出 `ChannelCode`。
- 异步预报任务无 HttpContext:payload 须带创单捕获的 **Authorization**,否则轨迹网关 401。
- TrackNo 为空时可用 ClientNo 占位,真正 TrackNo 到达后再 Upsert。
### Upsert 关键字段
| 字段 | 含义 |
|------|------|
| Client | 客户编码 |
| OrderNo | **ServiceNo**(运单号) |
| TrackNo | 跟踪号(可占位) |
| CountryCode 等 | 收件信息 |
| ApiCode | 拉单路由:下游供应商码 → 产品/渠道 rules 快照 SupplierCode → live 等 |
| Channel / Product / OtherNo | 渠道、产品、客户单号 |
OMS 扩展字段(不一定进 Upsert Body):`Authorization`、`AppendInitialTrackOnSuccess`。
---
## 3. TrackCenter:主数据与配置模型
| 概念 | 说明 |
|------|------|
| AppKey | 业务方隔离 |
| 轨迹状态 / 内容 / 标记 | 标准化主数据 |
| 拉取配置 | AppKey + **服务商** 唯一;内容映射 + 优先级 |
| 推送配置 | AppKey + **客户** 唯一;内容映射按 Sort |
| 预警 | 运输 / 妥投 / 退回 |
订单类型:小包 / 大包 / 主单;可关联同步。
---
## 4. 轨迹拉取(TrackCenter)
路由前缀约 `/Track/PullConfig`(`TrackPullService`)。
```
拉取主配置
├── 内容映射 N(状态值 / 关键词 / 反向词 → TrackContent)
└── 优先级 N(客户 + 单号类型 + PullInterval 分钟,最小常被钳到 60)
```
新建主配置通常自动插入 `Customer=*` 兜底优先级(禁止删)。
**匹配顺序:** 状态值 Contains → 正向关键词 → 反向词否决 → 未映射(保留原文 hash 去重)。
**触发:**
- 手工:`POST /ProviderService/Track/Refresh`(无能更新订单 →「一个能更新的都没有」)
- 定时:`TrackPullTask` + Redis 锁(TTL≈PullInterval)
- 消费:`TrackRefreshEventHandler` → 命中则 `TrackUpsert`,否则写未映射
报文解析路径依赖应用配置 `InterfaceRelayParamsConfigOptions` 与页面字段对齐。
---
## 5. 轨迹推送(TrackCenter)
路由前缀约 `/Track/PushConfig`(`TrackPushService`)。
```
推送主配置(IsAutoPush / IsCanUpdate / IsAllPush / 时间格式…)
└── 内容映射(OrderNoType + Sort;推送给对方的 code/文案/状态)
```
**系统推送:** `TrackUpsertEventHandler` → `TrackPushEventHandler`
- 异常单一般不自动推
- Sort 顺序校验(可用 SwapTrackContentId)
- `IsAutoPush` 必须为 true
**手工推送:** `POST /ProviderService/Track/Push`(不强制顺序)。
推送记录在 ES;完成回调队列 `TrackPushCompletedSubscribe`。
---
## 6. 预警
配置:运输超时、妥投、退回;依赖分区与状态/标记因子。
推送内容变更可能触发退回类预警重算(`ReturnWarningHandler`)。
---
## 7. OMS ↔ Track 联调清单
1. `Oms:TrackService` 可达且鉴权正确
2. 预报/入库已排出 `ChannelCode` / `ApiCode`
3. 异步任务 Authorization 未丢
4. TrackCenter 侧该服务商拉取映射已配
5. 需要回传时客户推送 `IsAutoPush` + Sort 顺序
---
## 8. 关键代码位置
| 侧 | 位置 |
|----|------|
| OMS 发轨迹 | `OrderService.ParcelBilling.cs`(Publish/Enqueue)、`Messaging/*Track*` |
| TrackCenter 拉取/推送 | `TrackPullService` / `TrackPushService`、`TrackRefreshEventHandler` / `TrackPushEventHandler` |
| 前端菜单 | `/track/*`(OMS 前端代理到轨迹中心 API 以部署为准) |

View File

@ -0,0 +1,179 @@
# 轨迹中心运营指引
> 本文面向 **运营、客服** 同学,说明轨迹怎么从服务商进来、怎么标准化、怎么推给客户或 OMS,以及查不到轨迹时怎么排查。
> Handler / API / 表结构见同页 **「开发」** Tab。
---
## 1. 轨迹中心管什么?
客户问「包裹到哪了」,背后通常是:
```
服务商原始轨迹
→ 轨迹中心按规则翻译成「标准节点」
→ 写到轨迹订单
→(可选)推送给客户系统 / OMS
```
| 你要配的 | 作用 |
|----------|------|
| **轨迹状态 / 内容 / 标记** | 统一话术与节点(如「已揽收」「运输中」「签收」) |
| **轨迹拉取** | 怎么问服务商要轨迹、原始文案怎么对上标准节点 |
| **轨迹推送** | 标准节点怎么回传给客户系统 |
| **轨迹订单** | 按单查轨迹、手工补录、刷新、推送 |
| **预警** | 超时未更新、妥投/退回异常提醒 |
---
## 2. 核心概念(白话)
| 概念 | 白话 |
|------|------|
| **轨迹订单** | 一张「查件单」:有客户单号、运单号、最新轨迹摘要 |
| **轨迹明细** | 时间轴上的每一条(几点、在哪、什么内容) |
| **轨迹内容** | 对外展示的标准文案(可中英文),并挂一个状态 |
| **轨迹拉取** | 按服务商配置:多久拉一次、用哪种单号、原始状态如何映射 |
| **轨迹推送** | 按客户配置:哪些节点要推、推送顺序、是否自动推 |
| **未映射轨迹** | 拉到了原文,但对不上你们的映射 → 订单上看得到「生数据」,标准节点没变 |
轨迹来源常见三种:服务商拉取、系统内部、**人工补录**。
---
## 3. 推荐配置顺序(新服务商接入)
```
① 轨迹状态:建好「已揽收 / 运输中 / 签收 …」
② 轨迹内容:每条标准话术关联对应状态
③ 轨迹标记(可选):异常、退回等标签
④ 确认开发/网关侧拉取接口已通(你这边有 App、服务商编码)
⑤ 轨迹拉取:按服务商建主配置 → 内容映射 → 拉取频率
⑥ 轨迹推送(若客户要回调):按客户建配置 → 节点顺序 → 打开自动推送
⑦ 用测试单「刷新轨迹」,看时间轴与推送记录
```
---
## 4. 轨迹拉取(怎么把服务商话翻译成咱们的话)
### 4.1 三层配置
```
某个服务商的拉取配置
├── 主配置:何时开始拉、何时停、静默多久
├── 内容映射:服务商状态/关键词 → 标准「轨迹内容」
└── 拉取优先级:用客户单号还是跟踪单号、隔多少分钟拉一次
```
系统新建主配置时,通常会自动带一条 **全部客户 `*`** 的兜底优先级(不要删)。
### 4.2 内容映射怎么配(举例)
服务商返回状态 `IT`、描述里有 “In Transit”。
| 映射字段 | 示例填法 |
|----------|----------|
| 对应标准轨迹内容 | 选「运输中」那条内容 |
| 服务商状态值 | `IT`(多个用英文逗号) |
| 正向关键词 | `In Transit,运输中`(描述包含任一即可能命中) |
| 反向关键词 | 如 `Returned`——描述里出现则 **否决** 本条,避免误匹配 |
**匹配顺序(记住即可):** 先按状态值对 → 对不上再按关键词 → 再被反向词否决 → 仍不行就变成 **未映射**。
**例:**
- 状态 `IT` 已配置 → 直接变成「运输中」
- 状态是空的,描述含 `Arrived at facility`,关键词也配了 → 也能命中
- 关键词命中了「签收」,但描述含反向词 `failed delivery` → 本条作废,继续找别的映射
同一服务商下,同一标准内容、同一状态值、同一关键词不要配重复行,保存会冲突。
### 4.3 日常操作
| 场景 | 怎么做 |
|------|--------|
| 单票催轨迹 | 轨迹订单 → 打开详情 → **刷新轨迹** |
| 看有没有推成功 | 详情 → **推送记录** |
| 服务商有节点、系统没有 | 先看是否 **未映射**;补映射后再刷新 |
| 客服口头告知节点 | 详情里 **人工补录** 一条轨迹 |
刷新提示「一个能更新的都没有」:订单不存在、App 不对、或该服务商根本没配拉取。
---
## 5. 轨迹推送(回传给客户系统)
### 5.1 按客户配置
每个客户一般一条推送主配置:
| 开关 | 含义 |
|------|------|
| 自动推送 | 轨迹入库后是否自动回调 |
| 允许更新 | 节点变更后是否再推修改 |
| 全量推送 | 是否把历史节点一起带上 |
再为「小包 / 大包 / 主单」分别配 **推送内容映射**,并用 **排序** 规定业务节点顺序(数字越小越靠前)。
### 5.2 顺序举例
映射顺序:1 已揽收 → 2 运输中 → 3 签收。
- 上次已推到「已揽收」,这次来「运输中」→ 正常
- 上次还是「已揽收」,这次直接「签收」→ 可能报 **推送顺序异常**(除非配置了可交换节点)
- **异常单** 一般不会自动推,需要人工推
手工推送通常 **不卡** 顺序,适合客服救急;仍建议事后把映射顺序配对。
### 5.3 客户收不到推送时
1. 该客户是否打开 **自动推送**
2. 当前节点是否在推送映射里
3. 推送记录里失败原因(顺序、未关联服务商等)
4. 是否异常单(只能手工推)
---
## 6. 预警(超时 / 妥投 / 退回)
| 类型 | 典型用途 |
|------|----------|
| 运输预警 | 长时间停在某状态没更新 |
| 妥投预警 | 签收相关 KPI |
| 退回预警 | 退回类异常 |
配置时要对好:产品、国家/分区、起始与结束状态、时限。
未触发时先查:分区是否覆盖、状态是否配错、轨迹时间时区是否乱。
---
## 7. 各菜单速查
| 菜单 | 路径 | 用途 |
|------|------|------|
| 新手指引 | `/track/guide` | 本文 |
| 轨迹订单 | `/track/order` | 查单、时间轴、补录、刷新、推送 |
| 预警订单 | `/track/warningOrder` | 处理已触发预警 |
| 轨迹状态 / 内容 / 标记 | `/track/state` 等 | 主数据 |
| 轨迹拉取 | `/track/pull` | 服务商拉取与映射 |
| 轨迹推送 | `/track/push` | 客户回调与映射 |
| 预警配置 | `/track/kpiEarlyWarningConfig` | 规则 |
---
## 8. 客服常见问答
| 客户说法 | 可能原因 | 怎么处理 |
|----------|----------|----------|
| 「官网有轨迹,你们没有」 | 未映射 / 拉取间隔还没到 / 单号类型不对 | 刷新;看未映射原文;补映射;查优先级用的是跟踪号还是客户单号 |
| 「一直显示已预报」 | 服务商尚未上网,或拉取未开始 | 看主配置开始节点、静默时间;问仓是否已出库 |
| 「节点乱跳 / 缺段」 | 映射关键词过宽、或推送顺序与业务不一致 | 收紧关键词、加反向词;调整推送 Sort |
| 「我们系统没收回调」 | 自动推送关了、顺序卡了、异常单 | 查推送记录;必要时手工推 |
---
## 9. 一句话心智模型
> **拉取 = 把服务商的话翻译成公司标准节点;推送 = 按约定顺序告诉客户。**
> 看到原始轨迹却没有标准节点,优先查 **内容映射**,不是「系统没拉到」。

View File

@ -1,550 +0,0 @@
# 轨迹中心新手指引
> 本文面向第一次接触 **轨迹中心(Track Center)** 的运营、客服与开发同学,帮助你理解「轨迹如何从服务商拉取、标准化入库、再推送给业务方」的完整链路,以及各菜单模块分别做什么。
---
## 1. 系统是什么?
**轨迹中心**是一套 **物流轨迹采集、标准化、存储与推送** 的中台服务(后端项目 `TrackCenter`)。它的核心目标是:
- 按 **应用(AppKey)** 隔离不同业务方的轨迹数据;
- 维护 **轨迹状态 / 轨迹内容 / 轨迹标记** 等主数据,把各服务商的原始描述映射为统一模型;
- 通过 **轨迹拉取配置** 向服务商拉取轨迹,经映射后写入订单;
- 通过 **轨迹推送配置** 把标准化后的轨迹推送给 OMS、客户系统或第三方回调;
- 提供 **轨迹订单** 查询、手工补录、刷新与推送能力;
- 基于规则做 **运输 / 妥投 / 退回** 等 KPI 预警。
你可以把它理解为:**轨迹主数据 + 拉取映射 + 推送映射 + 订单轨迹仓 + 预警引擎** 的组合。
---
## 2. 核心概念速查
| 概念 | 说明 | 在系统中的位置 |
|------|------|----------------|
| **应用(AppKey)** | 业务方身份,轨迹配置与订单均按 AppKey 隔离 | 各配置页、轨迹订单列表 |
| **轨迹订单** | 承载客户单号、尾程单号、最新轨迹摘要等的订单实体 | 菜单:轨迹订单 |
| **轨迹明细** | 单条轨迹事件(时间、时区、地点、内容、状态等),存于 ES | 轨迹订单详情 → 轨迹 Tab |
| **轨迹状态** | 轨迹节点编码(如已揽收、运输中、签收),用于状态机与预警 | 菜单:轨迹状态 |
| **轨迹内容** | 对外展示的轨迹文案(中/英),可关联状态与标记 | 菜单:轨迹内容 |
| **轨迹标记** | 轨迹标签(如异常、退回),用于筛选与预警因子 | 菜单:轨迹标记 |
| **轨迹拉取** | 某 App + 服务商 + 订单类型下的拉取规则与内容映射 | 菜单:轨迹拉取 |
| **轨迹推送** | 向业务方推送轨迹时的映射与优先级 | 菜单:轨迹推送 |
| **预警分区** | KPI 统计用的地理/业务分区 | 菜单:预警分区 |
| **预警配置** | 运输超时、妥投、退回等预警规则 | 菜单:预警配置 |
| **预警订单** | 已触发的预警明细与处理 | 菜单:预警订单 |
### 轨迹来源(TrackSourceEnum)
| 值 | 含义 |
|----|------|
| 1 | 外部服务商(拉取或接口回写) |
| 2 | 内部系统 |
| 3 | 人工补录 |
### 订单类型(TrackOrderType)
| 值 | 含义 |
|----|------|
| 1 | 小包 |
| 2 | 大包 |
| 4 | 主单 |
小包、大包、主单可存在 **关联关系**;拉取/更新轨迹后,系统可按关联关系 **向下游订单同步** 轨迹(见 CAP `TrackUpsert` 处理逻辑)。
---
## 3. 端到端业务流程
### 3.1 参与方
```
业务系统 / OMS / WMS
│
▼
轨迹中心(TrackCenter)
│
├── 订单创建 / 订阅(ProviderService)
├── 定时或手工拉取(接口转发 → 服务商)
├── 入参映射 → 轨迹内容/状态匹配 → 写入 ES
├── 推送映射 → 回调业务方
└── 预警引擎(运输/妥投/退回)
│
▼
外部轨迹服务商 API
```
### 3.2 轨迹拉取(概要)
配置分三层:**拉取主配置** → **内容映射** → **拉取优先级**。触发后由 `TrackRefreshEventHandler` 解析服务商报文并匹配内容映射,命中则发布 `TrackUpsert` 写入 ES;未命中则写入「未映射」原始轨迹。详见 [4.4 轨迹拉取详解](#44-轨迹拉取详解)。
### 3.3 轨迹推送(概要)
按 **AppKey + 客户(Customer)** 唯一维护推送主配置与 **推送内容映射**(含 Sort 顺序)。轨迹新增/修改后由 `TrackPushEventHandler` 组装报文并经接口转发回调业务方,结果写入 ES 推送记录。详见 [4.5 轨迹推送详解](#45-轨迹推送详解)。
### 3.4 人工补录
在 **轨迹订单** 详情中可新增/编辑轨迹明细(来源为「人工」),适用于服务商无节点、客服补录等场景。补录后同样走标准化与可选推送、预警逻辑。
---
## 4. 各菜单功能说明
| 菜单路径 | 主要功能 | 典型操作 |
|----------|----------|----------|
| **新手指引** `/track/guide` | 本文档,系统说明与配置顺序 | 首次接入必读 |
| **轨迹订单** `/track/order` | 订单列表、轨迹时间轴、补录、刷新、推送 | 按单号查询 → 打开详情 → 刷新/推送 |
| **预警订单** `/track/warningOrder` | 运输/妥投/退回预警单处理 | 筛选 → 查看详情 → 关闭/跟进 |
| **轨迹状态** `/track/state` | 维护状态编码与中英文名称 | 新增状态 → 与内容、预警规则关联 |
| **轨迹标记** `/track/flag` | 维护轨迹标签 | 配置异常类等标记 |
| **轨迹内容** `/track/content` | 维护标准轨迹文案,关联状态 | 配置展示文案、英文文案 |
| **轨迹拉取** `/track/pull` | 拉取主配置、内容映射、优先级 | 选服务商 → 建拉取配置 → 映射内容 |
| **轨迹推送** `/track/push` | 推送主配置、内容映射 | 配置推送字段与优先级 |
| **预警配置** `/track/kpiEarlyWarningConfig` | 运输、妥投、退回预警规则 | 按产品+国家配置时效与规则 |
### 4.1 轨迹订单详情 Tab
| Tab | 作用 |
|-----|------|
| **详情** | 订单主信息与轨迹列表 |
| **推送记录** | 历史推送报文与结果 |
| **日志** | 订单操作日志 |
| **拉取配置** | 当前订单关联的拉取配置快照 |
| **订单轨迹 / 外部轨迹** | 内外部轨迹时间轴展示 |
### 4.2 轨迹拉取 / 推送页面布局
| 页面 | 左侧 | 中间 / 主区 | 右侧 / 子表 |
|------|------|-------------|-------------|
| **轨迹拉取** `/track/pull` | 服务商列表 | 拉取主配置列表 | 内容映射、拉取优先级 |
| **轨迹推送** `/track/push` | 客户列表 | 推送主配置 | 推送内容映射(可排序) |
### 4.3 预警配置类型
| 类型 | 说明 |
|------|------|
| **运输预警** | 从起始状态到目标状态的超时未更新 |
| **妥投预警** | 签收相关 KPI |
| **退回预警** | 退回类轨迹异常 |
---
## 4.4 轨迹拉取详解
后端路由前缀:`/Track/PullConfig`(`TrackPullService`)。数据表:`t_track_pull_config`、`t_track_pull_content_config`、`t_track_pull_priority_config`。
### 4.4.1 三层配置模型
```
AppKey + ServiceProviderCode(唯一)
├── 拉取主配置(TrackPullConfigEntity)
├── 内容映射 N 条(TrackPullContentConfigEntity)→ 关联「轨迹内容」主数据
└── 拉取优先级 N 条(TrackPullPriorityEntity)→ 决定用哪种单号、多久拉一次
```
**唯一性约束**:同一 `AppKey + ServiceProviderCode` 只能有一条拉取主配置(新增/修改时后端校验,重复会报「配置已经存在」)。
**新增主配置时的默认行为**:保存成功后,系统**自动插入一条兜底优先级**(不可删除):
| 字段 | 默认值 | 含义 |
|------|--------|------|
| Customer | `*`(全部客户) | 未单独配置客户时使用 |
| OrderNoType | 跟踪单号 | 用尾程/跟踪单号向服务商查询 |
| Priority | 999 | 数值越大优先级越低,兜底排最后 |
| PullInterval | 720 分钟(12 小时) | 定时拉取间隔 |
### 4.4.2 拉取主配置字段
| 字段 | 说明 | 业务用途 |
|------|------|----------|
| AppKey | 应用标识 | 与订单、接口转发上下文隔离 |
| ServiceProviderCode | 服务商编码 | 与订单上的服务商、定时任务分组一致 |
| StartTrackCode | 开始轨迹 code | 订单到达该轨迹节点后,才进入正常拉取周期(配合订单拉取状态扫描) |
| EndTrackCode | 结束轨迹 code | 订单到达该节点后进入结束态;配合 **SilenceAfterEnd** 停止拉取 |
| SilenceBeforeStart | 开始前静默(小时) | 未达 StartTrackCode 前的等待;订单拉取状态为 `SilenceBeforeStart`,由定时任务扫描启动 |
| SilenceAfterEnd | 结束后静默(小时) | 签收等结束后仍可能短暂拉取,之后停止 |
| TrackExtraNames | 扩展字段名(逗号分隔) | 从服务商单条轨迹 JSON 中按路径提取字段,合并为 `Extra` 存入 ES |
前端编辑项中还包含 **报文解析** 相关字段(`EditTrackPullConfigInput`,写入配置供消费端使用):
| 字段 | 说明 |
|------|------|
| ParseMethod | 1=取映射后标准返参,2=取服务商原始返参 |
| OrderPath | 订单节点在响应 JSON 中的路径 |
| OrderNoName | 单号字段名(用于反查主单) |
| TrackDetailsPath | 轨迹明细数组/对象路径 |
| TrackStatusName / TrackContentName / TrackContentCodeName | 状态、内容、代码字段名 |
| TrackPlaceName | 发生地字段名 |
| TrackIsoTimeName / TrackTimeName / TrackTimeZoneName | ISO 时间、本地时间、时区字段名 |
| OrderType / Customer | 订单类型、客户(与优先级配合) |
> 实际解析时,`TrackRefreshEventHandler` 主要使用全局 `InterfaceRelayParamsConfigOptions` 中的路径配置;请与运维确认应用配置与页面配置一致。
### 4.4.3 内容映射(拉取内容配置)
每条映射把 **服务商原始轨迹** 对应到一条 **轨迹内容**(`TrackContentId`),从而得到标准 `TrackCode`、状态、中英文文案。
| 字段 | 说明 |
|------|------|
| TrackContentId | 关联「轨迹内容」主数据,决定入库后的标准轨迹 code |
| TrackStatusValue | 服务商状态值,**多个用英文逗号分隔**;与报文中状态字段精确匹配 |
| TrackKeyword | 正向关键词,逗号分隔;轨迹描述 **包含** 任一关键词即候选 |
| TrackKeywordReverse | 反向关键词;若描述 **包含** 反向词,则 **否决** 该条映射 |
| TrackPlaceValue | 发生地兜底枚举:返回值、目的港、起运港、目的国、起运国、目的城市、起运城市 |
| TrackTimePlaceValue | 时区发生地兜底(与地点枚举类似) |
**保存校验(避免重复映射)**:
- 同一拉取配置下,**TrackContentId** 不可重复;
- **TrackStatusValue** 中任一值不可与其它行的状态集合冲突;
- **TrackKeyword** 中任一关键词不可与其它行冲突。
**运行时匹配顺序**(`TrackRefreshEventHandler.HandleTrackAsync`):
1. **轨迹状态**:用 `TrackStatusName` 从报文取值,与某行的 `TrackStatusValues` 列表匹配(`Contains`);
2. **轨迹内容关键词**:若状态未命中,则遍历各映射的 `TrackKeywords`,描述包含关键词则命中;
3. **反向关键词**:命中后若描述含 `TrackKeywordReverse` 中任一词,则视为未命中;
4. 仍未命中:写入 ES 为 **未映射轨迹**(`TrackContentId = -1`,保留原始内容与报文 hash,默认 `IsExternal = false`)。
**去重**:同一订单下,相同原始报文 hash(`OriginTrackDataHash`)已存在则跳过,避免重复消费。
### 4.4.4 拉取优先级
按 **客户 + 订单号类型** 细分拉取策略(表 `t_track_pull_priority_config`)。
**订单号类型(TrackOrderNoType)**:
| 值 | 含义 |
|----|------|
| 1 | 客户单号 |
| 2 | 跟踪单号 |
| 4 | 其他单号 |
| 字段 | 说明 |
|------|------|
| Customer | 客户简称;`*` 表示全部客户(系统兜底行) |
| OrderNoType | 调用服务商 API 时使用的单号类型 |
| Priority | 数值越小越优先(与注释「最优先级、最匹配」一致) |
| PullInterval | 拉取频率,**单位:分钟**;保存时若小于 60,后端强制改为 60 |
**唯一性**:同一拉取配置下 `Customer + OrderNoType` 不可重复。
**删除限制**:`Customer = *` 的兜底行 **禁止删除**,否则会报「兜底配置禁止被删除」。
### 4.4.5 拉取触发与执行链路
#### 手工刷新
`POST /ProviderService/Track/Refresh`
1. 单号去重、去空;
2. 按 AppKey + 单号查询可刷新的订单(需已配置拉取、订单存在);
3. 按 `ServiceProviderCode + AppKey + OrderType` 分组;
4. 每组调用 `BatchForwardThenPushToQueueAsync`(拉取标准接口编码来自应用配置 `GetTrackInterfaceCode`);
5. 无可用订单时抛出:**「一个能更新的都没有」**。
#### 定时拉取
`POST /TimerTask/Refresh`(`TrackPullTask`,Hangfire)
1. 关联查询所有 **拉取主配置 + 优先级行**;
2. 为每条优先级配置创建后台任务 `RefreshForConfigAsync`;
3. Redis 锁 key:`TrackPullKey(config)`,TTL = `PullInterval * 60` 秒,防止同一策略并发重复拉取;
4. 分页查询待拉取订单,按订单类型分组批量转发;
5. 队列名由 `TrackAppOptions.TrackPullTaskLevel` 决定。
#### 消费拉取结果
队列订阅:`TrackRefreshSubscribe` → `TrackRefreshEventHandler`
1. 转发失败(`IsSuccess = false`)直接记日志返回;
2. 从 `Attach` 反序列化 `TrackRefreshAttach`(服务商、AppKey、OrderType);
3. Redis 读取 `AppKey:ServiceProviderCode` 下的拉取配置 + 内容映射列表(`TrackPullCacheService`,编辑后自动刷新缓存);
4. 按 `OrderPath` 解析订单列表,再按 `TrackDetailsPath` 遍历轨迹节点;
5. 用平台单号反查主单(`GetTrackOrderByProviderAndOrderNo`),找不到则告警跳过;
6. 匹配成功 → 发布 `TrackUpsertForRefreshSubscribe`;匹配失败 → 仅写未映射 ES 记录。
### 4.4.6 拉取相关 API
| 接口 | 说明 |
|------|------|
| `POST /Track/PullConfig/GetPage` | 主配置分页(可按 AppKey、服务商名关键字) |
| `GET /Track/PullConfig/GetDetail` | 主配置详情 |
| `POST /Track/PullConfig/Edit` | 主配置增删改(Options:新增/修改/删除) |
| `POST /Track/PullConfig/GetContentPage` | 内容映射分页 |
| `GET /Track/PullConfig/GetContentDetail` | 内容映射详情 |
| `POST /Track/PullConfig/EditContent` | 内容映射增删改 |
| `POST /Track/PullConfig/GetPriorityPage` | 优先级分页 |
| `GET /Track/PullConfig/GetPriorityDetail` | 优先级详情 |
| `POST /Track/PullConfig/EditPriority` | 优先级增删改 |
| `POST /ProviderService/Track/Refresh` | 手工刷新 |
---
## 4.5 轨迹推送详解
后端路由前缀:`/Track/PushConfig`(`TrackPushService`)。数据表:`t_track_push_config`、`t_track_push_content_config`。推送记录存 **Elasticsearch**(`TrackPushRecordIndex`)。
### 4.5.1 两层配置模型
```
AppKey + Customer(唯一)
├── 推送主配置(TrackPushConfigEntity)
└── 推送内容映射 N 条(TrackPushContentConfigEntity),按 OrderNoType + Sort 排序
```
**唯一性约束**:同一 **Customer** 只能有一条推送主配置(按客户维度,与 AppKey 组合使用)。
**缓存**:`TrackPushCacheService` 以 `AppKey:Customer` 为 key 缓存内容映射列表(TTL 约 3 天;无数据时 5 秒),主配置或内容变更后自动 `SetPushContentConfigsAsync` 刷新。
### 4.5.2 推送主配置字段
| 字段 | 说明 |
|------|------|
| AppKey | 应用标识 |
| Customer | 客户(外部主体 ID),推送策略按客户隔离 |
| ServiceProviderCode | 服务商编码;推送前校验,为空会报「未关联服务商,无法推送」 |
| IsAutoPush | 是否自动推送;为 false 时系统推送会被拦截,需手工推送 |
| IsCanUpdate | 是否允许推送「修改」类轨迹 |
| IsCanDelete | 是否允许推送「删除」类轨迹(当前删除分支未实现推送) |
| IsAllPush | 全量推送:为 true 时会把历史已推轨迹一并组装进报文 |
| IsAllPushWithLatest | 全量模式下,同 code + 同比较字段时替换旧条,保证最新一条 |
| DateTimeFormatType | 时间格式:0 默认、1 时间戳、2 时间+时区 |
| DateTimeFormat | 时间模板;保存时后端会返回「格式化样例」供核对 |
| TimezoneFormat | 时区模板;若填时区则必须同时填时间格式 |
**时间格式校验**(`CheckDateTimeFormat`):
- 类型为 **时间戳**:输出 Unix 时间样例;
- 类型为 **时间+时区**:可仅时间、或时间+时区组合;填了时区未填时间会报错;
- 默认:`yyyy-MM-ddTHH:mm:ss.fffK`。
### 4.5.3 推送内容映射
每条映射描述:**当订单出现某标准轨迹 code 时,推送给业务方的字段取值**。
| 字段 | 说明 |
|------|------|
| OrderNoType | 订单类型:小包 / 大包 / 主单(与拉取 OrderType 一致) |
| TrackContentId | 关联轨迹内容主数据 |
| Sort | 推送顺序,**数值越小越靠前**;新增时自动 `Max(Sort)+1` |
| TrackContentCodeValue | 推送给对方的轨迹 code 参数值 |
| TrackContentValue | 中文描述参数值 |
| TrackEnContentValue | 英文描述参数值 |
| TrackStatusValue | 状态参数值 |
| SwapTrackContentId | 可交换的轨迹内容 ID;用于顺序校验时的「等价节点」 |
| ExtraConfig | JSON:把订单表字段或轨迹 Extra 映射到推送体 `extra`(如 `t_order.xxx`) |
**唯一性**(同一推送配置 + 同一 OrderNoType 下不可重复):
- TrackContentId
- TrackContentCodeValue
- TrackStatusValue
- TrackContentValue
- TrackEnContentValue
**排序**:`POST /Track/PushConfig/SortContent` 可拖拽调整 Sort;删除某条后,同 OrderNoType 下更大 Sort 的记录会自动减 1。
**配置变更与预警**:修改/删除推送内容映射后,会异步触发 `ReturnWarningHandler.PushConfigChangedHandleAsync` 重新计算退回类预警。
### 4.5.4 推送触发方式
#### 自动推送(系统)
轨迹入库/更新后,`TrackUpsertEventHandler` 在满足条件时发布 `TrackPushSubscribe`(`TrackPushEvent`)。
`TrackPushEventHandler` 处理逻辑要点:
1. 按 AppKey + Customer 加载推送配置,无配置则直接 return;
2. 按 **OrderNoType** 分组,每组内按 **Sort 升序** 得到推送顺序链表;
3. **新增(OptionType.新增)且 OperationMan 为空** → 走系统推送 `BuiltSystemPushItemsAsync`:
- 异常单(`QuestionType = Error`)**不自动推**,只能人工;
- 必须在映射中找到当前 `TrackCode` 对应行;
- `IsAutoPush` 必须为 true;
- **顺序校验**:订单上 `LatestPushTrackCode` 须与本次推送构成「上一条 + 1」或命中 **SwapTrackContentId** 交换节点,否则报「推送顺序异常」;
4. **修改(OptionType.修改)** → `DoUpdateAsync`,需 `IsCanUpdate`,调用更新类标准接口编码(`TrackUpdateInterfaceCode`)。
#### 手工推送
`POST /ProviderService/Track/Push`
- 按 AppKey、OrderType、OrderNos 查订单;
- 从 ES 取指定 `TrackCode` 的轨迹;支持 `CompareFields` + `CompareValue` 按扩展字段过滤;
- 每个订单取 **CreateTime 最新** 的一条;
- 发布 `TrackManualPushSubscribe`,带 `OperationMan`(当前用户名)。
手工推送 **不校验** 是否已推过、**不强制** 顺序(但仍受 `IsAllPush` 等配置影响)。
#### 推送报文组装
`BuiltPushItem` 生成的业务字段(写入对方回调 Body):
| 字段 | 来源 |
|------|------|
| date | 按 DateTimeFormatType 格式化 TrackTime + TimeZone |
| timeZone | 轨迹发生时区 |
| location | 轨迹发生地 |
| description / descriptionCn | 映射中的英/中文参数值 |
| code / status | 映射中的 code、状态参数值 |
| extra | 轨迹 Extra + ExtraConfig 解析订单字段合并 |
`IsAllPush = true` 时:读取最近一次成功推送记录,合并历史节点,再追加当前节点;`IsAllPushWithLatest` 控制同 code 是否去重替换。
### 4.5.5 推送完成与记录
- 批量调用 `BatchForwardThenPushToQueueAsync`(`PushTrackInterfaceCode`),回调队列 `TrackPushCompletedSubscribe`;
- 成功/失败通过 `BroadcastingService` 发 RabbitMQ:`TrackPush.{AppKey}`;
- 订单详情 **推送记录** Tab:`GET /ProviderService/Track/GetTrackPushRecords`;
- 高级查询:`POST /ProviderService/Track/QueryTrackPushRecords`(支持成败、中英文内容、轨迹时间、推送时间范围);
- 获取可推轨迹 code 列表:`GET /ProviderService/Track/GetTrackPushContentList?appKey=&customer=`。
### 4.5.6 推送相关 API
| 接口 | 说明 |
|------|------|
| `POST /Track/PushConfig/GetPage` | 主配置分页 |
| `GET /Track/PushConfig/GetDetail` | 主配置详情 |
| `POST /Track/PushConfig/Edit` | 主配置增删改,返回时间格式样例文案 |
| `POST /Track/PushConfig/GetContentPage` | 内容映射分页 |
| `GET /Track/PushConfig/GetContentDetail` | 内容映射详情 |
| `POST /Track/PushConfig/EditContent` | 内容映射增删改 |
| `POST /Track/PushConfig/SortContent` | 调整 Sort 顺序 |
| `POST /ProviderService/Track/Push` | 手工推送 |
| `GET /ProviderService/Track/GetTrackPushContentList` | 可推送轨迹 code 列表 |
| `GET /ProviderService/Track/GetTrackPushRecords` | 单订单推送记录 |
| `POST /ProviderService/Track/QueryTrackPushRecords` | 批量查询推送记录 |
### 4.5.7 拉取 vs 推送 对照
| 维度 | 轨迹拉取 | 轨迹推送 |
|------|----------|----------|
| 配置主键 | AppKey + **服务商** | AppKey + **客户** |
| 子配置维度 | 内容映射 + **优先级(客户+单号类型+频率)** | 内容映射(**订单类型+Sort 顺序**) |
| 核心目标 | 服务商报文 → 标准轨迹内容 | 标准轨迹 → 业务方回调字段 |
| 顺序含义 | 内容映射为 **匹配规则**,无全局顺序 | Sort 为 **业务推送节点顺序** |
| 定时任务 | `TrackPullTask` 按 PullInterval | 无(由轨迹变更事件驱动) |
| 典型失败 | 未映射、单号找不到主单 | 顺序错误、未开自动推送、异常单 |
---
## 5. 新手推荐配置路径
按以下顺序操作,可最快跑通「拉取 → 入库 → 推送」:
### 第一步:维护主数据
1. **轨迹状态**:创建业务需要的状态编码(与 OMS 状态机对齐)。
2. **轨迹标记**(可选):配置异常、退回等标记。
3. **轨迹内容**:为每个展示节点配置中英文文案,并关联对应 **轨迹状态**。
### 第二步:确认拉取接口可用
1. 与开发确认轨迹服务商的 **拉取接口** 已在网关侧配置完成(含鉴权、签名、入参/出参映射)。
2. 记录拉取配置所需的 **标准接口编码**、**服务商接口编码**,并确认对应配置已 **发布生效**。
3. 确保当前 **AppKey** 已具备调用该拉取接口的权限。
### 第三步:配置轨迹拉取
1. 进入 **轨迹拉取**,选择服务商,新建主配置(AppKey + 服务商唯一)。
2. 填写 **StartTrackCode / EndTrackCode**、静默小时数、扩展字段名(若需要)。
3. 为常见服务商状态/描述添加 **内容映射**(先状态精确匹配,再关键词;注意反向词)。
4. 按客户补充 **拉取优先级**(单号类型 + PullInterval);保留系统自带的 `*` 兜底行。
5. 确认网关拉取接口可用后,在轨迹订单试 **刷新轨迹**。
### 第四步:配置轨迹推送(若需回传业务方)
1. 进入 **轨迹推送**,按 **客户** 新建主配置(每客户一条)。
2. 开启 **IsAutoPush**,配置时间格式并保存(核对返回的样例字符串)。
3. 按 **小包/大包/主单** 分别维护推送内容映射,设置 **Sort** 顺序(与业务节点顺序一致)。
4. 需要一次推全流程时开启 **IsAllPush**;仅需最新节点时配合 **IsAllPushWithLatest**。
5. 在业务侧准备好接收回调;用 **手工推送** 或观察 **推送记录** 验证。
### 第五步:验证
1. 在 **轨迹订单** 中按单号查询,或等待订单订阅入库。
2. 使用 **刷新轨迹** 触发拉取,在详情中查看新轨迹节点。
3. 查看 **推送记录** 确认业务方是否收到。
4. 若配置了预警,在 **预警订单** 中观察是否按规则触发。
---
## 6. 网关接口编码(拉取 / 推送)
定时任务与手工刷新/推送通过 **接口转发** 调用,编码来自应用配置(非轨迹表字段):
| 场景 | 配置项(示例名) | 订阅队列 |
|------|------------------|----------|
| 拉取轨迹 | `GetTrackInterfaceCode` | `TrackRefreshSubscribe` |
| 推送新增 | `PushTrackInterfaceCode` | `TrackPushCompletedSubscribe` |
| 推送修改 | `TrackUpdateInterfaceCode` | `TrackPushCompletedSubscribe` |
拉取/推送内容映射变更后会刷新 **Redis 缓存**;网关侧接口映射须 **发布生效** 后才会改变 HTTP 行为。
---
## 7. 主要后端 API(TrackCenter)
供联调与排查参考(前缀以部署环境为准,如 `/TrackCenter`):
| 路由 | 说明 |
|------|------|
| `POST /Track/Order/GetTrackOrderList` | 轨迹订单分页列表 |
| `POST /Track/Order/UpdateOrderStatus` | 更新订单完成状态等 |
| `POST /Track/Status/*` | 轨迹状态 CRUD |
| `POST /Track/Content/*` | 轨迹内容 CRUD |
| `POST /Track/Flag/*` | 轨迹标记 CRUD |
| `POST /Track/PullConfig/*` | 拉取配置与内容映射 |
| `POST /Track/PushConfig/*` | 推送配置与内容映射 |
| `POST /ProviderService/Track/Refresh` | 手工刷新轨迹 |
| `POST /ProviderService/Track/Push` | 手工推送轨迹 |
| `POST /Warning/*` | 预警查询与处理 |
| `POST /ForewarningService/*` | 预警分区与规则 |
---
## 8. 常见问题
### Q1:刷新轨迹提示「一个能更新的都没有」?
- 检查订单是否存在、AppKey 是否匹配;
- 检查是否已配置对应服务商的 **轨迹拉取**;
- 检查拉取配置中的 **接口编码** 是否正确,网关侧映射是否已 **发布生效**。
### Q2:拉取到了轨迹但订单摘要未变?
- 在详情查看是否为 **未映射轨迹**(有原始内容、无标准 TrackCode);
- 检查 **TrackStatusValue** 是否与服务商返回值一致(区分大小写逻辑见代码:状态为 Contains 匹配);
- 关键词是否被 **反向关键词** 否决;
- 内容映射是否与它行 **TrackContentId / 状态 / 关键词** 冲突导致保存失败。
### Q3:业务方收不到推送?
- 确认该客户 **IsAutoPush** 已开启,且订单非 **异常单**(异常单只能手工推);
- 检查 **LatestPushTrackCode** 与本次 TrackCode 是否满足 Sort 顺序(或 Swap 节点);
- 映射中是否存在当前 TrackCode 与 OrderNoType;
- 在 **推送记录** 查看拦截原因(Rabbit 广播失败消息)或网关 requestId;
- 核对 **ServiceProviderCode** 是否已在推送主配置中填写。
### Q4:预警未触发或误报?
- 核对 **预警分区** 是否覆盖该订单国家/渠道;
- 检查 **预警配置** 中起始/结束状态、时间阈值与因子(标记、内容);
- 确认轨迹时间与 **时区** 配置是否正确。
### Q5:小包/大包/主单轨迹不一致?
- 存在订单 **关联关系** 时,系统可能将轨迹同步到关联单;
- 拉取配置按 **订单类型** 区分,需分别配置。
---
## 9. 延伸阅读
| 文档 | 内容 |
|------|------|
| `docs/中后台前端页面设计规范.md` | 前端页面开发规范 |
---
**建议学习顺序**:先看第 3 章流程 → 精读 [4.4 轨迹拉取](#44-轨迹拉取详解) 与 [4.5 轨迹推送](#45-轨迹推送详解) → 按第 5 章实操 → 结合 **推送记录**、未映射轨迹与网关 requestId 排查。

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import guideContent from './guide.md?raw';
import GuideAudienceTabs from '@/components/GuideAudienceTabs/index.vue';
import opsContent from './guide-ops.md?raw';
import devContent from './guide-dev.md?raw';
defineOptions({
name: 'TrackGuide',
@ -8,40 +9,5 @@
</script>
<template>
<div class="track-guide-page">
<div class="track-guide-card">
<div class="guide-pane">
<MarkdownViewer :content="guideContent" />
</div>
</div>
</div>
<GuideAudienceTabs :ops-content="opsContent" :dev-content="devContent" />
</template>
<style lang="less" scoped>
.track-guide-page {
height: 100%;
width: 100%;
padding: 16px;
background: var(--app-page-bg);
overflow: hidden;
box-sizing: border-box;
}
.track-guide-card {
height: 100%;
background: var(--app-card-bg);
border-radius: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 24px;
box-sizing: border-box;
}
</style>

View File

@ -267,9 +267,14 @@ const detailTableData = computed(() =>
sourceTrajectoryData.value.map((row: any) => ({
...row,
trackTime: row.trackTime || '--',
trackTimeZoneDisplay: row.trackTimeZoneValue !== undefined && row.trackTimeZoneValue !== null
? String(row.trackTimeZoneValue)
: '--',
trackTimeZoneDisplay: (() => {
const zone = Number(row.trackTimeZoneValue);
if (row.trackTimeZoneValue === undefined || row.trackTimeZoneValue === null || Number.isNaN(zone)) {
return '--';
}
if (Math.abs(zone - 99999) < 1) return '--';
return String(row.trackTimeZoneValue);
})(),
trackPlace: row.trackPlace || '--',
content: row.content || '--',
enContent: row.enContent || '--',

View File

@ -72,6 +72,19 @@ export function useTrackOrderData(options: {
createTimeStart: (searchInfo as any).createTimeStart || '',
createTimeEnd: (searchInfo as any).createTimeEnd || '',
});
const newestTrackTimeZoneRaw = toServerSearchValue(searchParams.newestTrackTimeZone || '');
const destinationTimeZoneRaw = toServerSearchValue(searchParams.destinationTimeZone || '');
const newestTrackTimeZone = newestTrackTimeZoneRaw !== '' && !Number.isNaN(Number(newestTrackTimeZoneRaw))
? Number(newestTrackTimeZoneRaw)
: undefined;
const destinationTimeZone = destinationTimeZoneRaw !== '' && !Number.isNaN(Number(destinationTimeZoneRaw))
? Number(destinationTimeZoneRaw)
: undefined;
let isFinish: boolean | undefined;
if (searchParams.isFinish === true || searchParams.isFinish === 'true') isFinish = true;
else if (searchParams.isFinish === false || searchParams.isFinish === 'false') isFinish = false;
const requestParams: any = {
pageIndex: currentPage.value - 1,
@ -81,7 +94,7 @@ export function useTrackOrderData(options: {
orderNos: orderNos,
trackOrderType: trackOrderType,
customer: toServerSearchValue(searchParams.customer || ''),
// 添加所有新的搜索字段(尾程单号和其他单号保持逗号分隔的字符串格式)
// 表头筛选字段(尾程/其他单号支持逗号分隔多值)
trackNo: toServerSearchValue(searchParams.trackNo || ''),
otherNo: toServerSearchValue(searchParams.otherNo || ''),
product: toServerSearchValue(searchParams.product || ''),
@ -95,13 +108,16 @@ export function useTrackOrderData(options: {
newestTrackStatus: toServerSearchValue(searchParams.newestTrackStatus || ''),
newestTrackContent: toServerSearchValue(searchParams.newestTrackContent || ''),
newestTrackEnContent: toServerSearchValue(searchParams.newestTrackEnContent || ''),
newestTrackTime: searchParams.newestTrackTime || '',
newestTrackTimeZone: toServerSearchValue(searchParams.newestTrackTimeZone || ''),
newestTrackPlace: toServerSearchValue(searchParams.newestTrackPlace || ''),
isFinish: searchParams.isFinish !== '' ? searchParams.isFinish : undefined,
newestTrackTimeStart: dateRange.newestTrackTimeStart || '',
newestTrackTimeEnd: dateRange.newestTrackTimeEnd || '',
newestTrackTimeZone,
isFinish,
estimatedDeliveryDateStart: dateRange.estimatedDeliveryDateStart || '',
estimatedDeliveryDateEnd: dateRange.estimatedDeliveryDateEnd || '',
destinationTimeZone,
createTimeStart: dateRange.createTimeStart || '',
createTimeEnd: dateRange.createTimeEnd || '',
createTime: searchParams.createTime || '',
};
const response: any = await postGetTrackOrderList(requestParams as API.GetPageTrackOrderInput);

View File

@ -48,8 +48,25 @@ export function useTableColumns() {
{ key: 'city', label: t('track.order.table.city'), width: 120, searchable: true, searchField: 'city', headerMode: 'inline' as const },
{ key: 'postcode', label: t('track.order.table.postcode'), width: 100, searchable: true, searchField: 'postcode', headerMode: 'inline' as const },
{ key: 'email', label: t('track.order.table.email'), width: 200, searchable: true, searchField: 'email', headerMode: 'inline' as const },
{ key: 'estimatedDeliveryDate', label: t('track.order.table.estimatedDeliveryTime'), width: 180, searchable: false, headerMode: 'none' as const, sortable: true },
{ key: 'destinationTimeZone', label: t('track.order.table.deliveryTimezone'), width: 120, searchable: false, headerMode: 'none' as const },
{
key: 'estimatedDeliveryDate',
label: t('track.order.table.estimatedDeliveryTime'),
width: 180,
searchable: true,
searchField: 'estimatedDeliveryDate',
headerMode: 'icon' as const,
filterType: 'date' as const,
filterable: true,
sortable: true,
},
{
key: 'destinationTimeZone',
label: t('track.order.table.deliveryTimezone'),
width: 120,
searchable: true,
searchField: 'destinationTimeZone',
headerMode: 'inline' as const,
},
{
key: 'createTime',
label: t('track.order.table.createTime'),

View File

@ -220,6 +220,8 @@ import { useI18n } from 'vue-i18n';
newestTrackTimeZone: '',
newestTrackPlace: '',
isFinish: '',
estimatedDeliveryDate: '',
destinationTimeZone: '',
createTime: '',
});

View File

@ -311,6 +311,7 @@ export default defineConfig(({ mode }) => {
bypass(req) {
const path = req.url?.split('?')[0] ?? '';
const marketingSpaPrefixes = [
'/marketing/guide',
'/marketing/memberLevel',
'/marketing/pointsRule',
'/marketing/pointsFlow',
@ -586,6 +587,7 @@ export default defineConfig(({ mode }) => {
'/channel/labelDesign',
'/channel/guide',
// Marketing 相关前端路由(API 为 kebab-case,如 /marketing/member-level/list)
'/marketing/guide',
'/marketing/memberLevel',
'/marketing/pointsRule',
'/marketing/pointsFlow',