走专线的货,一票从收货到送进美国仓库要经过好几个节点:收货 → 开船 → 到港 → 提柜 → 送仓,中间还可能遇到查验、甩柜、改约。这些节点时间一直散落在客服手写的轨迹文字里(比如"1-9已开船"「美国时间2.5已送仓」),想统计"这批货哪一段慢了"只能一票票翻。
这个功能分两步,缺一不可,请按顺序做:
| 步骤 | 谁来做 | 做什么 | 做几次 |
|---|---|---|---|
| 第一步:配置轨迹触发器 | 公司管理员 / 实施 | 在系统里配一条规则,让系统自动从轨迹文字里认出开船、到港、提柜、送仓、查验等时间,记到运单上 | 一次性,配好长期生效 |
| 第二步:对小易说一句话 | 有「时效统计」权限的员工 | 说清时间范围,小易发回一票一行、35 列的时效分析 Excel | 随用随说 |
为什么第一步是必须的: 报表 35 列里有 19 列(全部节点时间、各段时效天数、查验天数、约仓预警、情况说明、内部备注、转港情况)读的都是第一步自动记到运单上的字段。没配触发器,小易照样能把表发给您,但这 19 列会整列空白,只剩客户、单号、收货时间等基础信息——等于没做时效分析。
本章面向公司管理员/实施人员。配置只需做一次,之后按需调整关键词即可。
触发器是系统的通用功能,本章只讲为本报表配这一条规则要填什么。触发器本身怎么进、怎么建、各选项什么意思,请先看 触发器基本操作;想了解触发器还能做什么,看 触发器功能总览。
在系统「触发器」功能里配一条规则:每当专线运单的轨迹有新增或修改,就自动执行一段识别脚本,把上面表格里的字段重新算一遍写到运单上。
| 自动生成的字段 | 从轨迹里认什么字样 | 举例 |
|---|---|---|
| 开船时间 | 已开船 | "6-15已开船,预计7-2到港" → 开船时间 6 月 15 日 |
| 到港时间 | 已到港 | "美国时间7.1已到港" → 到港时间 7 月 1 日 |
| 提柜时间 | 已提柜 | "美国时间7-13已提柜" |
| 送仓时间 | 已送仓 / 已递送 | 卡派看"已送仓",尾程快递看"已递送" |
| 国内查验开始/结束 | 国内查验 / 国内查验已放行 | "6.12国内查验—人查+机查" |
| 国外查验开始/结束 | 通知查验、国外查验 / 查验已放行 | "7-6国外通知查验"、"7-23查验已放行" |
| 情况说明 | 甩柜、报关放行晚、国内/国外查验、船司或官网显示晚到港、亚马逊推约/删约/跳约、转仓、约仓困难已安排快递派送 | 命中多项用中文逗号并列 |
| 内部备注 | 同上,但只看对客户隐藏的轨迹 | 仅内部可见 |
| 转港情况 | 转运 | "正在等待转运芝加哥" → 芝加哥 |
时间的取法:优先用轨迹文字里写的日期("7.1已到港"就记 7 月 1 日,点号、横杠两种写法都认识);文字里没写日期的,用这条轨迹的录入时间。文字只有月和日的(比如跨年时 1 月录入的"12.28已开船"),系统会自动推断正确的年份。
登录系统 → 右上角 菜单 → 系统设置 → 基础数据(会打开一个新页面)→ 页面顶部页签里点 触发器 → 点「新建」,按下表填写:
| 配置项 | 填什么 |
|---|---|
| 启用 | 勾选 |
| 允许重复触发 | 勾选(每条轨迹变化都要重新计算) |
| 延迟触发 | 不勾 |
| 名称 | 专线时效节点时间自动识别 |
| 条件 | 物流方式() == '专线' |
| 动作 | 只勾「执行任务」(不勾生成问题件、发送通知) |
| 操作状态 | 选「全部触发」 |
| 触发环节 | 只勾「轨迹相关」(其余全部不勾) |
| 执行任务的脚本框 | 粘贴下方的完整脚本 |
填好点「确定」,系统会再弹一个「确认要新建该触发器?」的小窗,再点一次「确定」即保存成功,保存后立刻生效。
/** dev-47486 专线时效节点时间自动识别:每次轨迹事件基于全量轨迹幂等重算 11 个字段并覆盖写入,最后 saveDelta 落库。关键词配置区可按贵公司话术增改。 */
(function () {
var NODE_RULES = [
{ field: 'sailingTime', keywords: ['已开船'] },
{ field: 'arriveTime', keywords: ['已到港'] },
{ field: 'tiGuiTime', keywords: ['已提柜'] },
{ field: 'deliveryStoreTime', keywords: ['已送仓', '已递送'], allowChild: true },
{ field: 'customsInspectionBeginTime', keywords: ['国内查验'], exclude: ['已放行'] },
{ field: 'customsInspectionReleaseTime', keywords: ['国内查验已放行'] },
{ field: 'foreignCustomsInspectionBeginTime', keywords: ['通知查验', '国外查验'], exclude: ['已放行'] },
{ field: 'foreignCustomsInspectionReleaseTime', keywords: ['查验已放行'], exclude: ['国内'] }
];
var SUMMARY_KEYWORDS = [
{ kw: '甩柜' }, { kw: '报关放行晚' }, { kw: '国内查验' },
{ kw: '国外查验' }, { kw: '通知查验', out: '国外查验' },
{ kw: '船司显示晚到港' }, { kw: '官网显示晚到港' },
{ kw: '亚马逊推约' }, { kw: '亚马逊删约' }, { kw: '亚马逊跳约' },
{ kw: '转仓', fullText: true },
{ kw: '约仓困难已安排快递派送' }
];
var TRANSFER_PORT_PREFIX = '转运';
var EXTRA_KEYS = { situation: 'situationSummary', internal: 'internalRemarkSummary', transfer: 'transferPortSummary' };
var Calendar = Java.type('java.util.Calendar');
var waybill = $yunDanEntity;
if (waybill == null) return;
var trackList = waybill.getYunDanGuiJiEntityList();
if (trackList == null) return;
var tracks = [];
for (var i = 0; i < trackList.size(); i++) {
var t = trackList.get(i);
if (t == null || t.getCaoZuoShiJian() == null) continue;
var desc = t.getMiaoShu();
if (desc == null || String(desc).trim() === '') continue;
tracks.push({
desc: String(desc),
time: t.getCaoZuoShiJian(),
hide: t.isHide(),
isChild: t.getChildTrackNo() != null && String(t.getChildTrackNo()).trim() !== ''
});
}
var receiveTime = waybill.getShouHuoShiJian();
function makeDate(y, m, d) {
var cal = Calendar.getInstance();
cal.clear();
cal.set(y, m - 1, d, 0, 0, 0);
return cal.getTime();
}
function dayFloor(date) {
var cal = Calendar.getInstance();
cal.setTime(date);
return makeDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DATE));
}
function yearOf(date) {
var cal = Calendar.getInstance();
cal.setTime(date);
return cal.get(Calendar.YEAR);
}
function validYmd(y, m, d) { return m >= 1 && m <= 12 && d >= 1 && d <= 31; }
function extractTextDate(desc, kwIndex, recordTime) {
var re = /(?:(\d{4})[.\-])?(\d{1,2})[.\-](\d{1,2})/g;
var best = null, first = null, m;
while ((m = re.exec(desc)) !== null) {
var cand = { year: m[1] ? parseInt(m[1], 10) : null, month: parseInt(m[2], 10), day: parseInt(m[3], 10), pos: m.index };
if (!validYmd(cand.year || 2000, cand.month, cand.day)) continue;
if (first === null) first = cand;
if (cand.pos < kwIndex) best = cand;
}
var hit = best !== null ? best : first;
if (hit === null) return null;
if (hit.year !== null) return makeDate(hit.year, hit.month, hit.day);
var ry = yearOf(recordTime);
var minDay = receiveTime != null ? dayFloor(receiveTime).getTime() : null;
var bestDate = null, bestDiff = -1, tie = false;
for (var y = ry - 1; y <= ry + 1; y++) {
var d2 = makeDate(y, hit.month, hit.day);
if (minDay !== null && d2.getTime() < minDay) continue;
var diff = Math.abs(d2.getTime() - recordTime.getTime());
if (bestDate === null || diff < bestDiff) { bestDate = d2; bestDiff = diff; tie = false; }
else if (diff === bestDiff) { tie = true; }
}
return tie ? null : bestDate;
}
function matchesRule(tk, rule, kw) {
if (tk.hide) return false;
if (tk.isChild && !rule.allowChild) return false;
if (tk.desc.indexOf(kw) < 0) return false;
if (rule.exclude) {
for (var e = 0; e < rule.exclude.length; e++) {
if (tk.desc.indexOf(rule.exclude[e]) >= 0) return false;
}
}
return true;
}
for (var r = 0; r < NODE_RULES.length; r++) {
var rule = NODE_RULES[r];
var value = null;
for (var k = 0; k < rule.keywords.length && value === null; k++) {
var kw = rule.keywords[k];
var earliest = null;
for (var j = 0; j < tracks.length; j++) {
var tk = tracks[j];
if (!matchesRule(tk, rule, kw)) continue;
if (earliest === null || tk.time.getTime() < earliest.time.getTime()) earliest = tk;
}
if (earliest !== null) {
var textDate = extractTextDate(earliest.desc, earliest.desc.indexOf(kw), earliest.time);
value = textDate !== null ? textDate : earliest.time;
}
}
$jsService.updateProperty(waybill, rule.field, value);
}
function collectSummary(hidden) {
var items = [], seen = {};
for (var s = 0; s < SUMMARY_KEYWORDS.length; s++) {
var conf = SUMMARY_KEYWORDS[s];
for (var j2 = 0; j2 < tracks.length; j2++) {
var tk2 = tracks[j2];
if (tk2.hide !== hidden || tk2.isChild) continue;
if (tk2.desc.indexOf(conf.kw) < 0) continue;
var text = conf.fullText ? tk2.desc : (conf.out || conf.kw);
if (!seen[text]) { seen[text] = true; items.push(text); }
if (!conf.fullText) break;
}
}
return items.join(',');
}
function collectTransferPort() {
var items = [], seen = {};
var re2 = new RegExp(TRANSFER_PORT_PREFIX + '([\\u4e00-\\u9fa5A-Za-z]+)');
for (var j3 = 0; j3 < tracks.length; j3++) {
var tk3 = tracks[j3];
if (tk3.hide || tk3.isChild) continue;
if (tk3.desc.indexOf(TRANSFER_PORT_PREFIX) < 0) continue;
var mm = re2.exec(tk3.desc);
var text2 = mm !== null ? mm[1] : tk3.desc;
if (!seen[text2]) { seen[text2] = true; items.push(text2); }
}
return items.join(',');
}
var extraJson = waybill.getWaybillExtraEntity().getExtraJson();
function putExtra(key, val) {
if (val === null || val === '') { extraJson.remove(key); } else { extraJson.put(key, val); }
}
putExtra(EXTRA_KEYS.situation, collectSummary(false));
putExtra(EXTRA_KEYS.internal, collectSummary(true));
putExtra(EXTRA_KEYS.transfer, collectTransferPort());
$yunDanService.saveDelta(waybill);
})();
公司的轨迹话术有变化时(比如客服开始写"已装船"表示开船),只改脚本开头的配置区,其余部分不要动:
NODE_RULES:时间字段的关键词。每行一个字段,keywords 里逗号分隔多个词,排在前面的优先;exclude 里的词命中则这条轨迹不算(用于区分"国内查验"和"国内查验已放行"这类包含关系)。SUMMARY_KEYWORDS:情况说明/内部备注的关键词。out 表示"命中这个词时在表格里显示成什么"(比如轨迹写"通知查验",表里统一显示"国外查验");fullText: true 表示把整条轨迹原文放进表里(用于"转仓"这类需要保留细节的)。改完后到 基础数据 → 触发器 → 双击这条规则 → 更新脚本框 → 确定保存,保存后马上生效。
从配置好触发器的那一刻起,之后新录入或修改的轨迹才会自动生成这些时间;配置之前的老运单,系统不会回头补算,报表里这些格子是空的——这是正常现象,不是数据丢了。不过老运单只要后面又有新轨迹(比如补录了一条"已送仓"),系统就会把这一票的时间重新算全。
另外两个小提示:
前提:第一步的触发器已经配好。配好之前生成的报表,节点时间列会是空的。
在系统右下角打开小易对话窗口,像这样说一句话即可:
小易会把 Excel 文件直接发到会话里,点击即可下载,同时附上一段结果摘要(时间范围、票数、筛选条件)。
说话时要讲清楚的:
| 要素 | 要求 |
|---|---|
| 时间范围(必须说) | 按收货时间筛选,最长一次 12 个月。超过的话小易会请您分段生成 |
| 客户(可选) | 客户代码或客户名称都行,可以一次报多个。名称必须和系统里完全一致;如果同名客户不止一家,小易会请您改用客户代码 |
| 收货渠道(可选) | 渠道名称必须和系统里完全一致(注意全称/简称、繁简写法) |
不加客户、渠道条件时,统计的是时间范围内您有权限看到的全部专线运单(不含作废单和杂费单)。
谁能用:
下载报表样例(样例为虚构数据,仅供体验列结构)
全表 35 列,大致分成几组:
| 分组 | 包含的列 |
|---|---|
| 运单基础信息 | 客户代码、客户名称、机构单号、客户单号、收件人识别码、邮编、地区、月份、周数、收货时间、收货渠道、最新公开轨迹 |
| 节点时间 | 开船时间、到港时间、提柜时间、送仓时间、国内查验开始/结束时间、国外查验开始/结束时间 |
| 时效天数(自然日) | 收货-开船时效、航程时间、到港-送仓时效、开船-送仓时效、收货-送仓时效、国内查验天数、国外查验天数、约仓时间预警 |
| 文字汇总 | 情况说明、内部备注、转港情况 |
| 本期占位(留空) | 报价表时效、是否超时、超时天数、转至目的港时间(待后续版本启用) |
几个约定:
问:表发回来了,但开船/到港/送仓这些时间列全是空的?
答:先确认第一步的触发器已经配置并启用(触发器怎么找、怎么看状态见 触发器基本操作)(基础数据 → 触发器 页签里能看到「专线时效节点时间自动识别」且状态为启用)。没配的话按第一步配上;配好后,之后再有轨迹录入的运单会自动补上,配之前的老票不会回头补算(见「生效范围」)。
问:保存触发器时提示 "Data too long for column 'execute_function_code'"?
答:说明贵公司的系统数据库还没把脚本字段扩容到 8000 字符。目前所有正常使用中的公司都已扩容;如果您的公司是新开通或曾停用后恢复的,请联系易抵达技术支持处理,不要自行删减脚本内容。
问:小易说我没有权限,怎么办?
答:请管理员在系统的角色权限里,给您的角色勾上「统计分析 - 时效统计」。如果您拿到的表里「内部备注」一列全是空的,是因为您没有「查看隐藏轨迹」权限,这一列只对有该权限的员工显示。
问:小易提示时间跨度超过上限?
答:一次最多统计 12 个月(按收货时间算)。跨度更长的话分几段生成即可,小易会给出分段建议。
问:报了客户名称,小易让我提供客户代码?
答:说明系统里有多家客户重名,用客户代码(唯一编码)最准确。客户代码在客户档案里能查到。
问:加了渠道条件后一票都没有?
答:渠道是精确匹配的,请核对渠道名称是否与系统里完全一致(全称/简称、繁简写法都算不同),并确认该渠道在所选时间范围内确实有收货。
问:为什么有的票开船时间是空的?
答:三种可能:① 这票是配置触发器之前的老票,之后没有新轨迹(见"生效范围");② 轨迹里没有出现"已开船"字样(比如只写了"离港")——可以把新话术加进关键词;③ 轨迹里的日期写法系统认不出且轨迹录入时间也缺失。
问:轨迹里写了"预计7-2到港",会不会被当成到港时间?
答:不会。"预计到港"不含"已到港"字样,不会命中;系统也优先取关键词前面紧挨着的日期,"6-15已开船,预计7-2到港"只会把 6-15 记为开船时间。
问:数据量太大生成失败?
答:单次最多导出 2 万票;再多的话请缩短时间范围或增加客户、渠道条件分段生成,小易也会这样提示您。