@operato/ops-contract 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/capability.d.ts +142 -0
- package/dist/capability.js +127 -0
- package/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +3557 -0
- package/dist/contract.js +1248 -0
- package/dist/domain-catalog.d.ts +280 -0
- package/dist/domain-catalog.js +322 -0
- package/dist/domain-definition.d.ts +356 -0
- package/dist/domain-definition.js +137 -0
- package/dist/ems-profile.d.ts +147 -0
- package/dist/ems-profile.js +367 -0
- package/dist/energy-ingest.d.ts +214 -0
- package/dist/energy-ingest.js +801 -0
- package/dist/epcis.d.ts +458 -0
- package/dist/epcis.js +640 -0
- package/dist/face2-adapter.d.ts +191 -0
- package/dist/face2-adapter.js +284 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +39 -0
- package/dist/iso-duration.d.ts +5 -0
- package/dist/iso-duration.js +43 -0
- package/dist/master-data.d.ts +46 -0
- package/dist/master-data.js +100 -0
- package/dist/mes-profile.d.ts +14 -0
- package/dist/mes-profile.js +59 -0
- package/dist/operational-ingest.d.ts +44 -0
- package/dist/operational-ingest.js +379 -0
- package/dist/operations-capability.d.ts +117 -0
- package/dist/operations-capability.js +120 -0
- package/dist/scenario-validate.d.ts +15 -0
- package/dist/scenario-validate.js +72 -0
- package/dist/vocabulary.d.ts +28 -0
- package/dist/vocabulary.js +81 -0
- package/dist/wms-profile.d.ts +20 -0
- package/dist/wms-profile.js +58 -0
- package/dist/yms-profile.d.ts +15 -0
- package/dist/yms-profile.js +39 -0
- package/dist-cjs/index.cjs +3767 -0
- package/package.json +30 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 에너지 계측 인제스트 — **표본 레코드 → 봉투.** 설계: `design/profiles/ems.md` §4.1
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 따로 있나 (2026-08-14) ───────────────────────────────────────────────
|
|
5
|
+
* 라이브 인제스트 경로는 **EPCIS 하나만 알았다.** 호스트의 정규 레코드 룰이 모든 레코드를
|
|
6
|
+
* `type: 'ObjectEvent'` 로 만들었기 때문이다(`canonical-ingest.ts`). 그 길로 계측을 넣으면 둘 중
|
|
7
|
+
* 하나가 된다: `epc` 가 없어 검증에서 거부되거나, 엉뚱하게 **물품 관측**으로 읽힌다.
|
|
8
|
+
*
|
|
9
|
+
* 에너지는 물(物)의 계보가 아니므로 EPCIS 어휘를 쓰지 않는다(§4). 그래서 정규 레코드의 **종류를
|
|
10
|
+
* 하나 더 인정**한다 — 봉투는 같은 것을 쓰고(저널·리플레이·시간여행을 그대로 얻는다) 어휘만 자기 것이다.
|
|
11
|
+
*
|
|
12
|
+
* ── 검증은 커널의 일이다 ────────────────────────────────────────────────────
|
|
13
|
+
* 매핑(원 시스템 스키마 → 정규 레코드)은 커넥터의 몫이고, **무엇이 유효한 사실인가**는 커널이 정한다
|
|
14
|
+
* (`face2-adapters.md` §7 의 규율: 매핑=밖, 검증=커널). 그래서 이 판정이 여기 있다.
|
|
15
|
+
*
|
|
16
|
+
* ── 무엇을 거부하나 ─────────────────────────────────────────────────────────
|
|
17
|
+
* 지어낼 수 없는 것이 빠지면 거부한다 — 계량 지점(`meterId`)과 시각(`at`)이다. 지금 시각으로 메우면
|
|
18
|
+
* 남의 구간에 실리고, 지점을 지어내면 어디의 소비인지 모르는 값이 누적된다. **거부한 것은 이유와 함께
|
|
19
|
+
* 돌려준다**(조용히 버리지 않는다 — 소비처가 그 수를 세어 사람에게 말할 수 있어야 한다).
|
|
20
|
+
*
|
|
21
|
+
* 값(`kW`)이 없는 표본은 **거부하지 않는다**: 계량기가 살아 있다는 사실 자체가 관측이고, 커널이
|
|
22
|
+
* 「받았지만 부하를 못 읽었다」를 구별해 낸다(`observedAbsence: 'no-load-samples'`).
|
|
23
|
+
*/
|
|
24
|
+
import { ENERGY_EVENT, OBSERVATION_BASIS } from "./contract.js";
|
|
25
|
+
/** 이 레코드가 에너지 표본인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게). */
|
|
26
|
+
export function isEnergyRecord(record) {
|
|
27
|
+
if (!record || typeof record !== 'object')
|
|
28
|
+
return false;
|
|
29
|
+
const r = record;
|
|
30
|
+
/* 계량 지점이 있고 EPCIS 어휘가 없으면 에너지다. `epc` 가 함께 있으면 둘 중 무엇인지 알 수 없으므로
|
|
31
|
+
에너지로 받지 않는다 — 그 판단은 커넥터가 명확히 해야 한다. */
|
|
32
|
+
if (!(typeof r.meterId === 'string' && r.meterId.trim().length > 0 && r.epc === undefined))
|
|
33
|
+
return false;
|
|
34
|
+
/*
|
|
35
|
+
* ── **마감된 구간은 계량 표본이 아니다** (2026-08-29) ──────────────────────
|
|
36
|
+
*
|
|
37
|
+
* 둘 다 `meterId` 를 가지므로 이 판별이 겹쳤다. 겹친 채로 두면 갈림의 **순서**가 값을 정한다 —
|
|
38
|
+
* 순서를 잘못 두면 시간별 사용량이 계량 문으로 가고, 그 문은 `kWh` 를 적산 레지스터로 읽는다.
|
|
39
|
+
* 우리가 넣는 값은 이미 그 구간의 양이므로 **차분의 차분**이 된다.
|
|
40
|
+
*
|
|
41
|
+
* 가장 나쁜 모양이다: **거부되지 않고 값만 틀린다.** 그래서 순서에 기대지 않고 배타로 만든다
|
|
42
|
+
* (설비 상태와 발전 적산을 가를 때와 같은 규율).
|
|
43
|
+
*/
|
|
44
|
+
return r.from === undefined && r.to === undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 표본 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 이유와 함께 남는다.
|
|
48
|
+
*
|
|
49
|
+
* `eventTime` 은 **계측이 말한 시각**(`at`)이 먼저다. 봉투와 페이로드에 같은 값을 싣는다: 커널은
|
|
50
|
+
* 페이로드를 먼저 보고, 저널·시간여행은 봉투를 본다 — 둘이 다르면 같은 사실이 두 시각을 갖는다.
|
|
51
|
+
*/
|
|
52
|
+
export function ingestEnergyRecords(records, opts) {
|
|
53
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
54
|
+
const accepted = [];
|
|
55
|
+
const rejected = [];
|
|
56
|
+
let seq = 0;
|
|
57
|
+
for (const record of arr) {
|
|
58
|
+
const errors = [];
|
|
59
|
+
const meterId = String(record?.meterId ?? '').trim();
|
|
60
|
+
if (!meterId)
|
|
61
|
+
errors.push('meterId 없음 — 어디의 소비인지 모르는 값은 누적할 수 없다');
|
|
62
|
+
const at = String(record?.at ?? '').trim() || opts.defaultEventTime;
|
|
63
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
64
|
+
if (!Number.isFinite(atMs))
|
|
65
|
+
errors.push('at 없음/형식 오류 — 지금 시각으로 메우면 남의 수요 구간에 실린다');
|
|
66
|
+
/* 값이 있으면 수여야 한다 — 문자열·NaN 을 그대로 흘리면 커널이 합에서 조용히 빠뜨린다. */
|
|
67
|
+
const num = (v, name) => {
|
|
68
|
+
if (v === undefined || v === null || v === '')
|
|
69
|
+
return undefined;
|
|
70
|
+
const n = Number(v);
|
|
71
|
+
if (!Number.isFinite(n)) {
|
|
72
|
+
errors.push(`${name} 가 수가 아니다: ${JSON.stringify(v)}`);
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return n;
|
|
76
|
+
};
|
|
77
|
+
const kW = num(record?.kW, 'kW');
|
|
78
|
+
const kWh = num(record?.kWh, 'kWh');
|
|
79
|
+
const powerFactor = num(record?.powerFactor, 'powerFactor');
|
|
80
|
+
if (errors.length) {
|
|
81
|
+
rejected.push({ record, errors });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const eventTime = new Date(atMs).toISOString();
|
|
85
|
+
const data = {
|
|
86
|
+
meterId,
|
|
87
|
+
/* 계약은 `kW` 를 필수로 두지만 **못 읽은 표본도 사실**이다 — 그 경우 값을 비우고 보낸다.
|
|
88
|
+
커널이 「받았지만 부하를 못 읽었다」로 세고, 구간 마감에 그 이유를 싣는다. */
|
|
89
|
+
...(kW !== undefined ? { kW } : {}),
|
|
90
|
+
...(kWh !== undefined ? { kWh } : {}),
|
|
91
|
+
...(powerFactor !== undefined ? { powerFactor } : {}),
|
|
92
|
+
at: eventTime
|
|
93
|
+
};
|
|
94
|
+
accepted.push({
|
|
95
|
+
eventId: `${opts.tenantId}-energy-${++seq}`,
|
|
96
|
+
eventType: ENERGY_EVENT.measured,
|
|
97
|
+
eventTime,
|
|
98
|
+
tenantId: opts.tenantId,
|
|
99
|
+
data
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { accepted, rejected };
|
|
103
|
+
}
|
|
104
|
+
/** 개폐 위치의 값 — 표준이 정한 넷. 그 밖의 낱말은 받지 않는다(뭉개면 없는 확신이 생긴다). */
|
|
105
|
+
const POSITIONS = new Set(['open', 'closed', 'intermediate', 'bad']);
|
|
106
|
+
/** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
|
|
107
|
+
export function isEnergyEquipmentRecord(record) {
|
|
108
|
+
if (!record || typeof record !== 'object')
|
|
109
|
+
return false;
|
|
110
|
+
const r = record;
|
|
111
|
+
/*
|
|
112
|
+
* ── **발전 적산을 밀어낸다** (2026-08-28 · 인티그레이션 레인이 걸어 보고 찾았다) ─
|
|
113
|
+
*
|
|
114
|
+
* 이 판정이 `equipmentId` 만 보아서 **발전 적산 레코드에도 참이었다.** 그래서 호스트가 설비 상태
|
|
115
|
+
* 필터를 먼저 돌리면 적산이 이 문으로 들어오고, 이 문은 「바꿀 상태가 하나도 없다」고 거부한다 —
|
|
116
|
+
* **엉뚱한 이유로 거부되고 커넥터는 보냈다고 믿는다.**
|
|
117
|
+
*
|
|
118
|
+
* 호스트가 순서로 풀 수 있지만 순서는 다음 사람이 줄을 옮기면 깨진다. 다른 어휘를 밀어내는 규율은
|
|
119
|
+
* 발전 쪽이 이미 갖고 있었다(`meterId`·`epc` 를 본다) — 이쪽만 없었다. **판정끼리 배타로 만든다.**
|
|
120
|
+
*/
|
|
121
|
+
return (typeof r.equipmentId === 'string' &&
|
|
122
|
+
r.equipmentId.trim().length > 0 &&
|
|
123
|
+
r.epc === undefined &&
|
|
124
|
+
r.meterId === undefined &&
|
|
125
|
+
/* 적산을 실었으면 발전이다 — 이 문의 값은 상태이고, 적산은 자기 문이 있다. */
|
|
126
|
+
r.kWh === undefined);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
|
|
130
|
+
* 이유와 함께 알린다.
|
|
131
|
+
*
|
|
132
|
+
* **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
|
|
133
|
+
* 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
|
|
134
|
+
*/
|
|
135
|
+
export function ingestEnergyEquipmentRecords(records, opts) {
|
|
136
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
137
|
+
const accepted = [];
|
|
138
|
+
const rejected = [];
|
|
139
|
+
let seq = 0;
|
|
140
|
+
for (const record of arr) {
|
|
141
|
+
const errors = [];
|
|
142
|
+
const r = record;
|
|
143
|
+
const equipmentId = String(r?.equipmentId ?? '').trim();
|
|
144
|
+
if (!equipmentId)
|
|
145
|
+
errors.push('equipmentId 없음 — 어느 설비의 상태인지 모르는 값은 실을 수 없다');
|
|
146
|
+
const at = String(r?.at ?? '').trim() || opts.defaultEventTime;
|
|
147
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
148
|
+
if (!Number.isFinite(atMs))
|
|
149
|
+
errors.push('at 없음/형식 오류 — 지금 시각으로 메우면 언제의 상태인지 알 수 없다');
|
|
150
|
+
const num = (v, name, min, max) => {
|
|
151
|
+
if (v === undefined || v === null || v === '')
|
|
152
|
+
return undefined;
|
|
153
|
+
const n = Number(v);
|
|
154
|
+
if (!Number.isFinite(n)) {
|
|
155
|
+
errors.push(`${name} 가 수가 아니다: ${JSON.stringify(v)}`);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (min !== undefined && n < min) {
|
|
159
|
+
errors.push(`${name} 가 ${min} 보다 작다: ${n}`);
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
if (max !== undefined && n > max) {
|
|
163
|
+
errors.push(`${name} 가 ${max} 보다 크다: ${n}`);
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
};
|
|
168
|
+
const generatedKW = num(r?.generatedKW, 'generatedKW', 0);
|
|
169
|
+
const exportKW = num(r?.exportKW, 'exportKW');
|
|
170
|
+
/* 충전율은 백분율이다 — 범위를 벗어난 값은 받지 않는다(단위를 잘못 매핑한 커넥터를 조용히 통과시키면
|
|
171
|
+
「배터리가 380% 찼다」가 화면에 뜬다). */
|
|
172
|
+
const soc = num(r?.soc, 'soc', 0, 100);
|
|
173
|
+
const chargeKW = num(r?.chargeKW, 'chargeKW', 0);
|
|
174
|
+
const dischargeKW = num(r?.dischargeKW, 'dischargeKW', 0);
|
|
175
|
+
const minKW = num(r?.minKW, 'minKW', 0);
|
|
176
|
+
let curtailable;
|
|
177
|
+
if (r?.curtailable !== undefined && r?.curtailable !== null) {
|
|
178
|
+
if (typeof r.curtailable !== 'boolean')
|
|
179
|
+
errors.push(`curtailable 가 참/거짓이 아니다: ${JSON.stringify(r.curtailable)}`);
|
|
180
|
+
else
|
|
181
|
+
curtailable = r.curtailable;
|
|
182
|
+
}
|
|
183
|
+
/*
|
|
184
|
+
* ── 전기 계측 (2026-08-29) ─────────────────────────────────────────────────
|
|
185
|
+
*
|
|
186
|
+
* 음수는 받지 않는다. 전압·전류의 크기는 음수가 되지 않고, 방향은 다른 축이 말한다
|
|
187
|
+
* (`chargeKW`·`dischargeKW`·`exportKW`). 음수를 받아 두면 화면이 「-380V」를 그린다.
|
|
188
|
+
*
|
|
189
|
+
* 상별 값은 **배열 그대로** 받는다. 합치거나 평균 내지 않는다 — 상 불평형은 그 자체로 사실이고,
|
|
190
|
+
* 한 수로 접으면 되돌릴 수 없다.
|
|
191
|
+
*/
|
|
192
|
+
const dcVoltage = num(r?.dcVoltage, 'dcVoltage', 0);
|
|
193
|
+
const dcCurrent = num(r?.dcCurrent, 'dcCurrent', 0);
|
|
194
|
+
const phases = (raw, name) => {
|
|
195
|
+
if (raw === undefined || raw === null)
|
|
196
|
+
return undefined;
|
|
197
|
+
if (!Array.isArray(raw)) {
|
|
198
|
+
errors.push(`${name} 가 배열이 아니다 — 상별 값이라 배열로 받는다(단상이면 원소 하나): ${JSON.stringify(raw)}`);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (!raw.length) {
|
|
202
|
+
errors.push(`${name} 가 빈 배열이다 — 잰 상이 없으면 그 칸을 보내지 않는다`);
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const [i, v] of raw.entries()) {
|
|
207
|
+
const n = Number(v);
|
|
208
|
+
if (!Number.isFinite(n)) {
|
|
209
|
+
errors.push(`${name}[${i}] 를 수로 읽을 수 없다: ${JSON.stringify(v)}`);
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
if (n < 0) {
|
|
213
|
+
errors.push(`${name}[${i}] 가 음수다(${n}) — 크기는 음수가 되지 않는다(방향은 다른 축이 말한다)`);
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
out.push(n);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
const acVoltage = phases(r?.acVoltage, 'acVoltage');
|
|
221
|
+
const acCurrent = phases(r?.acCurrent, 'acCurrent');
|
|
222
|
+
/* 상 수가 다르면 짝이 맞지 않는다 — 어느 상의 전류인지 알 수 없는 값을 받지 않는다. */
|
|
223
|
+
if (acVoltage && acCurrent && acVoltage.length !== acCurrent.length) {
|
|
224
|
+
errors.push(`acVoltage(${acVoltage.length}상)와 acCurrent(${acCurrent.length}상)의 상 수가 다르다`);
|
|
225
|
+
}
|
|
226
|
+
let position;
|
|
227
|
+
if (r?.position !== undefined && r?.position !== null && r?.position !== '') {
|
|
228
|
+
const p = String(r.position);
|
|
229
|
+
if (!POSITIONS.has(p))
|
|
230
|
+
errors.push(`position 이 표준 값이 아니다(open·closed·intermediate·bad): ${JSON.stringify(r.position)}`);
|
|
231
|
+
else
|
|
232
|
+
position = p;
|
|
233
|
+
}
|
|
234
|
+
const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position, dcVoltage, dcCurrent, acVoltage, acCurrent };
|
|
235
|
+
if (!errors.length && Object.values(values).every(v => v === undefined)) {
|
|
236
|
+
errors.push('바꿀 상태가 하나도 없다 — 값 없는 보고는 사실로 적을 것이 없다');
|
|
237
|
+
}
|
|
238
|
+
if (errors.length) {
|
|
239
|
+
rejected.push({ record, errors });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const eventTime = new Date(atMs).toISOString();
|
|
243
|
+
const data = {
|
|
244
|
+
equipmentId,
|
|
245
|
+
at: eventTime,
|
|
246
|
+
...(generatedKW !== undefined ? { generatedKW } : {}),
|
|
247
|
+
...(exportKW !== undefined ? { exportKW } : {}),
|
|
248
|
+
...(soc !== undefined ? { soc } : {}),
|
|
249
|
+
...(chargeKW !== undefined ? { chargeKW } : {}),
|
|
250
|
+
...(dischargeKW !== undefined ? { dischargeKW } : {}),
|
|
251
|
+
...(curtailable !== undefined ? { curtailable } : {}),
|
|
252
|
+
...(minKW !== undefined ? { minKW } : {}),
|
|
253
|
+
...(position !== undefined ? { position } : {}),
|
|
254
|
+
...(dcVoltage !== undefined ? { dcVoltage } : {}),
|
|
255
|
+
...(dcCurrent !== undefined ? { dcCurrent } : {}),
|
|
256
|
+
...(acVoltage !== undefined ? { acVoltage } : {}),
|
|
257
|
+
...(acCurrent !== undefined ? { acCurrent } : {})
|
|
258
|
+
};
|
|
259
|
+
accepted.push({
|
|
260
|
+
eventId: `${opts.tenantId}-energy-eq-${++seq}`,
|
|
261
|
+
eventType: ENERGY_EVENT.equipment,
|
|
262
|
+
eventTime,
|
|
263
|
+
tenantId: opts.tenantId,
|
|
264
|
+
data
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return { accepted, rejected };
|
|
268
|
+
}
|
|
269
|
+
/** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
|
|
270
|
+
export function isEnergyGenerationRecord(record) {
|
|
271
|
+
/* 구간이 있으면 마감된 발전 기간이지 시점의 적산이 아니다 — 겹치면 지난 기록이 「지금」으로 읽힌다. */
|
|
272
|
+
if (record?.from !== undefined || record?.to !== undefined)
|
|
273
|
+
return false;
|
|
274
|
+
if (!record || typeof record !== 'object')
|
|
275
|
+
return false;
|
|
276
|
+
const r = record;
|
|
277
|
+
/* 설비를 말하고 적산을 실었으면 발전이다. 계량 어휘가 함께 있으면 어느 쪽인지 알 수 없어 받지 않는다. */
|
|
278
|
+
return (typeof r.equipmentId === 'string' &&
|
|
279
|
+
r.equipmentId.trim().length > 0 &&
|
|
280
|
+
r.kWh !== undefined &&
|
|
281
|
+
r.meterId === undefined &&
|
|
282
|
+
r.epc === undefined);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 발전 적산 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
|
|
286
|
+
* 이유와 함께 알린다.
|
|
287
|
+
*
|
|
288
|
+
* **적산이 없는 레코드는 받지 않는다** — 이 문의 값은 그것 하나다. 순시 출력은 설비 상태 문이 받는다
|
|
289
|
+
* (`EnergyEquipmentRecord.generatedKW`).
|
|
290
|
+
*/
|
|
291
|
+
export function ingestEnergyGenerationRecords(records, opts) {
|
|
292
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
293
|
+
const accepted = [];
|
|
294
|
+
const rejected = [];
|
|
295
|
+
let seq = 0;
|
|
296
|
+
for (const r of list) {
|
|
297
|
+
const errors = [];
|
|
298
|
+
const equipmentId = String(r?.equipmentId ?? '').trim();
|
|
299
|
+
if (!equipmentId)
|
|
300
|
+
errors.push('equipmentId 가 없다 — 어느 설비가 만든 것인지 지어낼 수 없다');
|
|
301
|
+
/*
|
|
302
|
+
* ── **한 레코드가 두 사실을 실어 오면 거부한다** (2026-08-28) ────────────────
|
|
303
|
+
*
|
|
304
|
+
* 판정을 배타로 만들면서 생긴 자리다. 적산을 실었으면 이 문으로 오는데, 그 레코드에 상태 값
|
|
305
|
+
* (`generatedKW`·`soc`·`position` …)이 함께 있으면 **그 사실이 조용히 사라진다** — 이 문은 상태를
|
|
306
|
+
* 읽지 않기 때문이다.
|
|
307
|
+
*
|
|
308
|
+
* 잃는 것보다 거부하는 편이 낫다. 커넥터가 갈라 보내면 둘 다 산다.
|
|
309
|
+
*/
|
|
310
|
+
const STATE_FIELDS = ['generatedKW', 'exportKW', 'soc', 'chargeKW', 'dischargeKW', 'curtailable', 'minKW', 'position'];
|
|
311
|
+
const alsoState = STATE_FIELDS.filter(f => r?.[f] !== undefined);
|
|
312
|
+
if (alsoState.length) {
|
|
313
|
+
errors.push(`적산과 설비 상태를 한 레코드에 실었다(${alsoState.join('·')}) — 이 문은 적산만 읽으므로 그 값이 사라진다. 갈라 보낼 것`);
|
|
314
|
+
}
|
|
315
|
+
const raw = r?.kWh;
|
|
316
|
+
const kWh = Number(raw);
|
|
317
|
+
if (raw === undefined || raw === null || !Number.isFinite(kWh)) {
|
|
318
|
+
errors.push('kWh 가 없다 — 이 문이 받는 값은 적산 하나다(순시 출력은 설비 상태로 보낸다)');
|
|
319
|
+
}
|
|
320
|
+
else if (kWh < 0) {
|
|
321
|
+
/* 음수 적산은 계기가 낼 수 있는 값이 아니다. 받아 두면 그 뒤의 차분이 전부 거짓이 된다. */
|
|
322
|
+
errors.push(`kWh 가 음수다(${kWh}) — 적산은 뒤로 가더라도 음수가 되지 않는다`);
|
|
323
|
+
}
|
|
324
|
+
const eventTime = String(r?.at ?? '').trim() || opts.defaultEventTime || '';
|
|
325
|
+
if (!eventTime)
|
|
326
|
+
errors.push('at 이 없고 기본 시각도 주지 않았다 — 언제 잰 것인지 지어낼 수 없다');
|
|
327
|
+
/*
|
|
328
|
+
* 기준점은 **말했을 때만** 받는다. 형식이 틀리면 거부한다 — 못 읽는 기준점을 통과시키면 커널이
|
|
329
|
+
* 그것을 「기준점이 없다」로 읽고 다시 추론으로 떨어지는데, 커넥터는 알렸다고 믿는다.
|
|
330
|
+
*/
|
|
331
|
+
let since;
|
|
332
|
+
const rawSince = r?.kWhSince;
|
|
333
|
+
if (rawSince !== undefined && rawSince !== null && String(rawSince).trim()) {
|
|
334
|
+
const text = String(rawSince).trim();
|
|
335
|
+
const at = Date.parse(text);
|
|
336
|
+
if (!Number.isFinite(at))
|
|
337
|
+
errors.push(`kWhSince 를 시각으로 읽을 수 없다: ${JSON.stringify(rawSince)}`);
|
|
338
|
+
else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
|
|
339
|
+
/* 미래에서 쌓이기 시작한 적산은 없다 — 두 값 중 하나가 틀렸고, 어느 쪽인지 우리가 고를 수 없다. */
|
|
340
|
+
errors.push(`kWhSince(${text}) 가 잰 시각(${eventTime})보다 뒤다 — 둘 중 하나가 틀렸다`);
|
|
341
|
+
}
|
|
342
|
+
else
|
|
343
|
+
since = text;
|
|
344
|
+
}
|
|
345
|
+
/*
|
|
346
|
+
* 누적의 종류 — 아는 목록 밖의 값은 거부한다. 뭉개서 받으면 「모른다」와 「틀리게 말했다」가 같아진다.
|
|
347
|
+
*/
|
|
348
|
+
const ACCUMULATIONS = ['lifetime', 'daily', 'monthly', 'billing', 'unknown'];
|
|
349
|
+
let accumulation;
|
|
350
|
+
const rawAcc = r?.kWhAccumulation;
|
|
351
|
+
if (rawAcc !== undefined && rawAcc !== null && String(rawAcc).trim()) {
|
|
352
|
+
const text = String(rawAcc).trim();
|
|
353
|
+
if (!ACCUMULATIONS.includes(text)) {
|
|
354
|
+
errors.push(`kWhAccumulation 이 아는 값이 아니다(${ACCUMULATIONS.join('·')}): ${JSON.stringify(rawAcc)}`);
|
|
355
|
+
}
|
|
356
|
+
else
|
|
357
|
+
accumulation = text;
|
|
358
|
+
}
|
|
359
|
+
if (errors.length) {
|
|
360
|
+
rejected.push({ record: r, errors });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const data = {
|
|
364
|
+
equipmentId,
|
|
365
|
+
kWh,
|
|
366
|
+
at: eventTime,
|
|
367
|
+
...(since ? { since } : {}),
|
|
368
|
+
...(accumulation ? { accumulation } : {})
|
|
369
|
+
};
|
|
370
|
+
accepted.push({
|
|
371
|
+
eventId: `${opts.tenantId}-generated-${++seq}`,
|
|
372
|
+
eventType: ENERGY_EVENT.generated,
|
|
373
|
+
eventTime,
|
|
374
|
+
tenantId: opts.tenantId,
|
|
375
|
+
data
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return { accepted, rejected };
|
|
379
|
+
}
|
|
380
|
+
export function isEnergyUsagePeriodRecord(record) {
|
|
381
|
+
const r = record;
|
|
382
|
+
return !!r && typeof r === 'object' && r.meterId !== undefined && r.from !== undefined && r.to !== undefined && r.kWh !== undefined;
|
|
383
|
+
}
|
|
384
|
+
export function isEnergyGenerationPeriodRecord(record) {
|
|
385
|
+
const r = record;
|
|
386
|
+
if (!r || typeof r !== 'object')
|
|
387
|
+
return false;
|
|
388
|
+
if (r.equipmentId === undefined || r.from === undefined || r.to === undefined)
|
|
389
|
+
return false;
|
|
390
|
+
return r.kWh !== undefined;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* 마감된 발전 기간을 봉투로 — **사건 시각은 기간의 끝**이다(그때 성립한다).
|
|
394
|
+
*
|
|
395
|
+
* 적산의 문(`ingestEnergyGenerationRecords`)과 겹치지 않는다: 그쪽은 구간이 없는 시점의 값이고
|
|
396
|
+
* 이쪽은 구간이 있는 마감된 사실이다. 겹치면 지난 기록이 「지금 적산」으로 읽혀 트윈의 시계가
|
|
397
|
+
* 과거로 끌린다.
|
|
398
|
+
*/
|
|
399
|
+
export function ingestEnergyGenerationPeriodRecords(records, opts) {
|
|
400
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
401
|
+
const accepted = [];
|
|
402
|
+
const rejected = [];
|
|
403
|
+
for (const r of list) {
|
|
404
|
+
const errors = [];
|
|
405
|
+
const equipmentId = String(r?.equipmentId ?? '').trim();
|
|
406
|
+
if (!equipmentId)
|
|
407
|
+
errors.push('equipmentId 가 없다 — 어느 설비가 낸 것인지 지어낼 수 없다');
|
|
408
|
+
const span = readSpan(r, errors);
|
|
409
|
+
const kWh = readAmount(r?.kWh, 'kWh', errors);
|
|
410
|
+
if (kWh === undefined && !errors.length)
|
|
411
|
+
errors.push('kWh 가 없다 — 이 문이 받는 값은 그 기간의 발전량이다');
|
|
412
|
+
const ACCUMULATIONS = ['lifetime', 'daily', 'monthly', 'billing', 'unknown'];
|
|
413
|
+
let accumulation;
|
|
414
|
+
const rawAcc = r?.accumulation;
|
|
415
|
+
if (rawAcc !== undefined && rawAcc !== null && String(rawAcc).trim()) {
|
|
416
|
+
const text = String(rawAcc).trim();
|
|
417
|
+
if (!ACCUMULATIONS.includes(text)) {
|
|
418
|
+
errors.push(`accumulation 이 아는 값이 아니다(${ACCUMULATIONS.join('·')}): ${JSON.stringify(rawAcc)}`);
|
|
419
|
+
}
|
|
420
|
+
else
|
|
421
|
+
accumulation = text;
|
|
422
|
+
}
|
|
423
|
+
/* 관측 구간은 기간 안에 있어야 한다 — 밖이면 그 값이 이 기간의 것이 아니다. */
|
|
424
|
+
const readAt = (raw, name) => {
|
|
425
|
+
if (raw === undefined || raw === null || !String(raw).trim())
|
|
426
|
+
return undefined;
|
|
427
|
+
const text = String(raw).trim();
|
|
428
|
+
if (!Number.isFinite(Date.parse(text))) {
|
|
429
|
+
errors.push(`${name} 를 시각으로 읽을 수 없다: ${JSON.stringify(raw)}`);
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
return text;
|
|
433
|
+
};
|
|
434
|
+
const observedFrom = readAt(r?.observedFrom, 'observedFrom');
|
|
435
|
+
const observedTo = readAt(r?.observedTo, 'observedTo');
|
|
436
|
+
if (errors.length || !span || kWh === undefined) {
|
|
437
|
+
rejected.push({ record: r, errors });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const data = {
|
|
441
|
+
equipmentId,
|
|
442
|
+
kWh,
|
|
443
|
+
periodStart: span.from,
|
|
444
|
+
periodEnd: span.to,
|
|
445
|
+
/* 관측 구간을 말하지 않으면 기간 전체를 잰 것으로 둔다 — 지난 기록은 그 원본이 하루를 마감해 준 값이다. */
|
|
446
|
+
observedFrom: observedFrom ?? span.from,
|
|
447
|
+
observedTo: observedTo ?? span.to,
|
|
448
|
+
accumulation: (accumulation ?? 'daily'),
|
|
449
|
+
/* 밖에서 마감되어 온 것이다 — 우리가 시각 기준으로 닫은 것이 아니다. */
|
|
450
|
+
boundary: 'declared'
|
|
451
|
+
};
|
|
452
|
+
const subj = resolveSubject('equipment', equipmentId, opts);
|
|
453
|
+
data.subject = subj.subject;
|
|
454
|
+
data.subjectBasis = subj.basis;
|
|
455
|
+
const futureError = futureFactError(span.to, opts);
|
|
456
|
+
if (futureError) {
|
|
457
|
+
rejected.push({ record: r, errors: [futureError] });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
accepted.push({
|
|
461
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
|
|
462
|
+
eventType: ENERGY_EVENT.generationPeriod,
|
|
463
|
+
eventTime: span.to,
|
|
464
|
+
tenantId: opts.tenantId,
|
|
465
|
+
data
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
return { accepted, rejected };
|
|
469
|
+
}
|
|
470
|
+
export function isEnergyGenerationPriceRecord(record) {
|
|
471
|
+
const r = record;
|
|
472
|
+
if (!r || typeof r !== 'object')
|
|
473
|
+
return false;
|
|
474
|
+
if (r.from === undefined || r.to === undefined)
|
|
475
|
+
return false;
|
|
476
|
+
/* 계량 지점·설비가 있으면 그 설비의 사실이지 단가가 아니다 — 문이 겹치지 않게. */
|
|
477
|
+
if (r.meterId !== undefined || r.equipmentId !== undefined)
|
|
478
|
+
return false;
|
|
479
|
+
return r.unitPrice !== undefined;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* 발전 단가를 봉투로 — **사건 시각은 `from`** 이다(그때부터 적용된다).
|
|
483
|
+
*
|
|
484
|
+
* 제도의 가산·인증서 셈은 커넥터가 하고 여기에는 1kWh 당 얼마만 온다. 커널이 한 제도의 모양을
|
|
485
|
+
* 안으면 다른 제도에서 그 자리가 거짓이 된다.
|
|
486
|
+
*/
|
|
487
|
+
export function ingestEnergyGenerationPriceRecords(records, opts) {
|
|
488
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
489
|
+
const accepted = [];
|
|
490
|
+
const rejected = [];
|
|
491
|
+
for (const r of list) {
|
|
492
|
+
const errors = [];
|
|
493
|
+
const span = readSpan(r, errors);
|
|
494
|
+
const unitPrice = readAmount(r?.unitPrice, 'unitPrice', errors);
|
|
495
|
+
if (unitPrice === undefined && !errors.length)
|
|
496
|
+
errors.push('unitPrice 가 없다 — 이 문이 받는 값은 1kWh 당 값 하나다');
|
|
497
|
+
/* 단위 없는 값은 다른 금액과 더할 수 없다. */
|
|
498
|
+
const currency = String(r?.currency ?? '').trim();
|
|
499
|
+
if (!currency)
|
|
500
|
+
errors.push('currency 가 없다 — 단위 없는 값은 다른 금액과 더할 수 없다');
|
|
501
|
+
if (errors.length || !span || unitPrice === undefined) {
|
|
502
|
+
rejected.push({ record: r, errors });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const data = { from: span.from, to: span.to, unitPrice, currency };
|
|
506
|
+
accepted.push({
|
|
507
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPrice, '', span.from, span.to),
|
|
508
|
+
eventType: ENERGY_EVENT.generationPrice,
|
|
509
|
+
eventTime: span.from,
|
|
510
|
+
tenantId: opts.tenantId,
|
|
511
|
+
data
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return { accepted, rejected };
|
|
515
|
+
}
|
|
516
|
+
export function isEnergyTariffBasisRecord(record) {
|
|
517
|
+
const r = record;
|
|
518
|
+
if (!r || typeof r !== 'object')
|
|
519
|
+
return false;
|
|
520
|
+
if (r.from === undefined || r.to === undefined)
|
|
521
|
+
return false;
|
|
522
|
+
/*
|
|
523
|
+
* ── 청구서와 배타로 (2026-08-29) ───────────────────────────────────────────
|
|
524
|
+
*
|
|
525
|
+
* 청구서가 요금적용전력을 함께 실으면 이 판별도 참이 됐다. 그때 이 문으로 가면 「금액은 받지
|
|
526
|
+
* 않는다」로 거부되어 **정산이 통째로 사라진다** — 거부는 되지만 그 사실을 잃는 것은 같다.
|
|
527
|
+
*
|
|
528
|
+
* 금액이 하나라도 있으면 그것은 정산이다. 기준은 금액을 싣지 않는다.
|
|
529
|
+
*/
|
|
530
|
+
if (r.energyCharge !== undefined || r.demandCharge !== undefined || r.total !== undefined)
|
|
531
|
+
return false;
|
|
532
|
+
return r.billingDemandKW !== undefined || r.demandChargePerKW !== undefined;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* 요금 기준을 봉투로 — **사건 시각은 `from`** 이다.
|
|
536
|
+
*
|
|
537
|
+
* 「이 시각부터 이 기준이 적용된다」이므로 주기가 끝나기 전에 성립한다. `to` 로 잡으면 아직 오지 않은
|
|
538
|
+
* 시각이 저널에 적힌다(실제로 그렇게 해서 9월 1일 사건이 8월 29일에 적힌 적이 있다).
|
|
539
|
+
*/
|
|
540
|
+
export function ingestEnergyTariffBasisRecords(records, opts) {
|
|
541
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
542
|
+
const accepted = [];
|
|
543
|
+
const rejected = [];
|
|
544
|
+
for (const r of list) {
|
|
545
|
+
const errors = [];
|
|
546
|
+
const span = readSpan(r, errors);
|
|
547
|
+
const billingDemandKW = readAmount(r?.billingDemandKW, 'billingDemandKW', errors);
|
|
548
|
+
const demandChargePerKW = readAmount(r?.demandChargePerKW, 'demandChargePerKW', errors);
|
|
549
|
+
if (billingDemandKW === undefined && demandChargePerKW === undefined) {
|
|
550
|
+
errors.push('요금 기준이 하나도 없다 — 적을 사실이 없는 기준은 받지 않는다');
|
|
551
|
+
}
|
|
552
|
+
/* 단가를 실었으면 통화가 있어야 한다 — 단위 없는 금액은 다른 금액과 더할 수 없다. */
|
|
553
|
+
const currency = String(r?.currency ?? '').trim() || undefined;
|
|
554
|
+
if (demandChargePerKW !== undefined && !currency)
|
|
555
|
+
errors.push('demandChargePerKW 를 실었는데 currency 가 없다');
|
|
556
|
+
/* 금액은 받지 않는다 — 두 값의 곱은 커널이 한다. 곱을 받으면 그것이 정산인 척한다. */
|
|
557
|
+
if (r?.demandCharge !== undefined || r?.total !== undefined) {
|
|
558
|
+
errors.push('금액(demandCharge·total)은 이 문이 받지 않는다 — 정산 금액은 청구서 문으로 보낸다');
|
|
559
|
+
}
|
|
560
|
+
if (errors.length || !span) {
|
|
561
|
+
rejected.push({ record: r, errors });
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const data = {
|
|
565
|
+
from: span.from,
|
|
566
|
+
to: span.to,
|
|
567
|
+
...(billingDemandKW !== undefined ? { billingDemandKW } : {}),
|
|
568
|
+
...(demandChargePerKW !== undefined ? { demandChargePerKW } : {}),
|
|
569
|
+
...(currency ? { currency } : {})
|
|
570
|
+
};
|
|
571
|
+
accepted.push({
|
|
572
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.tariffBasis, '', span.from, span.to),
|
|
573
|
+
eventType: ENERGY_EVENT.tariffBasis,
|
|
574
|
+
/* **시작 시각**이다 — 그때부터 유효하다(§ 위 주석). */
|
|
575
|
+
eventTime: span.from,
|
|
576
|
+
tenantId: opts.tenantId,
|
|
577
|
+
data
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return { accepted, rejected };
|
|
581
|
+
}
|
|
582
|
+
export function isEnergyBillRecord(record) {
|
|
583
|
+
const r = record;
|
|
584
|
+
if (!r || typeof r !== 'object')
|
|
585
|
+
return false;
|
|
586
|
+
if (r.from === undefined || r.to === undefined)
|
|
587
|
+
return false;
|
|
588
|
+
return r.energyCharge !== undefined || r.demandCharge !== undefined || r.total !== undefined;
|
|
589
|
+
}
|
|
590
|
+
/*
|
|
591
|
+
* ── 마감된 구간 사실의 **정체성** (2026-08-30) ───────────────────────────────
|
|
592
|
+
*
|
|
593
|
+
* 커널이 다루는 사실에는 두 종류가 있다.
|
|
594
|
+
*
|
|
595
|
+
* 시점의 관측 「이 시각에 이 값이었다」 같은 값이 두 번 오면 **두 사실**이다
|
|
596
|
+
* 마감된 구간 「이 기간에 이만큼이었다」 같은 기간이 두 번 오면 **한 사실**이다
|
|
597
|
+
*
|
|
598
|
+
* 둘째 종류는 정체성이 내용에 있다 — **종류·대상·시작·끝**이 같으면 같은 사실이다. 어느 커넥터가
|
|
599
|
+
* 어떻게 보내든, 재기동을 몇 번 하든 그렇다.
|
|
600
|
+
*
|
|
601
|
+
* 그래서 이 문들의 봉투 id 를 **순번이 아니라 내용에서** 만든다. 순번으로 만들면 같은 기간을 다시
|
|
602
|
+
* 보낼 때마다 다른 id 가 나오고, 읽는 쪽이 같은 사실인지 알 방법이 없다.
|
|
603
|
+
*
|
|
604
|
+
* **이것만으로 중복이 막히지는 않는다.** 저널은 아직 이 id 를 유일성으로 쓰지 않는다(그 판단은
|
|
605
|
+
* 호스트의 것이고 드라이버마다 다르다). 여기서 하는 일은 **막을 근거를 만드는 것**이다.
|
|
606
|
+
*/
|
|
607
|
+
/**
|
|
608
|
+
* 이 사실의 **주체를 무엇으로 부를까** — 선언이 있으면 그 이름, 없으면 이름표와 그 범위.
|
|
609
|
+
*
|
|
610
|
+
* 선언이 없을 때 트윈 범위를 이름에 넣는 이유는 **겹침을 숨기지 않기 위해서**다. 넣지 않으면 서로 다른
|
|
611
|
+
* 설비의 같은 날이 한 사실이 되고, 여러 현장을 함께 계산하는 곳에서 한쪽이 조용히 사라진다(실측).
|
|
612
|
+
* 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
|
|
613
|
+
* 척하지 않는다.
|
|
614
|
+
*/
|
|
615
|
+
/**
|
|
616
|
+
* **아직 오지 않은 시각의 사실은 받지 않는다.**
|
|
617
|
+
*
|
|
618
|
+
* ── 왜 필요한가 (2026-08-30) ──────────────────────────────────────────────
|
|
619
|
+
* 한 원본이 **끝나지 않은 이번 달**의 요금 정보를 청구서로 보냈다. 청구서의 사건 시각은 기간의 끝이라
|
|
620
|
+
* 그 사실이 **내일** 일어난 것으로 저널에 적혔다. 성과 계산은 「가장 늦은 사건」으로 구간을 잡으므로
|
|
621
|
+
* 구간 전체가 미래로 밀렸고, 그 현장의 화면에서 어제 발전량이 사라졌다. 오류는 하나도 나지 않았다.
|
|
622
|
+
*
|
|
623
|
+
* 지난 기록 채우기는 이미 이 판단을 하고 있었다(§`judgeBackfill`). 라이브 문에만 없어서 **한 종류에
|
|
624
|
+
* 규칙이 두 벌**이었다. 같은 사실이 어느 문으로 들어오느냐에 따라 받아지기도 하고 거절되기도 했다.
|
|
625
|
+
*
|
|
626
|
+
* ── 기준 선언은 이 판정을 받지 않는다 (2026-08-30, 두 번째 판단) ────────────
|
|
627
|
+
* 요금 기준·발전 단가는 **예정될 수 있다.** 한전은 다음 달 요금을 미리 공표하고, 공정 조건도 시행일을
|
|
628
|
+
* 앞두고 정해진다. 「9월부터 이 단가」는 오늘 성립하는 사실이다.
|
|
629
|
+
*
|
|
630
|
+
* 처음에는 이 둘도 거부했다. 그러면 정상적인 사실이 막힌다. 받되, **예정된 선언이 지금 것을 덮지 않게**
|
|
631
|
+
* 커널이 유효 구간으로 고른다(§`EnergyState.tariffBasisSchedule`). 받아 놓고 아무 데나 쓰면
|
|
632
|
+
* 오늘 요금이 다음 달 단가로 계산된다 — 그것이 이 문을 열기 전에 먼저 만들어야 했던 자리다.
|
|
633
|
+
*/
|
|
634
|
+
function futureFactError(eventTime, opts) {
|
|
635
|
+
const nowText = opts.nowISO ?? opts.defaultEventTime;
|
|
636
|
+
if (!nowText)
|
|
637
|
+
return undefined;
|
|
638
|
+
const nowMs = Date.parse(nowText);
|
|
639
|
+
const atMs = Date.parse(eventTime);
|
|
640
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(atMs) || atMs <= nowMs)
|
|
641
|
+
return undefined;
|
|
642
|
+
return `아직 오지 않은 시각의 사실이다(${eventTime}) — 끝나지 않은 기간은 마감된 사실이 아니다`;
|
|
643
|
+
}
|
|
644
|
+
export function resolveSubject(kind, localId, opts) {
|
|
645
|
+
const declared = opts.identityOf?.(kind, localId);
|
|
646
|
+
if (declared)
|
|
647
|
+
return { subject: declared, basis: 'declared' };
|
|
648
|
+
return { subject: opts.scopeId ? `${opts.scopeId}/${localId}` : localId, basis: 'twin-local' };
|
|
649
|
+
}
|
|
650
|
+
export function periodFactId(tenantId, kind, subject, from, to) {
|
|
651
|
+
/* 대상이 없는 사실(요금 기준·청구서·발전 단가)은 그 자리를 비운다 — 없는 것을 지어내지 않는다. */
|
|
652
|
+
return [tenantId, kind, subject, from, to].join('|');
|
|
653
|
+
}
|
|
654
|
+
/** 두 시각을 읽고 순서를 본다 — 뒤집힌 구간은 받지 않는다(합이 음수가 된다). */
|
|
655
|
+
function readSpan(r, errors) {
|
|
656
|
+
const from = String(r?.from ?? '').trim();
|
|
657
|
+
const to = String(r?.to ?? '').trim();
|
|
658
|
+
const fromMs = Date.parse(from);
|
|
659
|
+
const toMs = Date.parse(to);
|
|
660
|
+
if (!from || !Number.isFinite(fromMs))
|
|
661
|
+
errors.push(`from 을 시각으로 읽을 수 없다: ${JSON.stringify(r?.from)}`);
|
|
662
|
+
if (!to || !Number.isFinite(toMs))
|
|
663
|
+
errors.push(`to 를 시각으로 읽을 수 없다: ${JSON.stringify(r?.to)}`);
|
|
664
|
+
if (Number.isFinite(fromMs) && Number.isFinite(toMs) && !(toMs > fromMs)) {
|
|
665
|
+
errors.push(`구간이 뒤집혔거나 길이가 없다(${from} → ${to}) — 어느 쪽이 틀렸는지 우리가 정할 수 없다`);
|
|
666
|
+
}
|
|
667
|
+
return errors.length ? undefined : { from, to };
|
|
668
|
+
}
|
|
669
|
+
/** 0 이상의 수 하나 — 없으면 `undefined`, 값인데 읽을 수 없으면 오류. */
|
|
670
|
+
function readAmount(raw, name, errors) {
|
|
671
|
+
if (raw === undefined || raw === null || String(raw).trim() === '')
|
|
672
|
+
return undefined;
|
|
673
|
+
const v = Number(raw);
|
|
674
|
+
if (!Number.isFinite(v)) {
|
|
675
|
+
errors.push(`${name} 를 수로 읽을 수 없다: ${JSON.stringify(raw)}`);
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
if (v < 0) {
|
|
679
|
+
errors.push(`${name} 가 음수다(${v})`);
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
return v;
|
|
683
|
+
}
|
|
684
|
+
export function ingestEnergyUsagePeriodRecords(records, opts) {
|
|
685
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
686
|
+
const accepted = [];
|
|
687
|
+
const rejected = [];
|
|
688
|
+
for (const r of list) {
|
|
689
|
+
const errors = [];
|
|
690
|
+
const meterId = String(r?.meterId ?? '').trim();
|
|
691
|
+
if (!meterId)
|
|
692
|
+
errors.push('meterId 가 없다 — 어느 지점이 쓴 것인지 지어낼 수 없다');
|
|
693
|
+
const span = readSpan(r, errors);
|
|
694
|
+
const rawKWh = r?.kWh;
|
|
695
|
+
let kWh;
|
|
696
|
+
if (rawKWh === undefined || rawKWh === null || String(rawKWh).trim() === '') {
|
|
697
|
+
errors.push('kWh 가 없다 — 이 문이 받는 값은 그 구간에 쓴 양이다');
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
kWh = readAmount(rawKWh, 'kWh', errors);
|
|
701
|
+
}
|
|
702
|
+
const maxKW = readAmount(r?.maxKW, 'maxKW', errors);
|
|
703
|
+
const unitPrice = readAmount(r?.unitPrice, 'unitPrice', errors);
|
|
704
|
+
/* 단가를 실었으면 통화도 있어야 한다 — 단위 없는 금액은 다른 값과 더할 수 없다. */
|
|
705
|
+
const currency = String(r?.currency ?? '').trim() || undefined;
|
|
706
|
+
if (unitPrice !== undefined && !currency)
|
|
707
|
+
errors.push('unitPrice 를 실었는데 currency 가 없다 — 단위 없는 금액은 더할 수 없다');
|
|
708
|
+
/* 근거는 아는 목록 안에서만 — 모르는 값을 받으면 화면이 그것을 근거로 말한다. */
|
|
709
|
+
let basis;
|
|
710
|
+
const rawBasis = r?.basis;
|
|
711
|
+
if (rawBasis !== undefined && rawBasis !== null && String(rawBasis).trim()) {
|
|
712
|
+
const text = String(rawBasis).trim();
|
|
713
|
+
if (!OBSERVATION_BASIS.includes(text)) {
|
|
714
|
+
errors.push(`basis 가 아는 값이 아니다(${OBSERVATION_BASIS.join('·')}): ${JSON.stringify(rawBasis)}`);
|
|
715
|
+
}
|
|
716
|
+
else
|
|
717
|
+
basis = text;
|
|
718
|
+
}
|
|
719
|
+
if (errors.length || !span || kWh === undefined) {
|
|
720
|
+
rejected.push({ record: r, errors });
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const subj = resolveSubject('meter', meterId, opts);
|
|
724
|
+
const data = {
|
|
725
|
+
meterId,
|
|
726
|
+
subject: subj.subject,
|
|
727
|
+
subjectBasis: subj.basis,
|
|
728
|
+
from: span.from,
|
|
729
|
+
to: span.to,
|
|
730
|
+
kWh,
|
|
731
|
+
...(maxKW !== undefined ? { maxKW } : {}),
|
|
732
|
+
...(unitPrice !== undefined ? { unitPrice } : {}),
|
|
733
|
+
...(currency ? { currency } : {}),
|
|
734
|
+
...(basis ? { basis } : {})
|
|
735
|
+
};
|
|
736
|
+
const futureError = futureFactError(span.to, opts);
|
|
737
|
+
if (futureError) {
|
|
738
|
+
rejected.push({ record: r, errors: [futureError] });
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
accepted.push({
|
|
742
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
|
|
743
|
+
eventType: ENERGY_EVENT.usagePeriod,
|
|
744
|
+
/* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
|
|
745
|
+
eventTime: span.to,
|
|
746
|
+
tenantId: opts.tenantId,
|
|
747
|
+
data
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
return { accepted, rejected };
|
|
751
|
+
}
|
|
752
|
+
export function ingestEnergyBillRecords(records, opts) {
|
|
753
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
754
|
+
const accepted = [];
|
|
755
|
+
const rejected = [];
|
|
756
|
+
for (const r of list) {
|
|
757
|
+
const errors = [];
|
|
758
|
+
const span = readSpan(r, errors);
|
|
759
|
+
const energyCharge = readAmount(r?.energyCharge, 'energyCharge', errors);
|
|
760
|
+
const demandCharge = readAmount(r?.demandCharge, 'demandCharge', errors);
|
|
761
|
+
const total = readAmount(r?.total, 'total', errors);
|
|
762
|
+
const billingDemandKW = readAmount(r?.billingDemandKW, 'billingDemandKW', errors);
|
|
763
|
+
if (energyCharge === undefined && demandCharge === undefined && total === undefined) {
|
|
764
|
+
errors.push('금액이 하나도 없다 — 적을 사실이 없는 청구서는 받지 않는다');
|
|
765
|
+
}
|
|
766
|
+
/* 금액을 실었으면 통화가 있어야 한다. 없으면 그 수는 다른 수와 더할 수 없다. */
|
|
767
|
+
const currency = String(r?.currency ?? '').trim() || undefined;
|
|
768
|
+
if (!currency)
|
|
769
|
+
errors.push('currency 가 없다 — 단위 없는 금액은 다른 금액과 더할 수 없다');
|
|
770
|
+
/*
|
|
771
|
+
* 합계가 부분들과 다른 것은 **거부하지 않는다.** 세금·역률 요금·최소 청구액처럼 우리가 세지 않는
|
|
772
|
+
* 항목이 청구서에 있을 수 있다. 그 차이는 사실이고, 읽는 쪽이 그것을 보고 판단한다.
|
|
773
|
+
*/
|
|
774
|
+
if (errors.length || !span) {
|
|
775
|
+
rejected.push({ record: r, errors });
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const data = {
|
|
779
|
+
from: span.from,
|
|
780
|
+
to: span.to,
|
|
781
|
+
...(energyCharge !== undefined ? { energyCharge } : {}),
|
|
782
|
+
...(demandCharge !== undefined ? { demandCharge } : {}),
|
|
783
|
+
...(total !== undefined ? { total } : {}),
|
|
784
|
+
...(currency ? { currency } : {}),
|
|
785
|
+
...(billingDemandKW !== undefined ? { billingDemandKW } : {})
|
|
786
|
+
};
|
|
787
|
+
const futureError = futureFactError(span.to, opts);
|
|
788
|
+
if (futureError) {
|
|
789
|
+
rejected.push({ record: r, errors: [futureError] });
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
accepted.push({
|
|
793
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.bill, '', span.from, span.to),
|
|
794
|
+
eventType: ENERGY_EVENT.bill,
|
|
795
|
+
eventTime: span.to,
|
|
796
|
+
tenantId: opts.tenantId,
|
|
797
|
+
data
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return { accepted, rejected };
|
|
801
|
+
}
|