/* * ★ 같은 사실을 두 번 받으면 한 번만 반영한다. * * ── 왜 이것이 핵심인가 (2026-08-26 · 사용자) ───────────────────────────────── * 「웹훅 이벤트이건 폴링 데이터이건 아이뎀포턴트를 유지할 수 있도록 로직을 세우는 것」이 핵심이다. * 그것이 서면 가져오는 방식이 무엇이든 트윈이 안전하고, 커넥터마다 각자 조심하지 않아도 된다. * * ── 무엇이 났나 ───────────────────────────────────────────────────────────── * 지금까지 중복은 커넥터마다 각자 막았다. 태양광 커넥터가 10초마다 물으면서 10분마다 바뀌는 값을 매번 * 다시 보냈고, 한 주기에 76건이 되풀이됐다(커넥터를 고쳐 2건이 됐다). 다음 커넥터가 잊으면 또 난다. * 밀어 주는 방식으로 받으면 더 자주 난다 — 재전송이 정상이다. * * ── 정체를 무엇으로 보나 ──────────────────────────────────────────────────── * 봉투의 `eventId` 로는 판단할 수 없다. 보낸 쪽이 매번 새로 만드는 순번이고, 호출마다 1부터 다시 * 시작한다. 그래서 사실 자체로 판단한다 — 대상 · 사건 종류 · 연결된 시스템이 준 시각. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { factIdentity, FactDeduper } from '../server/engine/ingest-dedupe.js' const at = '2026-08-26T00:00:00.000Z' const energy = (equipmentId: string, kWh: number, when = at, eventId = 'x') => ({ eventId, eventType: 'energy.generated', eventTime: when, tenantId: 't', data: { equipmentId, kWh, at: when } }) const status = (moverId: string, s: string, when = at) => ({ eventId: `s-${moverId}`, eventType: 'equipment.status', eventTime: when, tenantId: 't', data: { moverId, kind: 'pv-array', status: s, at: when } }) test('★ 같은 사실이 두 번 오면 한 번만 지난다 — 보낸 쪽이 되풀이 보내도 트윈이 틀리지 않는다', () => { const d = new FactDeduper() const { fresh, duplicates } = d.filter([energy('inv-1', 574.8), energy('inv-1', 574.8)]) assert.equal(fresh.length, 1) assert.equal(duplicates, 1) }) test('★ 봉투의 순번이 달라도 같은 사실이면 한 번이다 — eventId 로는 판단할 수 없다', () => { /* 같은 측정을 두 번 보내면 보낸 쪽이 새 순번을 붙인다. 그것으로 판단하면 중복을 못 잡는다. */ const d = new FactDeduper() const { fresh, duplicates } = d.filter([energy('inv-1', 574.8, at, 't-energy-1'), energy('inv-1', 574.8, at, 't-energy-2')]) assert.equal(fresh.length, 1) assert.equal(duplicates, 1) }) test('★ 다른 사실은 지난다 — 대상이 다르면 다른 사실이다', () => { const d = new FactDeduper() const { fresh, duplicates } = d.filter([energy('inv-1', 574.8), energy('inv-2', 577.2)]) assert.equal(fresh.length, 2) assert.equal(duplicates, 0) }) test('★ 잰 시각이 나아가면 다른 사실이다 — 그것이 새 측정이다', () => { const d = new FactDeduper() const { fresh } = d.filter([energy('inv-1', 574.8, at), energy('inv-1', 580.1, '2026-08-26T00:10:00.000Z')]) assert.equal(fresh.length, 2) }) test('★ 사건 종류가 다르면 다른 사실이다 — 같은 설비의 같은 시각이어도', () => { const d = new FactDeduper() const { fresh } = d.filter([energy('inv-1', 574.8), status('inv-1', 'busy')]) assert.equal(fresh.length, 2) }) test('★ 정체를 알 수 없으면 거르지 않는다 — 모르는 것을 같다고 하면 다른 사실이 하나로 합쳐진다', () => { const d = new FactDeduper() const noTarget = { eventId: 'a', eventType: 'energy.demand.window', eventTime: at, tenantId: 't', data: { maxKW: 3 } } const noTime = { eventId: 'b', eventType: 'equipment.status', tenantId: 't', data: { moverId: 'inv-9', status: 'idle' } } assert.equal(factIdentity(noTarget), undefined) assert.equal(factIdentity(noTime), undefined) const { fresh, duplicates } = d.filter([noTarget, noTarget, noTime, noTime]) assert.equal(fresh.length, 4, '거르지 않고 통과시킨다') assert.equal(duplicates, 0) }) test('★ 늦게 도착한 옛 사실은 버리지 않는다 — 그것이 왔다는 것도 사실이다', () => { /* * 상태에 반영하지 않는 판단은 커널이 한다(시각 비교). 여기서 버리면 지난 기록에서 사라지고, * 「늦게 왔다」는 사실 자체를 아무도 알 수 없게 된다. */ const d = new FactDeduper() const { fresh, duplicates } = d.filter([ energy('inv-1', 580.1, '2026-08-26T00:10:00.000Z'), energy('inv-1', 574.8, at) ]) assert.equal(fresh.length, 2) assert.equal(duplicates, 0) }) test('★ 창을 넘으면 잊는다 — 전부 기억할 수는 없다', () => { const window = FactDeduper.WINDOW try { FactDeduper.WINDOW = 3 const d = new FactDeduper() d.filter([energy('inv-1', 1, '2026-08-26T00:00:00.000Z')]) d.filter([ energy('inv-1', 2, '2026-08-26T00:01:00.000Z'), energy('inv-1', 3, '2026-08-26T00:02:00.000Z'), energy('inv-1', 4, '2026-08-26T00:03:00.000Z') ]) /* 첫 사실은 창 밖으로 밀려났으므로 다시 오면 통과한다 — 그 사실을 시험으로 못 박는다. */ const { duplicates } = d.filter([energy('inv-1', 1, '2026-08-26T00:00:00.000Z')]) assert.equal(duplicates, 0) assert.ok(d.size() <= 3) } finally { FactDeduper.WINDOW = window } }) test('버린 수를 함께 낸다 — 알리지 않고 버리지 않는다', () => { const d = new FactDeduper() const same = [energy('inv-1', 1), energy('inv-1', 1), energy('inv-1', 1), energy('inv-2', 2)] const { fresh, duplicates } = d.filter(same) assert.equal(fresh.length, 2) assert.equal(duplicates, 2) }) test('트윈마다 따로 센다 — 한 트윈의 되풀이가 다른 트윈을 막지 않는다', () => { const a = new FactDeduper() const b = new FactDeduper() a.filter([energy('inv-1', 1)]) const { duplicates } = b.filter([energy('inv-1', 1)]) assert.equal(duplicates, 0) }) /* ════════════════════════════════════════════════════════════════════════════ * ★ **구별할 값이 봉투 안에 있으면 다 본다** — 골라 쓰고 나머지를 버리던 자리. * * ── 실측 (2026-09-05, 인티그레이션·plant 레인) ───────────────────────────── * plant 이 실적 둘을 보냈는데 한 건이 저널에 안 남았다. * * seq 36 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-01 · goodCount 12 * seq 41 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-02 · goodCount 9 * * 시각이 같았고(원본이 끝시각을 복사) 대상을 **먼저 걸리는 하나**로 골라서 `moverId` 가 이겼다. * 거부가 아니라 **되풀이로 세어졌다** — 유입 장부에 `offered 5 · rejected 0 · journaled 3`. * * 원인이 시험 데이터였다고 해서 거동이 옳아지지 않는다. 두 봉투는 `taskId` 도 수량도 달랐고, * **구별할 값이 이미 봉투 안에 있었는데 판정이 그것을 안 봤다.** * * ── 이 파일이 스스로 적어 둔 값 ─────────────────────────────────────────── * 「모르는 것을 같다고 판단하면 서로 다른 사실이 하나로 합쳐진다. * 알리지 않고 잃는 것보다 두 번 세는 것이 낫다.」 * * 지금 거동은 **알리지 않고 잃는 쪽**이었다. * ════════════════════════════════════════════════════════════════════════════ */ const AT_36 = '2026-09-05T06:50:00.000Z' const quality = (taskId: string, goodCount: number) => ({ eventType: 'quality.output', data: { moverId: 'EQ-CT-MWELD-01', taskId, goodCount, at: AT_36 } }) test('★ 한 설비가 두 작업을 같은 시각에 보고해도 두 사실이다 — 실제로 한 건을 잃던 그 줄', () => { const a = factIdentity(quality('PR-CT-WIRE-01', 12)) const b = factIdentity(quality('PR-CT-WIRE-02', 9)) assert.ok(a && b, '정체를 못 만들면 거르지 않는다 — 이 검사가 뜻이 없다') assert.notEqual(a, b, '작업이 다른 두 실적이 한 사실이 됐다') }) test('★ 같은 봉투를 다시 보내면 여전히 같은 사실이다 — 넓혀도 되풀이는 잡힌다', () => { /* * 넓히는 것이 「되풀이를 놓친다」로 이어지지 않는다는 것이 이 변경의 근거다. 같은 봉투는 이 * 값들이 전부 같으므로 정체도 같다. */ assert.equal(factIdentity(quality('PR-CT-WIRE-01', 12)), factIdentity(quality('PR-CT-WIRE-01', 12))) }) test('★ 한 개체의 서로 다른 관측을 가른다 — EPCIS 는 그것이 정상이다', () => { /* * 저널 246만 건에 옛 규칙을 다시 걸어 여섯 묶음이 나왔고 전부 이 모양이었다. 한 물건이 한 * 순간에 입고이면서 개시일 수 있다 — 표준에서 이상한 일이 아닌데 규칙이 그 셋을 안 봤다. */ const epc = 'urn:epc:id:sgtin:9521321.011111.1' const receiving = { eventType: 'epcis.ObjectEvent', data: { epcList: [epc], bizStep: 'receiving', action: 'ADD', disposition: 'sellable_accessible', eventTime: AT_36 } } const commissioning = { eventType: 'epcis.ObjectEvent', data: { epcList: [epc], bizStep: 'commissioning', action: 'OBSERVE', disposition: 'reserved', eventTime: AT_36 } } assert.notEqual(factIdentity(receiving), factIdentity(commissioning), '입고와 개시가 한 사실이 됐다') }) test('★ 참조가 달라도 갈린다 — 주체만 보고 나머지를 버리지 않는다', () => { /* * 「주체를 먼저 본다」는 판단 자체는 옳았다. 틀린 것은 **고르고 나머지를 버린 것**이다. */ const one = { eventType: 'task.status', data: { taskId: 'T-1', orderId: 'order-1', at: AT_36 } } const two = { eventType: 'task.status', data: { taskId: 'T-1', orderId: 'order-2', at: AT_36 } } assert.notEqual(factIdentity(one), factIdentity(two)) }) test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => { /* 가르는 값이 하나도 없으면 정체를 안 만든다(거르지 않고 통과) — 그 경계가 살아 있는지. */ assert.equal(factIdentity({ eventType: 'x', data: { at: AT_36 } }), undefined, '대상 없는 봉투에 정체를 만들었다') assert.equal(factIdentity({ eventType: 'x', data: { taskId: 'T-1' } }), undefined, '시각 없는 봉투에 정체를 만들었다') assert.ok(factIdentity(quality('T-1', 1))!.includes('task=T-1'), '작업이 정체에 안 들어갔다') })