/* * **나간 지시와 돌아온 관측을 잇는다** — 폐루프의 마지막 칸. 판단만, 저장소는 여기 없다. * * ── 왜 이 칸이 비어 있었나 (2026-09-02) ─────────────────────────────────────── * `dispatchRef` 를 **쓰는 곳만 있고 읽는 곳이 없었다.** 어댑터가 답한 작업지시 이름을 적어 두는데, * 그 이름으로 무엇도 찾지 않았다. 그래서 조치를 넘긴 뒤 저쪽에서 그것이 어떻게 됐는지 트윈은 몰랐다. * * 자리는 있고 길이 없는 상태다. 조치 경로에서 이 부류가 나면 결과가 하나다 — **넘긴 것이 현장에서 * 무엇이 되었는지 아무도 모르는데, 화면은 「넘겼다」까지만 보여 주고 끝난 것처럼 읽힌다.** * * ── 커널은 무엇이 효과인지 정하지 않는다 ───────────────────────────────────── * 오더 상태 어휘는 도메인 소유다(§`OrderStatusDelta.status` — `status: string`, 코어는 강제하지 * 않는다). 어느 값이 「현장에 나갔다」인지는 현장마다 다르고, 커널이 그 방언을 알면 보편 계약이 * 깨진다 — 흐름 판정이 공정 이름을 설비 종류로 환산하지 않는 것과 같은 이유다. * * 그래서 여기서 판단하는 것은 **이어지나** 하나다. * * 이 관측의 오더 식별자가 우리가 낸 지시의 식별자와 같은가 * * 같으면 그 상태를 **해석하지 않고 그대로** 적는다. 무엇을 뜻하는지는 그 어휘를 아는 쪽이 읽는다. */ import type { ISOTime } from '@operato/ops-contract' /** 관측 하나 — 오더 상태 보고에서 필요한 것만. */ export interface OrderObservation { orderId: string status: string /** 이 보고를 관측한 시각. 없으면 이을 수 없다 — 언제인지 모르는 효과는 적지 않는다. */ at?: ISOTime } /** 이을 대상 — 저장소가 `dispatchRef` 로 찾아 준 조치. */ export interface DispatchedCommand { id: string dispatchRef: string /** 이미 적힌 효과가 있나 — 몇 번 보고됐는지를 이어서 센다. */ effect?: { status: string; at: ISOTime; seen: number } } /** 적을 것 하나. */ export interface EffectUpdate { commandId: string status: string at: ISOTime seen: number } export interface EffectMatch { updates: EffectUpdate[] /** 이을 수 없던 관측과 그 이유 — 조용히 버리지 않는다. */ skips: { orderId: string; reason: string }[] } /** * 한 번에 여러 관측을 잇는다. * * **같은 오더가 한 묶음에 여러 번 올 수 있다.** 그때는 마지막 것이 남고 `seen` 이 그만큼 는다 — * 중간 상태를 덮어쓰는 것이 맞다(화면이 묻는 것은 「지금 저쪽에서 무엇인가」다). 다만 몇 번 바뀌었는지는 * 남긴다. 한 번도 안 바뀐 것과 다섯 번 바뀐 것은 다른 일이다. */ export function matchEffects(args: { observations: readonly OrderObservation[] /** `dispatchRef` → 그 지시를 낸 조치. 저장소가 채운다. */ byRef: ReadonlyMap }): EffectMatch { const { observations, byRef } = args const updates = new Map() const skips: { orderId: string; reason: string }[] = [] for (const ob of observations) { const orderId = String(ob?.orderId ?? '').trim() if (!orderId) { skips.push({ orderId: '', reason: '오더 식별자가 없는 보고 — 이을 수 없다' }) continue } const command = byRef.get(orderId) if (!command) { /* * 대개 이것이다 — 우리가 내지 않은 오더의 상태 보고. 저쪽 시스템은 자기 오더를 다 보고하고, * 그중 우리가 낸 것은 일부다. **이유로 남기되 흔한 것임을 적어 둔다** — 이 줄이 쌓이는 것을 * 결함으로 읽으면 안 된다. */ skips.push({ orderId, reason: '우리가 낸 지시가 아니다' }) continue } const status = String(ob?.status ?? '').trim() if (!status) { /* 빈 상태를 적으면 「보고는 왔는데 아무 말도 안 했다」가 「효과 없음」과 같아 보인다. */ skips.push({ orderId, reason: '상태를 말하지 않은 보고 — 빈 값을 효과로 적지 않는다' }) continue } const at = String(ob?.at ?? '').trim() if (!at) { /* 언제인지 모르는 효과는 적지 않는다 — 「지금」으로 채우면 관측 시각이 아니라 처리 시각이 된다. */ skips.push({ orderId, reason: '시각이 없는 보고 — 지금으로 채우지 않는다' }) continue } /* 같은 묶음 안에서 앞서 센 것이 있으면 그것을 잇는다 — 없으면 적힌 효과에서 잇는다. */ const prior = updates.get(command.id)?.seen ?? command.effect?.seen ?? 0 updates.set(command.id, { commandId: command.id, status, at, seen: prior + 1 }) } return { updates: [...updates.values()], skips } } /** * 이 묶음에서 **찾아봐야 할 오더 식별자**만 골라낸다 — 저장소에 물을 것을 줄인다. * * 관측 한 묶음이 수백 건일 수 있고 그중 우리가 낸 것은 몇 건이다. 전부를 하나씩 물으면 유입 경로에 * 질의가 그만큼 붙는다 — 유입은 초당 수천 건이 지나는 자리다. */ export function refsToLookUp(observations: readonly OrderObservation[]): string[] { const out = new Set() for (const ob of observations) { const orderId = String(ob?.orderId ?? '').trim() if (orderId) out.add(orderId) } return [...out] }