forked from ChaiHao/BPOS-MVP
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:
co-authored by
Claude Opus 5
parent
a8f6eef610
commit
4ad901c452
@@ -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(/非法日历偏移/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user