M5(3/3): trigger —— 三类触发器与起单幂等

- matchesEvent:事件触发的命中判定(S-FEEL 规则;规则写坏按不命中处置,不让事件流停摆)
- assembleInput:字段映射装配;缺必填即起单失败入死信,不静默丢弃
- 幂等:同一事件对同一触发器至多起一单;数据日历型同一行同一日期只触发一次
- 周期两型:日历表达式到点扫描 + 数据日历型(日期字段 + 偏移 + 过滤)逐行起单
- 登记触发:人工起单的统一形态,登记人即来源
- 触发器与流程包平级:配置独立存放,增删启停不改流程包版本

340 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 02:03:58 +08:00
co-authored by Claude Opus 5
parent a8f6eef610
commit 4ad901c452
16 changed files with 3768 additions and 0 deletions
@@ -0,0 +1,121 @@
/** 触发器判定与输入装配 —— domain 测试零 mock(OS 3.1 / 6.3) */
import { describe, expect, it } from 'vitest';
import type { TriggerConfig } from '@contracts';
import { evaluateCondition, evaluate as evalSfeel } from '@sfeel';
import {
applyOffset,
assembleInput,
dataCalendarKey,
matchesEvent,
nextCalendarFire,
scopeOf,
triggerIdempotencyKey,
} from '../internal/domain/matching.ts';
const eventTrigger = (over: Partial<TriggerConfig> = {}): TriggerConfig =>
({
id: 'tr-rfq' as never,
name: '客户RFQ询价',
enabled: true,
processPackageId: 'quote' as never,
mapping: [
{ target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customer' } },
{ target: 'channel', source: { from: 'constant', value: 'mail' } },
],
spec: { kind: 'event', rule: 'contains(subject, "询价")', eventType: 'data' },
...over,
}) as TriggerConfig;
const scope = {
type: 'data',
keys: { customer: 'C001' },
payload: { subject: '询价 RFQ - 车规 Analog' },
source: 'channel',
occurredAt: '2026-07-27T02:00:00.000Z',
};
describe('matchesEvent —— 流程层的系统级事件监听', () => {
it('规则命中即起单候选', () => {
expect(matchesEvent(eventTrigger(), scope, evaluateCondition)).toBe(true);
});
it('规则不命中即跳过', () => {
expect(matchesEvent(eventTrigger(), { ...scope, payload: { subject: '普通问候' } }, evaluateCondition)).toBe(false);
});
it('停用的触发器恒不命中', () => {
expect(matchesEvent(eventTrigger({ enabled: false }), scope, evaluateCondition)).toBe(false);
});
it('事件类型不符即跳过', () => {
expect(matchesEvent(eventTrigger(), { ...scope, type: 'time' }, evaluateCondition)).toBe(false);
});
it('规则写坏了按不命中处置 —— 不让事件流停摆', () => {
const broken = eventTrigger({ spec: { kind: 'event', rule: 'contains(', eventType: 'data' } } as never);
expect(matchesEvent(broken, scope, evaluateCondition)).toBe(false);
});
});
describe('assembleInput —— 缺必填即起单失败入死信', () => {
const evalExpr = (expr: string, s: Readonly<Record<string, unknown>>): unknown => evalSfeel(expr, s);
it('必填齐备即 ready', () => {
const result = assembleInput(eventTrigger(), scopeOf(scope), ['customerCode'], evalExpr);
expect(result).toEqual({ kind: 'ready', data: { customerCode: 'C001', channel: 'mail' } });
});
it('缺必填即 missing,并指出缺谁', () => {
const result = assembleInput(eventTrigger(), scopeOf({ ...scope, keys: {} }), ['customerCode'], evalExpr);
expect(result).toEqual({ kind: 'missing', fields: ['customerCode'] });
});
it('常量映射不依赖事件', () => {
const result = assembleInput(eventTrigger(), {}, ['channel'], evalExpr);
expect(result.kind).toBe('ready');
});
});
describe('幂等键', () => {
it('同一事件对同一触发器至多起一单', () => {
expect(triggerIdempotencyKey('t1', 'e1')).toBe('trigger:t1:e1');
expect(triggerIdempotencyKey('t1', 'e1')).not.toBe(triggerIdempotencyKey('t1', 'e2'));
});
it('数据日历型:同一行同一日期只触发一次', () => {
expect(dataCalendarKey('t1', 'pn=LM358', '2026-08-01')).toBe('trigger:t1:pn=LM358:2026-08-01');
// 日期变了就能再触发
expect(dataCalendarKey('t1', 'pn=LM358', '2026-09-01')).not.toBe(dataCalendarKey('t1', 'pn=LM358', '2026-08-01'));
});
});
describe('周期触发', () => {
it('日历型:每天到点', () => {
const after = new Date('2026-07-27T01:00:00.000Z');
const next = nextCalendarFire('daily 09:00', after);
expect(next).toBeInstanceOf(Date);
expect(next!.getHours()).toBe(9);
expect(next!.getTime()).toBeGreaterThan(after.getTime());
});
it('已过点即顺延到次日', () => {
const morning = new Date('2026-07-27T00:00:00.000Z');
morning.setHours(10, 0, 0, 0);
const next = nextCalendarFire('daily 09:00', morning);
expect(next!.getDate()).toBe(morning.getDate() + 1);
});
it('周期表达式不认即 undefined', () => {
expect(nextCalendarFire('每天九点', new Date())).toBeUndefined();
});
it('数据日历型偏移:合同到期前 N 天', () => {
const due = new Date('2026-08-10T00:00:00.000Z');
expect(applyOffset(due, '-P3D').toISOString().slice(0, 10)).toBe('2026-08-07');
expect(applyOffset(due, 'P0D').toISOString().slice(0, 10)).toBe('2026-08-10');
});
it('非法偏移即报错', () => {
expect(() => applyOffset(new Date(), '前三天')).toThrow(/非法日历偏移/);
});
});