import { type CanonicalRecord, type CanonicalAggregationRecord, type CanonicalTransformationRecord, type AdapterRule, type EnergyRecord, type EnergyEquipmentRecord, type EnergyGenerationRecord, type EnergyUsagePeriodRecord, type EnergyBillRecord, type EnergyTariffBasisRecord, type EnergyGenerationPriceRecord, type EnergyGenerationPeriodRecord, type OperationalRecord, type IngestResult, type MasterDataRecord } from '@operato/ops-contract' import { ingest, ingestEnergyRecords, ingestEnergyEquipmentRecords, ingestEnergyGenerationRecords, ingestEnergyUsagePeriodRecords, ingestEnergyBillRecords, ingestEnergyTariffBasisRecords, ingestEnergyGenerationPriceRecords, ingestEnergyGenerationPeriodRecords, ingestOperationalRecords, isAggregationRecord, isTransformationRecord, isEnergyRecord, isEnergyEquipmentRecord, isEnergyGenerationRecord, isEnergyUsagePeriodRecord, isEnergyBillRecord, isEnergyTariffBasisRecord, isEnergyGenerationPriceRecord, isEnergyGenerationPeriodRecord, isOperationalRecord, isMasterDataRecord, ingestMasterData } from '@operato/ops-contract' import { factEventTime, undatedByKind } from './event-time.js' /* * 정규 레코드 → 커널 검증 ACL → CanonicalEnvelope (P1 seam 코어). * 순수(커널만 의존; shell·integration-base 무의존 → node:test 검증 가능). * 매핑(레거시 스키마 → 정규)은 이 앞 단계(jsonata step 또는 커넥터 openLiveFeed)가 끝낸다. * 여기 도착하는 레코드는 이미 정규 필드다 — 어휘마다 자기 필드 이름이 계약이다: * EPCIS 품목 { epc, action, bizStep, disposition?, readPoint?, bizLocation? } * 에너지 계량 { meterId, kW?, kWh?, … } · 설비 에너지 { equipmentId, generatedKW?, soc?, … } * 발전 적산 { equipmentId, kWh, kWhSince?, … } * 사용 구간 { meterId, from, to, kWh, unitPrice?, basis?, … } · 청구서 { from, to, total?, … } * 요금 기준 { from, to, billingDemandKW?, demandChargePerKW?, currency } * 담김(적재) { parentID, childEPCs[] | childQuantityList[], action, bizStep, … } * 운영 사실 { taskId | moverId | personId | assetId | orderId, status, … } // vocabulary-guard: allow 저널 와이어 필드 * 커널은 objectEvent 구성 + EPCIS 검증(위반=오염 차단) + 봉투 래핑만(방언 없음). * * 두 소비처가 이 단일 코어를 공유(중복 금지): * - sim/scenario 경로: operato-twin twin-ingest 태스크(http-get→jsonata→여기). * - live 커넥터 경로: reference-live(어댑터 openLiveFeed→여기). * SoT: design/integration/face2-adapters.md §5(seam)·§7(매핑=밖, 검증=커널). */ /* * **표준 헤더 필드는 한 번만 적는다** — 세 규칙이 이것을 펼쳐 쓴다. * * ── 왜 뽑아냈나 (2026-09-07) ──────────────────────────────────────────────── * 이 파일은 규칙 셋을 손으로 나란히 적고 있었고, 그래서 헤더 필드를 더할 때마다 **세 곳을 다 고쳐야** * 했다. 그 구조가 같은 결함을 네 번 만들었다. * * ``` * quantityList 낱개 번호 없는 자재의 잔량을 말할 길이 없었다 * ilmd 포천 재고 1,704행 전부에 소비기한이 있는데 상태에는 0건 * errorDeclaration 정정이 또 하나의 사실로 쌓였다 * bizTransactionList 저널에 칸·인덱스·질의가 다 있는데 실 시스템 연결에서 0건 * ``` * * 네 번째는 계약을 열고도 **이 파일이 그 스펙을 안 써서** 여전히 사라지고 있었다. 인티그레이션 레인이 * 규칙 그대로 `mapRecord` 를 돌려서 찾았다 — 읽어서 맞다고 하지 않고 돌려 본 것이 잡았다. * * 이제 헤더 필드를 더하는 것은 **이 상수 한 줄**이고, 세 갈래에 동시에 닿는다. * * `action` 은 여기 없다 — `TransformationEvent` 에는 그 칸이 없다(무엇이 무엇으로 되었나에는 방향이 * 하나뿐이다). 그것만 규칙마다 적는다. */ const TWIN_INGEST_HEADER = { bizStep: '$.bizStep', disposition: '$.disposition', /* * **정정 선언** — 「이 사건은 없던 일이 되었다」(커널 0.7.65 에서 이 자리가 열렸다). * * 세 값이 함께여야 뜻이 있어 묶음으로 온다. 커널이 선언 시각을 요구하고, 없으면 사건 시각으로 * 메우지 않고 이유와 함께 돌려준다 — 「언제 정정했나」와 「언제 일어났나」가 같아지면 둘을 다시 * 갈라낼 수 없다. * * 상태는 이것으로 흔들리지 않는다. 커널은 정정을 목록에 적고 재고를 그대로 둔다 — 무엇을 어떻게 * 되돌릴지는 도메인 판단이고, 참 재고는 그 뒤의 잔량 관측이 다시 말해 준다. */ errorDeclaration: { declarationTime: '$.errorDeclaration.declarationTime', reason: '$.errorDeclaration.reason', correctiveEventIDs: '$.errorDeclaration.correctiveEventIDs' }, /* * **오더 참조** — 「이 물건은 어느 오더 것인가」(표준 §7.3.5.4, 계약 0.9.11 에서 이 자리가 열렸다). * * 받는 쪽은 처음부터 다 서 있었다: 저널에 `biz_transaction` 칸과 인덱스(`ix_twin_event_9`), 이력추적 * 질의가 그 칸으로 거르고, 커널이 네 자리에서 그것을 채운다. 그래서 커널 안쪽에서 나는 사실만 그 * 칸을 갖고 실 시스템이 먹이는 트윈은 조용히 비었다 — 시뮬 2,492건 대 `mes-line-a` 0건. */ bizTransactionList: '$.bizTransactionList', readPoint: '$.readPoint', bizLocation: '$.bizLocation' } as const /** 고정 정규 매핑(방언 아님 — 필드명 자체가 정규 계약). `sourceType` 으로 어느 품목 사실인지 고른다. */ const TWIN_INGEST_RULES: AdapterRule[] = [ { sourceType: 'twin', mapping: { type: 'ObjectEvent', ...TWIN_INGEST_HEADER, action: '$.action', epc: '$.epc', /* 비직렬 수량 — 개체 없이 「이 자리에 이 품목이 얼마 남았다」만 오는 관측이 있다. */ quantityList: '$.quantityList', /* * **개체·로트의 마스터데이터**(로트 번호·소비기한 등) — 커널 0.7.58 에서 이 자리가 열렸다. * * 실측(포천): 원본 재고 1,704행 **전부**에 소비기한이 있는데 투영된 상태에는 **0건**이었다. 지난 * 재고 950건·59,785kg 이 어디에도 나타나지 않았고, 빈 화면이 「이상 없음」으로 읽혔다. * * ★ **표준이 `action: 'ADD'` 일 때만 허용한다**(또는 변환). 마스터데이터는 로트가 **생길 때** * 말하는 사실이고, 주기 관측마다 실으면 커널이 거부한다 — 알리지 않고 통과시키지 않는 것이 옳다 * (커넥터가 첫 목격에 `ADD`, 그 뒤로 `OBSERVE` 를 내야 한다). */ ilmd: '$.ilmd', /* * **정정 선언** — 「이 사건은 없던 일이 되었다」(커널 0.7.65 에서 이 자리가 열렸다). * * 세 값이 함께여야 뜻이 있어 묶음으로 온다. 커널이 선언 시각을 요구하고, 없으면 사건 시각으로 * 메우지 않고 이유와 함께 돌려준다 — 「언제 정정했나」와 「언제 일어났나」가 같아지면 둘을 다시 * 갈라낼 수 없다. * * 상태는 이것으로 흔들리지 않는다. 커널은 정정을 목록에 적고 재고를 그대로 둔다 — 무엇을 어떻게 * 되돌릴지는 도메인 판단이고, 참 재고는 그 뒤의 잔량 관측이 다시 말해 준다. */ } }, { /* **변환의 사실** — 무엇으로 무엇이 되었나. 이름은 표준의 것을 그대로 쓴다(갈래 판정이 그 이름을 본다). */ sourceType: 'twin-transformation', mapping: { type: 'TransformationEvent', ...TWIN_INGEST_HEADER, inputEPCList: '$.inputEPCList', inputQuantityList: '$.inputQuantityList', outputEPCList: '$.outputEPCList', outputQuantityList: '$.outputQuantityList', transformationID: '$.transformationID', } }, { /* * **담김의 사실** — 무엇이 무엇에 실렸나(팔레트·상자). 오래 비어 있던 자리다: 관측 리듀서는 이 사실을 * 받으면 자식 물품에 `parent` 를 붙이는데, 문이 `ObjectEvent` 하나만 알아서 원본은 적재를 말할 길이 * 없었다(시뮬 원본을 붙여 돌려 보고서야 로그로 드러났다). */ sourceType: 'twin-aggregation', mapping: { type: 'AggregationEvent', ...TWIN_INGEST_HEADER, action: '$.action', parentID: '$.parentID', childEPCs: '$.childEPCs', /* 낱개 식별자 없이 **수량으로** 담긴 자식(「이 품번 40개」) — 입고·포장이 이 모양으로 온다. */ childQuantityList: '$.childQuantityList', } } ] /* * 규칙을 시험이 돌릴 수 있게 낸다 — **읽어서 맞다고 하지 않기 위해.** * * 오늘 이 자리가 정확히 그것으로 새고 있었다: 계약을 열고 계약 시험을 초록으로 만든 뒤에도, 이 파일이 * 그 스펙을 안 써서 값이 계속 사라졌다. 인티그레이션 레인이 이 규칙 그대로 `mapRecord` 를 돌려 봐서 * 찾았다 — 규칙이 파일 안에 갇혀 있으면 그것을 돌려 볼 방법이 없다. * * 복사본을 낸다. 부르는 쪽이 고쳐도 유입 경로가 바뀌지 않아야 한다. */ export function canonicalRulesForTest(): AdapterRule[] { return TWIN_INGEST_RULES.map(r => ({ ...r, mapping: { ...r.mapping } })) } /* * 정규 레코드 셋(`CanonicalRecord` · `CanonicalTransformationRecord` · `CanonicalAggregationRecord`)은 * **계약으로 옮겼다**(2026-08-30, `@operato/ops-contract` 0.2.0). * * 여기 있던 이유는 소비 방식 때문이었다 — EPCIS 는 커널의 범용 어댑터를 지나서 타입을 요구하지 않았다. * 그런데 모양 자체는 EPCIS 2.0 의 필드 이름이고 호스트의 것이 아니다. 이제 MES 가 이 모양 그대로 * 보낸다. * * **매핑 룰(`TWIN_INGEST_RULES`)은 여기 남는다** — 이 모양을 EPCIS 사건으로 옮기는 정책이지 모양이 아니다. */ /** * 거절 목록의 사본을 원본 참조로 되돌린다 — **훅이 객체 동일성으로 봉투와 짝짓기 때문이다.** * * 지도에 없는 것은 그대로 둔다. 사본을 만들지 않는 갈래(에너지·운영·어휘)의 거절이 그렇고, 그것들은 * 이미 원본을 들고 있다. */ function toOriginals( originalOf: Map, rejected: { record: unknown; errors: string[] }[] ): { record: unknown; errors: string[] }[] { if (!originalOf.size) return rejected return rejected.map(r => { const original = originalOf.get(r.record) return original === undefined ? r : { record: original, errors: r.errors } }) } /** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */ export function ingestCanonicalRecords( records: | CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord | EnergyRecord | EnergyEquipmentRecord | OperationalRecord /* 다섯째 어휘 — 사건이 아닌 것(변하지 않는 속성). 저널로 가지 않는다(§`CanonicalIngestResult`). */ | MasterDataRecord | ( | CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord | EnergyRecord | EnergyEquipmentRecord | OperationalRecord | MasterDataRecord )[] | undefined | null, tenantId: string, defaultEventTime: string, /** * **이 사실들이 어느 트윈의 것인가** — 마감된 구간 사실의 이름에 쓰인다(커널 §`SubjectBasis`). * * 커널은 도메인으로 서므로 자기가 어느 트윈인지 모른다. 그런데 설비 번호·계량 지점 번호는 트윈 * 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 된다. * 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳤다. * * 주지 않으면 이름표를 그대로 쓰고 `twin-local` 로 표시된다 — 겹칠 수 있다는 뜻이다. */ scope?: { scopeId?: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined } ): CanonicalIngestResult { const arr = Array.isArray(records) ? records : records ? [records] : [] /* * ── 사본을 만들었으면 나가는 길에 되돌린다 (2026-09-08 실측) ───────────────── * * 아래에서 레코드마다 `{ ...r, eventTime, sourceType }` 로 **새 객체**를 만들어 계약의 유입 * 함수에 넘긴다. 그 함수들은 거절한 것을 `{ record, errors }` 로 내는데, 그 `record` 는 **사본**이다. * * 훅은 거절된 레코드를 봉투와 짝지어 `eventId` 를 실어 보낸다(§`rejectedForCaller`). 그 짝은 * **객체 동일성**으로 짓는다 — 값으로 비교하면 같은 모양의 레코드 둘이 서로의 id 를 가져가기 * 때문이다. 그런데 사본은 원본과 다른 객체라 그 지도에 없고, 결과가 이렇게 나왔다. * * ``` * 보낸 것 {"eventId":"probe-eventid-check-001", "record":{"kind":"transformation"}} * 온 것 rejected: [{ record: {kind:"transformation", sourceType:"twin"}, errors:[…] }] * ↑ eventId 가 없다. 보내는 쪽이 어느 행이 떨어졌는지 모른다 * ``` * * 보내는 쪽은 그래서 정산을 못 하고 큐가 안 움직인다(plant 실측: `PENDING 719`). 사실은 하나도 * 안 잃지만 나아가지도 않는다. * * **사본을 만든 자리가 되돌린다.** 훅이 값 비교로 내려가면 오늘 그 짝짓기를 객체 동일성으로 고른 * 이유가 없어지고, 계약의 유입 함수들이 원본을 들고 있게 바꾸면 그쪽이 사본을 만들 자유를 잃는다. * * 그리고 이 결함이 시험에 안 걸린 이유를 적어 둔다 — `hook-rejected-shape.test.ts` 가 * `rejectedForCaller` 에 **양쪽 다 같은 객체**를 직접 넘긴다. 순수 시험은 그 사이에 누가 사본을 * 만드는지 보지 않는다. */ const originalOf = new Map() /* * ── **다섯째 어휘는 사건이 아니다** (2026-08-28) ──────────────────────────── * * 마스터데이터 — 변하지 않는 속성(유통기한·로트번호·자리의 성질). 표준은 이것을 사건이 아니라 * **어휘 요소의 속성**으로 담고, 커널에 그 자리가 없어서 지금까지 사건에 실려 왔다. * * 그 결과가 유입에서 이렇게 났다. 전량을 읽는 원본이 유통기한을 실으려고 **들어오지 않은 것을 * `ADD`** 라고 말했고, `ilmd` 가 사건 단위라 자리별로 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에 * 2,665건이다. * * 그래서 여기서 갈라 낸다. 이것은 봉투가 되지 않고 저널에도 적히지 않는다(시각축이 없는 값이다). * 갈래 판정은 커널의 것을 부른다 — 다른 넷과 같은 규율이다. * * 설계: `operato-twin/design/plans/master-data.md` */ const masterCandidates = arr.filter(isMasterDataRecord) const master = masterCandidates.length ? ingestMasterData(masterCandidates) : { accepted: [], rejected: [] } /* * **어휘가 넷이다** — 물류(EPCIS)·에너지 계량·설비 에너지·운영 사실. 예전에는 이 함수가 무조건 * `ObjectEvent` 를 만들었고, 그래서 계측을 이 길로 넣으면 `epc` 가 없어 거부되거나(그나마 나은 쪽) * 엉뚱한 물품 관측이 됐다. * * 물류 어휘 안에는 모양이 둘이다(개체 관측 `epc` / 담김 `parentID`+`childEPCs`). * * 판정은 커널이 한 곳에서 한다(`isEnergyRecord`·`isOperationalRecord`·`isAggregationRecord`) — 여기서 다시 짐작하면 * 소비처마다 답이 달라진다. 봉투는 같은 것을 쓰므로 아래 소비처(미러·저널·브로드캐스팅)는 이 갈림을 알 * 필요가 없다. */ /* * 여섯째·일곱째 어휘 — **마감된 사용 구간**과 **청구서**. 커널 0.7.72 가 이 문들을 열었다. * * ── ★ 사용 구간을 계량보다 **먼저** 가른다 ───────────────────────────────── * `isEnergyRecord` 는 `meterId` 가 있으면 참이므로 사용 구간에도 참이다. 순서를 정하지 않으면 * 시간별 사용량이 계량 표본으로 들어가고, 그 문은 `kWh` 를 **적산 레지스터**로 읽는다. 그러면 * 커널이 구간 전력량을 차분으로 구하는데 우리가 넣은 값이 이미 구간의 양이다 — 차분의 차분이 되어 * 값이 조용히 틀린다. 거부되지도 않는다. */ const usagePeriods = arr.filter(isEnergyUsagePeriodRecord) as unknown as EnergyUsagePeriodRecord[] const bills = arr.filter(isEnergyBillRecord) as unknown as EnergyBillRecord[] /* * 여덟째 어휘 — **그 주기에 적용되는 요금 기준**. 커널 0.7.76 이 이 문을 열었다. * * ★ 청구서와 겹친다. 청구서가 `billingDemandKW` 를 실을 수 있고, 그러면 두 판정이 다 참이다. * 그때 요금 기준 문으로 보내면 「금액은 이 문이 받지 않는다」로 거부된다 — 정산이 통째로 * 사라진다. 그래서 청구서인 것을 뺀다. 커널에서 배타로 만들어 달라고 올렸다. */ const tariffBases = arr.filter(r => isEnergyTariffBasisRecord(r) && !isEnergyBillRecord(r)) as unknown as EnergyTariffBasisRecord[] /* * 아홉째 어휘 — **발전 단가**. 낸 것에 매겨지는 1kWh 당 값이고 날마다 바뀐다(커널 0.7.79). * * 다른 문과 겹치지 않는다: 계량 지점이나 설비 id 가 있으면 이 문이 아니고(커널이 배타로 막는다), * 요금적용전력·기본요금 단가가 없으므로 요금 기준도 아니다. */ const generationPrices = arr.filter(isEnergyGenerationPriceRecord) as unknown as EnergyGenerationPriceRecord[] /* 마감된 발전 기간 — 지난 기록 채우기가 쓴다. 적산의 문과 배타다(구간이 있으면 이쪽). */ const generationPeriods = arr.filter(isEnergyGenerationPeriodRecord) as unknown as EnergyGenerationPeriodRecord[] const energy = arr.filter(r => isEnergyRecord(r) && !isEnergyUsagePeriodRecord(r)) as EnergyRecord[] /* 세 번째 어휘 — 설비가 낸 자기 에너지 상태(발전·저장·감축 여지·개폐 위치). 계량과 갈라 두는 이유는 커널에 적혀 있다: 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다. */ /* * 다섯 번째 어휘 — **발전 적산**(설비가 지금까지 만든 양). 커널 0.7.69 가 이 문을 열었다. * * ── ★ 설비 상태보다 **먼저** 가른다 ────────────────────────────────────────── * 두 판정이 겹친다. `isEnergyEquipmentRecord` 는 `equipmentId` 가 있고 물류·계량 어휘가 아니면 * 참이므로 발전 적산도 함께 참이다. 그래서 순서가 갈림을 정한다. * * 순서를 잘못 두면 조용히 사라지는 것이 아니라 **엉뚱한 이유로 거부된다**: 설비 상태 문은 적산을 * 값으로 세지 않아 「바꿀 상태가 하나도 없다」로 돌려보낸다. 커넥터는 보냈다고 믿고, 화면에는 * 발전량이 없다. */ const energyGeneration = arr.filter(isEnergyGenerationRecord) as unknown as EnergyGenerationRecord[] const energyEquipment = arr.filter(r => isEnergyEquipmentRecord(r) && !isEnergyGenerationRecord(r)) as unknown as EnergyEquipmentRecord[] /* * 네 번째 어휘 — **운영 사실**(작업·설비·사람·자산·오더·품질). 관측 리듀서는 이것을 오래전부터 * 계산했는데 **들어올 문이 없었다**: 원본이 「이 작업이 끝났다」를 말할 길이 없어 시뮬 커널만 그것을 * 아는 상태가 남았다(같은 화면이 두 구동에서 다른 것을 말한다). */ const operational = arr.filter(isOperationalRecord) as OperationalRecord[] /* * ── 이 목록은 **어휘를 더할 때마다 함께 늘어야 한다** (2026-08-30) ───────── * * EPCIS 갈래는 「나머지 전부」다. 새 어휘를 빼 주지 않으면 그 레코드가 제 문으로 가면서 * **EPCIS 문으로도 들어가** 거부된다. 받아들여지기도 하고 거부되기도 하는 상태가 되어, * 유입 장부의 거부 수가 늘고 사람이 그것을 진짜 결함으로 읽는다. * * 실제로 발전 단가를 더할 때 그렇게 됐다. 목록을 손으로 지키는 대신 시험이 지킨다 * (§`canonical-ingest-vocabularies` — 어휘마다 한 문으로만 가는지). */ const epcis = arr.filter( r => !isEnergyRecord(r) && !isEnergyEquipmentRecord(r) && !isEnergyGenerationRecord(r) && !isEnergyUsagePeriodRecord(r) && !isEnergyBillRecord(r) && !isEnergyTariffBasisRecord(r) && !isEnergyGenerationPriceRecord(r) && !isEnergyGenerationPeriodRecord(r) && !isOperationalRecord(r) && !isMasterDataRecord(r) ) as (CanonicalRecord | CanonicalAggregationRecord | CanonicalTransformationRecord)[] /* 품목 어휘 안에서도 갈림이 있다(개체 관측 / 담김) — 그 판정도 커널의 것을 부른다. */ const epcisResult = epcis.length ? ingest( epcis.map(r => ({ ...r, /* * ── **시각의 이름을 둘 흡수한다** (2026-08-24 실측) ──────────────────── * `eventTimePath` 는 **한 이름**만 받는다. 호스트가 `'eventTime'` 으로 못박아 두었는데, 뒤에 * 생긴 커넥터는 `at` 을 싣는다(에너지 채널이 이미 `at` 을 쓰고 있어 그 어휘를 따랐다). * 그래서 그 커넥터의 사건은 **전부 폴링 순간**으로 찍혔다 — 저널 18,150건이 그랬다. * * 그 결과 둘이 함께 망가졌다: 재고 관측이 트윈의 시계를 밀지 못해 시계가 `task.status` 의 * 최댓값(2026-04-15)에 **멈춰 있었고**, 이동 사건에서 「언제」가 사라져 순서만 남았다. * * 이름을 하나로 강요하지 않고 흡수한다 — `orderId`·`resourceRef`·`equipmentId` 를 함께 보는 * 것과 같은 규율이다(§`twin-event-keys`). 세대가 섞이는 것은 이 이음새의 성질이다. * * **없으면 만들지 않는다**: 절대값 스냅샷 관측은 원본의 갱신 시각이 아니라 **폴링 순간**이 * 맞는 관측 시각이다(「어제 값을 오늘 봤다」는 「어제 일어났다」가 아니다). 값을 말하는 * 레코드만 자기 시각을 갖는다. */ /* * ── 세 번째로 같은 자리를 밟았다 (2026-09-06) ──────────────────────── * 여기가 `eventTime ?? at` **두 이름만** 보고 있었다. `nonconformance.disposition` 은 * `data.decidedAt` 을 싣고(contract 가 required), 그래서 ingest 시각으로 떨어졌다 — * backfill 로 5일 지난 사실을 받으니 5일 틀린 값이 저장됐다. * * 이름을 하나 더 넣는 것으로는 안 끝난다. type 마다의 칸을 한 표에 두고, 못 읽은 것은 * 세어서 낸다(§`event-time.ts`). */ ...(factEventTime(r) !== undefined ? { eventTime: factEventTime(r) } : {}), /* 판정은 커널이 한 곳에서 한다 — 여기서 다시 짐작하면 소비처마다 답이 달라진다. */ sourceType: isTransformationRecord(r) ? 'twin-transformation' : isAggregationRecord(r) ? 'twin-aggregation' : 'twin' })).map((copy, i) => { /* 사본 → 원본. 거절이 사본을 들고 나오면 이 지도로 되돌린다(§ 위 머리말). */ originalOf.set(copy, epcis[i]) return copy }), TWIN_INGEST_RULES, /* * **레코드가 시각을 말하면 그것을 쓴다** (2026-08-23). * * `eventTimePath` 를 주지 않던 동안 이 경로는 **언제나 폴링 시각**을 썼다(`defaultEventTime`). * 그래서 커넥터가 이미 싣고 있던 `eventTime`(변환 레코드의 `finishedAt` 등)이 **알리지 않고 * 무시됐다** — 커넥터를 붙인 쪽은 그것이 먹는다고 믿고 있었고, 어디에도 오류가 나지 않았다. * * 무엇이 걸렸나: 계보(변환)의 시각이다. 4월에 만든 로트가 8월에 만들어진 것으로 저널에 남았다. * 그리고 실측 소요를 재는 폴드가 봉투 시각을 쓰므로(§`kpi-fold`: `workMs = 완료 − 첫 착수`), * 폴링 간격을 작업시간으로 배울 수 있었다 — 못 배우는 것보다 나쁘다. * * 레코드에 `eventTime` 이 없으면 커널이 알리지 않고 기본값으로 떨어진다(§`resolve` — 없는 값은 * 오류가 아니다). 그래서 이 한 줄이 기존 경로를 막지 않는다: 시각을 말하는 레코드만 사실이 된다. */ { tenantId, defaultEventTime, eventTimePath: 'eventTime' } ) : { accepted: [], rejected: [] } const energyResult = energy.length ? ingestEnergyRecords(energy, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const equipmentResult = energyEquipment.length ? ingestEnergyEquipmentRecords(energyEquipment, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const usageResult = usagePeriods.length ? ingestEnergyUsagePeriodRecords(usagePeriods, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const billResult = bills.length ? ingestEnergyBillRecords(bills, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const tariffBasisResult = tariffBases.length ? ingestEnergyTariffBasisRecords(tariffBases, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const generationPeriodResult = generationPeriods.length ? ingestEnergyGenerationPeriodRecords(generationPeriods, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const generationPriceResult = generationPrices.length ? ingestEnergyGenerationPriceRecords(generationPrices, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } const generationResult = energyGeneration.length ? ingestEnergyGenerationRecords(energyGeneration, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } /* * ── fact type 마다 시각 칸 이름이 다르다 (2026-09-06) ──────────────────────── * 운영 레코드의 봉투 시각은 `at` 이다. 그런데 `nonconformance.disposition` 은 자기 시각을 * **payload 의 `decidedAt`** 에 싣는다(contract 가 그 자리에 required 로 선언했다). 그래서 `at` 이 * 없고 `defaultEventTime`(= ingest 시각)으로 떨어졌다. * * ``` * [측정함 2026-09-06, mes-line-a backfill 뒤] * event_time 2026-09-05 23:50:51 data.decidedAt 2026-09-01T02:50:51.832Z 5일 차이 * ``` * * push 경로에서는 몇 초 차이라 아무도 못 알아챈다. backfill 이 그 간격을 벌려서 드러냈다 — * backfill 이 만든 결함이 아니라 원래 있던 것이 보인 것이다. * * 여기서 `at` 을 채워 넣는다. 더 깊은 고침은 contract 쪽이지만(선언과 봉투가 다른 이름을 쓰는 * 비대칭), 그것은 배포를 기다려야 하고 그동안 값이 계속 틀리게 저장된다. */ const operationalDated = operational.map(r => { const at = factEventTime(r) return at !== undefined && (r as any).at === undefined ? { ...(r as any), at } : r }) const operationalResult = operationalDated.length ? ingestOperationalRecords(operationalDated, { tenantId, defaultEventTime, ...scope }) : { accepted: [], rejected: [] } /* 거부분을 합쳐 돌려준다 — 어느 어휘에서 거부됐는지는 이유 문구가 말한다. */ return { accepted: [ ...epcisResult.accepted, ...energyResult.accepted, ...equipmentResult.accepted, ...generationResult.accepted, ...usageResult.accepted, ...generationPriceResult.accepted, ...generationPeriodResult.accepted, ...billResult.accepted, ...tariffBasisResult.accepted, ...operationalResult.accepted ], rejected: toOriginals(originalOf, [ ...epcisResult.rejected, ...energyResult.rejected, ...equipmentResult.rejected, ...generationResult.rejected, ...usageResult.rejected, ...billResult.rejected, ...tariffBasisResult.rejected, ...generationPriceResult.rejected, ...generationPeriodResult.rejected, ...operationalResult.rejected, ...master.rejected ]), /* 사건이 아닌 것은 따로 낸다 — 부르는 쪽이 상태 세우는 문으로 보낸다(저널로 가지 않게). */ masterData: master.accepted, /* * **시각을 못 읽어 ingest 시각으로 떨어진 것** — type 별 수. * * 오류가 아니다. 절대값 snapshot 은 polling 순간이 맞는 시각이라 여기 세어지는 것이 정상이다. * 다만 그 수가 어디에도 안 남으면 `event_time == created_at` 이 유일한 단서가 되고, 그것은 * 우연히 같은 것과 구별되지 않는다. */ undated: undatedByKind([...epcis, ...operational]) } } /** * 유입 결과 — 사건(봉투)과 **사건이 아닌 것**을 갈라 낸다. * * 마스터데이터를 `accepted` 에 섞지 않는 이유: 부르는 쪽이 그것을 저널에 적게 되고, 시각축이 없는 값이 * 「그때 일어난 일」로 남는다. 갈래를 타입으로 갈라 두면 그 실수를 할 수 없다. */ export interface CanonicalIngestResult extends IngestResult { masterData: MasterDataRecord[] /** * 시각을 못 읽어 ingest 시각으로 떨어진 record 를 **type 별로** 센다. * * 비어 있으면 전부 자기 시각을 말한 것이다. 값이 있어도 오류는 아니다 — 절대값 snapshot 은 * polling 순간이 맞는 시각이다. 다만 그 수가 남아야 「5일 틀린 값이 저장됐다」를 나중에 찾을 수 * 있다(2026-09-06 에 그것을 backfill 로 찾았다). */ undated: Record }