diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/EventExceptionRule.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/EventExceptionRule.java
new file mode 100644
index 0000000..c47d932
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/EventExceptionRule.java
@@ -0,0 +1,109 @@
+package org.jeecg.modules.inquiry.domain;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * 事件异常纯规则:摘要拼接、哪些六态还盯超时、换等待种类才重置计时。
+ *
+ * 不写库、不发企微。工单六态不因事件异常改变。
+ */
+public final class EventExceptionRule {
+
+ private EventExceptionRule() {
+ }
+
+ /**
+ * 多次异常拼在一起;同一类型不重复。
+ */
+ public static String appendSummary(String existing, String type) {
+ String add = StringUtils.trimToEmpty(type);
+ if (add.isEmpty()) {
+ return StringUtils.trimToEmpty(existing);
+ }
+ String cur = StringUtils.trimToEmpty(existing);
+ if (cur.isEmpty() || "-".equals(cur)) {
+ return add;
+ }
+ Set parts = new LinkedHashSet(Arrays.asList(cur.split(";")));
+ if (parts.contains(add)) {
+ return cur;
+ }
+ parts.add(add);
+ StringBuilder out = new StringBuilder();
+ for (String p : parts) {
+ String one = StringUtils.trimToEmpty(p);
+ if (one.isEmpty()) {
+ continue;
+ }
+ if (out.length() > 0) {
+ out.append(';');
+ }
+ out.append(one);
+ }
+ return out.toString();
+ }
+
+ /**
+ * 已成交 / 已关闭 / 转人工不再盯;未成交仍可能在等原因。
+ */
+ public static boolean isWatchable(String status) {
+ if (StringUtils.isBlank(status)) {
+ return false;
+ }
+ if (TicketStatuses.DEAL.equals(status)
+ || TicketStatuses.CLOSED.equals(status)
+ || TicketStatuses.HANDOFF.equals(status)) {
+ return false;
+ }
+ return TicketStatuses.isKnown(status);
+ }
+
+ /**
+ * 等待种类变了才重置起点;同一种类重复 start 不改已过时间。
+ */
+ public static boolean shouldResetClock(String currentKind, String newKind) {
+ String cur = StringUtils.trimToEmpty(currentKind);
+ String next = StringUtils.trimToEmpty(newKind);
+ if (next.isEmpty()) {
+ return false;
+ }
+ return !cur.equals(next);
+ }
+
+ /**
+ * 开始等待后的三列新值。
+ */
+ public static EventWaitState applyStart(String currentKind, Date started, Integer alerted,
+ String newKind, Date now) {
+ String next = StringUtils.trimToEmpty(newKind);
+ if (next.isEmpty()) {
+ return new EventWaitState(currentKind, started, alerted);
+ }
+ if (!shouldResetClock(currentKind, next)) {
+ Date keep = started != null ? started : now;
+ int flag = alerted == null ? 0 : alerted.intValue();
+ return new EventWaitState(next, keep, flag);
+ }
+ return new EventWaitState(next, now, 0);
+ }
+
+ /**
+ * 当前等待三列。
+ */
+ public static final class EventWaitState {
+ public final String kind;
+ public final Date startedAt;
+ public final int alerted;
+
+ public EventWaitState(String kind, Date startedAt, int alerted) {
+ this.kind = kind;
+ this.startedAt = startedAt;
+ this.alerted = alerted;
+ }
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/EventExceptionRuleTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/EventExceptionRuleTest.java
new file mode 100644
index 0000000..bacc542
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/EventExceptionRuleTest.java
@@ -0,0 +1,53 @@
+package org.jeecg.modules.inquiry.domain;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 事件异常纯规则:摘要拼接、可盯状态、换等待种类才重置计时。
+ */
+public class EventExceptionRuleTest {
+
+ @Test
+ public void appendKeepsBothTypes() {
+ assertEquals("航线响应超时", EventExceptionRule.appendSummary("", "航线响应超时"));
+ assertEquals("航线响应超时;销售响应超时",
+ EventExceptionRule.appendSummary("航线响应超时", "销售响应超时"));
+ assertEquals("航线响应超时", EventExceptionRule.appendSummary("航线响应超时", "航线响应超时"));
+ }
+
+ @Test
+ public void lostIsWatchableHandoffIsNot() {
+ assertTrue(EventExceptionRule.isWatchable("未成交"));
+ assertTrue(EventExceptionRule.isWatchable(TicketStatuses.INQUIRING));
+ assertFalse(EventExceptionRule.isWatchable("已成交"));
+ assertFalse(EventExceptionRule.isWatchable("已关闭"));
+ assertFalse(EventExceptionRule.isWatchable("转人工"));
+ }
+
+ @Test
+ public void sameKindDoesNotResetClock() {
+ assertFalse(EventExceptionRule.shouldResetClock("air_quote", "air_quote"));
+ assertTrue(EventExceptionRule.shouldResetClock("air_quote", "adopt"));
+ assertTrue(EventExceptionRule.shouldResetClock("", "product_quote"));
+ }
+
+ @Test
+ public void applyStartKeepsClockOnSameKind() {
+ java.util.Date first = new java.util.Date(1_000L);
+ java.util.Date now = new java.util.Date(2_000L);
+ EventExceptionRule.EventWaitState keep = EventExceptionRule.applyStart(
+ "air_quote", first, 1, "air_quote", now);
+ assertEquals("air_quote", keep.kind);
+ assertEquals(first, keep.startedAt);
+ assertEquals(1, keep.alerted);
+ EventExceptionRule.EventWaitState reset = EventExceptionRule.applyStart(
+ "air_quote", first, 1, "adopt", now);
+ assertEquals("adopt", reset.kind);
+ assertEquals(now, reset.startedAt);
+ assertEquals(0, reset.alerted);
+ }
+}