/* * **이 사건은 무엇에 대한 것인가** — 사건 하나에서 가리키는 대상들을 뽑는다. * * ── 왜 필요한가 (2026-08-27 실측) ─────────────────────────────────────────── * 360 화면이 「이 대상의 이력」을 묻는다. 그 조회가 64초다. 대상으로 걸러 시각순으로 정렬하는 목차가 * 없기 때문이고, 목차를 얹어도 풀리지 않는다. * * 부분일치(지금 화면) 63.998초 앞에 와일드카드가 붙어 목차를 쓸 수 없다 * 정확일치로 바꾸면 67.316초 · 0건 품목 하나에 로트가 여럿이라 성립하지 않는다 * 앞부분 범위로 바꾸면 68.402초 목차는 쓰지만 정렬 때문에 맞는 행을 다 가져온다 * * 저장된 식별자에 품목과 로트가 한 문자열로 붙어 있는 것이 그 원인이다. * * https://seunghwa.net/class/product-M0000618-lot-L2604100071 * * 그래서 문자열을 자르는 대신 **대상마다 한 줄**을 적는다. 같은 사건이 품목과 로트를 함께 가리키면 * 두 줄이다. 자를 규칙이 필요 없다 — 넣는 쪽이 이미 둘을 안다. * * ── 왜 「뜻이 달라지는 키 칸」이 아닌가 ───────────────────────────────────── * `key01~05` 처럼 칸을 두고 사건 종류에 따라 뜻을 달리 쓰는 방식을 **이 표에는** 쓰지 않는다. * * 그 방식 자체가 틀린 것은 아니다. 데이터셋마다 정의가 따로 있는 프레임워크에서는 합리적이다 — 읽는 * 쪽이 그 정의를 보고 해석한다. 우리 저널은 표 하나를 화면 여럿이 읽고 볼 정의가 없다. 그래서 * 「M0000438 을 찾아 줘」가 어떤 줄에서는 품목, 다른 줄에서는 오더가 되고, 묻는 쪽이 사건 종류마다 * 어느 칸에 무엇이 들어가는지 외워야 한다. * * **종류를 값으로 밝힌다.** 그러면 읽는 쪽이 사건 종류표를 들고 있지 않아도 된다. * * ── 왜 순수 모듈인가 ──────────────────────────────────────────────────────── * 기록 경로에서 불리므로 DB·데코레이터를 알지 않는다. 그래야 테스트가 값으로 확인할 수 있다 * (`twin-event-keys` 와 같은 이유다). * * 설계: `operato-twin/design/plans/read-models.md` */ import { twinEventKeys } from '../twin-event/twin-event-keys.js' /** * 대상의 종류 — **닫힌 목록**이다. * * 열어 두면 넣는 쪽마다 다른 이름을 쓰게 되고(설비를 세 가지 이름으로 부르는 식), 그러면 조회가 * 어느 이름으로 물어야 하는지 알 수 없다. 늘려야 하면 이 목록에 더한다. * * item 선언된 품목. 로트 여럿이 같은 품목을 가리킨다 * lot 개체 하나. 저장된 식별자 그대로다 * order 오더 * location 자리 * equipment 설비 * task 작업 * transaction 거래(PO/SO/생산오더) — 오더와 다르다. 전에는 오더 칸에 섞여 들어와 한 오더의 * 이력이 두 쪽으로 갈라졌다(2026-09-05) */ export const SUBJECT_KINDS = ['item', 'lot', 'order', 'location', 'equipment', 'task', 'transaction'] as const export type SubjectKind = (typeof SUBJECT_KINDS)[number] export interface EventSubject { kind: SubjectKind id: string } /** * 개체 식별자에서 **선언된 품목**을 찾아 주는 함수 — 부르는 쪽이 넘긴다. * * 이 모듈이 문자열을 자르지 않는 이유는 그 규칙이 연결된 시스템마다 다르기 때문이다. 어느 부호가 * 로트를 가리키는지 커널 계약이 정한 적이 없고, chef 의 `-lot-` 을 여기에 굳히면 이 파일이 그 * 시스템 위에 서게 된다(`item-ref.ts` 가 같은 판단을 적어 두었다). * * 그래서 판정은 **그 트윈의 선언**과 견주어 하고(`resolveByDeclaration`), 그 선언을 아는 것은 * 부르는 쪽이다. 답하지 못하면 `undefined` — 그때는 품목 줄을 만들지 않는다. 짐작으로 이으면 남의 * 품목 이력에 이 사건이 섞인다. */ export type ItemResolver = (epc: string) => string | null | undefined /** 컬럼 길이 상한 — `twin-event-keys` 와 같은 값이다(같은 표에 나란히 서는 값이므로). */ const MAX_ID = 255 function put(out: EventSubject[], kind: SubjectKind, raw: unknown): void { if (raw === undefined || raw === null) return const id = String(raw).trim() if (!id || id.length > MAX_ID) return /* 같은 대상을 두 번 적지 않는다 — 사건 하나에 같은 자리가 두 번 실려 올 수 있다. */ if (out.some(s => s.kind === kind && s.id === id)) return out.push({ kind, id }) } /** * 사건 하나가 가리키는 대상들 — 없으면 빈 배열이다. * * 승격된 검색 키(`twinEventKeys`)를 그대로 쓴다. 그 함수가 이미 사건 종류별로 어디에서 값을 꺼낼지 * 알고 있으므로, 여기서 다시 판단하면 두 곳이 어긋난다. * * **작업은 승격 키에 없어서 봉투에서 직접 읽는다.** 승격 컬럼을 늘리는 것은 이 작업의 범위가 아니다 * (그것이 바로 이 파생 표로 대신하려는 일이다). */ export function subjectsOf(envelope: any, opts?: { itemOf?: ItemResolver }): EventSubject[] { const keys = twinEventKeys(envelope) const out: EventSubject[] = [] /* * ── 개체로 온 것과 클래스로 온 것을 갈라 적는다 (2026-08-27 실측) ──────────── * * `epcOf` 는 셋을 한 칸에 담는다 — `epcList[0]` · `parentID` · `quantityList[0].epcClass`. * 승격 컬럼이 하나라서 그렇게 되어 있고, 그 칸으로는 「개체인가 클래스인가」를 알 수 없다. * * 실측한 chef 의 사건 하나가 그 경우다. 물건을 개체로 세지 않고 무게로 담는 현장이라 `epcList` 가 * 비어 있고 `quantityList` 에 클래스가 있다. * * epcList [] * quantityList [{ epcClass: 'https://…/product-M0000879', quantity: 11797.6, uom: 'KGM' }] * * 그것을 로트로 적으면 뜻이 틀린다 — 개체가 아니라 품목이다. 이 표는 종류를 값으로 밝히므로 * 갈라 적을 수 있다. */ const d = envelope?.data ?? envelope ?? {} const instanceEpc = d.epcList?.[0] ?? d.parentID const classEpc = d.quantityList?.[0]?.epcClass if (instanceEpc) { /* 개체는 저장된 식별자 그대로다 — 자르지 않는다. */ put(out, 'lot', instanceEpc) /* * 품목은 선언과 견주어 찾는다. 답이 개체 식별자와 같으면 품목을 찾지 못한 것이므로 줄을 만들지 * 않는다 — `itemRefOf` 는 URI 세계에서 문자열을 그대로 돌려주기 때문에 그 값이 그대로 올 수 있다. */ const item = opts?.itemOf?.(String(instanceEpc)) if (item && item !== instanceEpc) put(out, 'item', item) } if (classEpc) { /* * 클래스로 온 것은 개체 줄을 만들지 않는다 — 그 사건에 개체가 없다. 품목 줄만 만든다. * * 선언과 견주어 품목을 찾고, 찾지 못하면 클래스 문자열을 그대로 품목으로 쓴다. 클래스는 이미 품목을 * 가리키는 값이므로 그것이 짐작이 아니다(개체 식별자를 그대로 품목으로 쓰는 것과 다르다). */ const item = opts?.itemOf?.(String(classEpc)) ?? null put(out, 'item', item || classEpc) } put(out, 'order', keys.orderId) /* * 거래는 오더가 아니다 — 갈라 적는다. 합쳐 적으면 EPCIS 사건의 GDTI 가 오더 이름으로 앉고, * 그러면 한 오더의 이력이 두 쪽이 된다(`twin-event-keys.ts` 의 그 실측). */ put(out, 'transaction', keys.bizTransaction) put(out, 'location', keys.locationId) put(out, 'equipment', keys.moverId) const data = envelope?.data put(out, 'task', data?.taskId) /* * ── 작업이 오간 두 자리도 적는다 ──────────────────────────────────────────── * * 승격 컬럼(`locationId`)은 EPCIS 자리만 담는다(`readPoint` · `bizLocation` · `location`). 작업이 * A 에서 B 로 갈 때 그 컬럼에는 **둘 중 하나만** 넣을 수 있어서 아무것도 넣지 않는다. * * 여기서는 둘 다 적는다. 이것이 승격 컬럼과의 어긋남이 아니라 **이 표가 할 수 있는 일**이다 — * 자리 하나에 줄 하나이므로 두 자리를 함께 담는다. 「그 자리에서 무슨 일이 있었나」를 묻는 화면은 * 출발지와 도착지를 둘 다 그 자리의 사건으로 본다. */ put(out, 'location', data?.fromNode) put(out, 'location', data?.toNode) return out }