@operato/twin-kernel 0.7.85 → 0.7.87

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.
@@ -679,7 +679,9 @@ export class EmsKernel extends FlowEngine {
679
679
  boundary,
680
680
  subject: subj.subject,
681
681
  subjectBasis: subj.basis
682
- }, periodFactId(this.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, periodStart, periodEnd));
682
+ }, periodFactId(this.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, periodStart, periodEnd),
683
+ /* 이 사실이 성립한 것은 기간의 끝이다 — 우리가 계산한 시각이 아니다. */
684
+ periodEnd);
683
685
  eq.generatedKWhLastPeriod = total;
684
686
  eq.generatedKWhLastPeriodEnd = new Date(endMs).toISOString();
685
687
  /* 상태만 읽는 화면이 덜 잰 값을 온전한 값으로 읽지 않게, 관측 구간을 함께 남긴다. */
@@ -35,6 +35,11 @@ export interface EnergyIngestOptions {
35
35
  * 그때도 `twin-local` 이되 범위는 비어 있다.
36
36
  */
37
37
  scopeId?: string;
38
+ /**
39
+ * **지금** — 아직 오지 않은 시각의 사실을 받지 않기 위해 쓴다. 주지 않으면 `defaultEventTime` 을 쓰고,
40
+ * 그것도 없으면 판단하지 않는다(없는 기준으로 거절하지 않는다).
41
+ */
42
+ nowISO?: string;
38
43
  }
39
44
  export interface EnergyIngestResult {
40
45
  accepted: CanonicalEnvelope[];
@@ -200,14 +205,6 @@ export interface EnergyBillRecord {
200
205
  billingDemandKW?: number;
201
206
  }
202
207
  export declare function isEnergyBillRecord(record: unknown): boolean;
203
- /**
204
- * 이 사실의 **주체를 무엇으로 부를까** — 선언이 있으면 그 이름, 없으면 이름표와 그 범위.
205
- *
206
- * 선언이 없을 때 트윈 범위를 이름에 넣는 이유는 **겹침을 숨기지 않기 위해서**다. 넣지 않으면 서로 다른
207
- * 설비의 같은 날이 한 사실이 되고, 여러 현장을 함께 계산하는 곳에서 한쪽이 조용히 사라진다(실측).
208
- * 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
209
- * 척하지 않는다.
210
- */
211
208
  export declare function resolveSubject(kind: 'equipment' | 'meter', localId: string, opts: EnergyIngestOptions): {
212
209
  subject: string;
213
210
  basis: 'declared' | 'twin-local';
@@ -452,6 +452,11 @@ export function ingestEnergyGenerationPeriodRecords(records, opts) {
452
452
  const subj = resolveSubject('equipment', equipmentId, opts);
453
453
  data.subject = subj.subject;
454
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
+ }
455
460
  accepted.push({
456
461
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
457
462
  eventType: ENERGY_EVENT.generationPeriod,
@@ -498,6 +503,11 @@ export function ingestEnergyGenerationPriceRecords(records, opts) {
498
503
  continue;
499
504
  }
500
505
  const data = { from: span.from, to: span.to, unitPrice, currency };
506
+ const futureError = futureFactError(span.from, opts);
507
+ if (futureError) {
508
+ rejected.push({ record: r, errors: [futureError] });
509
+ continue;
510
+ }
501
511
  accepted.push({
502
512
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPrice, '', span.from, span.to),
503
513
  eventType: ENERGY_EVENT.generationPrice,
@@ -563,6 +573,11 @@ export function ingestEnergyTariffBasisRecords(records, opts) {
563
573
  ...(demandChargePerKW !== undefined ? { demandChargePerKW } : {}),
564
574
  ...(currency ? { currency } : {})
565
575
  };
576
+ const futureError = futureFactError(span.from, opts);
577
+ if (futureError) {
578
+ rejected.push({ record: r, errors: [futureError] });
579
+ continue;
580
+ }
566
581
  accepted.push({
567
582
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.tariffBasis, '', span.from, span.to),
568
583
  eventType: ENERGY_EVENT.tariffBasis,
@@ -607,6 +622,30 @@ export function isEnergyBillRecord(record) {
607
622
  * 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
608
623
  * 척하지 않는다.
609
624
  */
625
+ /**
626
+ * **아직 오지 않은 시각의 사실은 받지 않는다.**
627
+ *
628
+ * ── 왜 필요한가 (2026-08-30) ──────────────────────────────────────────────
629
+ * 한 원본이 **끝나지 않은 이번 달**의 요금 정보를 청구서로 보냈다. 청구서의 사건 시각은 기간의 끝이라
630
+ * 그 사실이 **내일** 일어난 것으로 저널에 적혔다. 성과 계산은 「가장 늦은 사건」으로 구간을 잡으므로
631
+ * 구간 전체가 미래로 밀렸고, 그 현장의 화면에서 어제 발전량이 사라졌다. 오류는 하나도 나지 않았다.
632
+ *
633
+ * 지난 기록 채우기는 이미 이 판단을 하고 있었다(§`judgeBackfill`). 라이브 문에만 없어서 **한 종류에
634
+ * 규칙이 두 벌**이었다. 같은 사실이 어느 문으로 들어오느냐에 따라 받아지기도 하고 거절되기도 했다.
635
+ *
636
+ * 기준 선언(요금 기준·발전 단가)은 기간이 미래로 뻗어도 된다 — 그쪽은 사건 시각이 **기간의 시작**이라
637
+ * 이 판정에 걸리지 않는다. 앞으로 이렇게 매기겠다는 선언은 오늘 성립한다.
638
+ */
639
+ function futureFactError(eventTime, opts) {
640
+ const nowText = opts.nowISO ?? opts.defaultEventTime;
641
+ if (!nowText)
642
+ return undefined;
643
+ const nowMs = Date.parse(nowText);
644
+ const atMs = Date.parse(eventTime);
645
+ if (!Number.isFinite(nowMs) || !Number.isFinite(atMs) || atMs <= nowMs)
646
+ return undefined;
647
+ return `아직 오지 않은 시각의 사실이다(${eventTime}) — 끝나지 않은 기간은 마감된 사실이 아니다`;
648
+ }
610
649
  export function resolveSubject(kind, localId, opts) {
611
650
  const declared = opts.identityOf?.(kind, localId);
612
651
  if (declared)
@@ -699,6 +738,11 @@ export function ingestEnergyUsagePeriodRecords(records, opts) {
699
738
  ...(currency ? { currency } : {}),
700
739
  ...(basis ? { basis } : {})
701
740
  };
741
+ const futureError = futureFactError(span.to, opts);
742
+ if (futureError) {
743
+ rejected.push({ record: r, errors: [futureError] });
744
+ continue;
745
+ }
702
746
  accepted.push({
703
747
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
704
748
  eventType: ENERGY_EVENT.usagePeriod,
@@ -745,6 +789,11 @@ export function ingestEnergyBillRecords(records, opts) {
745
789
  ...(currency ? { currency } : {}),
746
790
  ...(billingDemandKW !== undefined ? { billingDemandKW } : {})
747
791
  };
792
+ const futureError = futureFactError(span.to, opts);
793
+ if (futureError) {
794
+ rejected.push({ record: r, errors: [futureError] });
795
+ continue;
796
+ }
748
797
  accepted.push({
749
798
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.bill, '', span.from, span.to),
750
799
  eventType: ENERGY_EVENT.bill,
@@ -1291,8 +1291,15 @@ export declare abstract class FlowEngine implements TwinKernel {
1291
1291
  * 커넥터가 지난 기록으로도 보내면, 순번 이름으로는 그 둘이 다른 사실이 되어 **발전량이 두 배로**
1292
1292
  * 잡힌다. 유입 문은 이미 내용으로 이름을 짓고 있었는데(§`periodFactId`) 커널 자신만 아니었다 —
1293
1293
  * 한 종류에 규칙이 두 벌이었다.
1294
+ *
1295
+ * ── 시각도 내용에서 온다 ─────────────────────────────────────────────────
1296
+ * `emitOp` 은 **지금**을 찍는다. 마감된 구간에 그것을 찍으면 「우리가 언제 계산했나」가 되고, 재기동이
1297
+ * 늦으면 어제의 사실이 오늘 시각을 갖는다. 실제로 그렇게 났다 — 부팅에서 닫힌 8월 29일치가 22:24 를
1298
+ * 달고 나왔고, 시각 구간으로 읽는 중복 판정이 그것을 못 봐서 같은 하루가 12줄이 되었다(이름은 6개).
1299
+ *
1300
+ * 이 사실이 성립하는 것은 **기간의 끝**이다. 유입 문도 그 시각을 찍는다.
1294
1301
  */
1295
- protected emitPeriodFact(eventType: string, data: unknown, eventId: string): void;
1302
+ protected emitPeriodFact(eventType: string, data: unknown, eventId: string, eventTime: string): void;
1296
1303
  /**
1297
1304
  * 이 트윈의 **범위 이름** — 선언된 정체성이 없을 때 이름표가 어디까지 통하는지(§`SubjectBasis`).
1298
1305
  *
@@ -2798,10 +2798,17 @@ export class FlowEngine {
2798
2798
  * 커넥터가 지난 기록으로도 보내면, 순번 이름으로는 그 둘이 다른 사실이 되어 **발전량이 두 배로**
2799
2799
  * 잡힌다. 유입 문은 이미 내용으로 이름을 짓고 있었는데(§`periodFactId`) 커널 자신만 아니었다 —
2800
2800
  * 한 종류에 규칙이 두 벌이었다.
2801
+ *
2802
+ * ── 시각도 내용에서 온다 ─────────────────────────────────────────────────
2803
+ * `emitOp` 은 **지금**을 찍는다. 마감된 구간에 그것을 찍으면 「우리가 언제 계산했나」가 되고, 재기동이
2804
+ * 늦으면 어제의 사실이 오늘 시각을 갖는다. 실제로 그렇게 났다 — 부팅에서 닫힌 8월 29일치가 22:24 를
2805
+ * 달고 나왔고, 시각 구간으로 읽는 중복 판정이 그것을 못 봐서 같은 하루가 12줄이 되었다(이름은 6개).
2806
+ *
2807
+ * 이 사실이 성립하는 것은 **기간의 끝**이다. 유입 문도 그 시각을 찍는다.
2801
2808
  */
2802
- emitPeriodFact(eventType, data, eventId) {
2809
+ emitPeriodFact(eventType, data, eventId, eventTime) {
2803
2810
  this.revision++;
2804
- const e = { eventId, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
2811
+ const e = { eventId, eventType, eventTime, tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
2805
2812
  for (const h of this.handlers)
2806
2813
  h(e);
2807
2814
  }
@@ -2824,6 +2824,11 @@ function ingestEnergyGenerationPeriodRecords(records, opts) {
2824
2824
  const subj = resolveSubject("equipment", equipmentId, opts);
2825
2825
  data.subject = subj.subject;
2826
2826
  data.subjectBasis = subj.basis;
2827
+ const futureError = futureFactError(span.to, opts);
2828
+ if (futureError) {
2829
+ rejected.push({ record: r, errors: [futureError] });
2830
+ continue;
2831
+ }
2827
2832
  accepted.push({
2828
2833
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
2829
2834
  eventType: ENERGY_EVENT.generationPeriod,
@@ -2857,6 +2862,11 @@ function ingestEnergyGenerationPriceRecords(records, opts) {
2857
2862
  continue;
2858
2863
  }
2859
2864
  const data = { from: span.from, to: span.to, unitPrice, currency };
2865
+ const futureError = futureFactError(span.from, opts);
2866
+ if (futureError) {
2867
+ rejected.push({ record: r, errors: [futureError] });
2868
+ continue;
2869
+ }
2860
2870
  accepted.push({
2861
2871
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPrice, "", span.from, span.to),
2862
2872
  eventType: ENERGY_EVENT.generationPrice,
@@ -2902,6 +2912,11 @@ function ingestEnergyTariffBasisRecords(records, opts) {
2902
2912
  ...demandChargePerKW !== void 0 ? { demandChargePerKW } : {},
2903
2913
  ...currency ? { currency } : {}
2904
2914
  };
2915
+ const futureError = futureFactError(span.from, opts);
2916
+ if (futureError) {
2917
+ rejected.push({ record: r, errors: [futureError] });
2918
+ continue;
2919
+ }
2905
2920
  accepted.push({
2906
2921
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.tariffBasis, "", span.from, span.to),
2907
2922
  eventType: ENERGY_EVENT.tariffBasis,
@@ -2919,6 +2934,14 @@ function isEnergyBillRecord(record) {
2919
2934
  if (r.from === void 0 || r.to === void 0) return false;
2920
2935
  return r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0;
2921
2936
  }
2937
+ function futureFactError(eventTime, opts) {
2938
+ const nowText = opts.nowISO ?? opts.defaultEventTime;
2939
+ if (!nowText) return void 0;
2940
+ const nowMs = Date.parse(nowText);
2941
+ const atMs = Date.parse(eventTime);
2942
+ if (!Number.isFinite(nowMs) || !Number.isFinite(atMs) || atMs <= nowMs) return void 0;
2943
+ return `\uC544\uC9C1 \uC624\uC9C0 \uC54A\uC740 \uC2DC\uAC01\uC758 \uC0AC\uC2E4\uC774\uB2E4(${eventTime}) \u2014 \uB05D\uB098\uC9C0 \uC54A\uC740 \uAE30\uAC04\uC740 \uB9C8\uAC10\uB41C \uC0AC\uC2E4\uC774 \uC544\uB2C8\uB2E4`;
2944
+ }
2922
2945
  function resolveSubject(kind, localId, opts) {
2923
2946
  const declared = opts.identityOf?.(kind, localId);
2924
2947
  if (declared) return { subject: declared, basis: "declared" };
@@ -2997,6 +3020,11 @@ function ingestEnergyUsagePeriodRecords(records, opts) {
2997
3020
  ...currency ? { currency } : {},
2998
3021
  ...basis ? { basis } : {}
2999
3022
  };
3023
+ const futureError = futureFactError(span.to, opts);
3024
+ if (futureError) {
3025
+ rejected.push({ record: r, errors: [futureError] });
3026
+ continue;
3027
+ }
3000
3028
  accepted.push({
3001
3029
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
3002
3030
  eventType: ENERGY_EVENT.usagePeriod,
@@ -3037,6 +3065,11 @@ function ingestEnergyBillRecords(records, opts) {
3037
3065
  ...currency ? { currency } : {},
3038
3066
  ...billingDemandKW !== void 0 ? { billingDemandKW } : {}
3039
3067
  };
3068
+ const futureError = futureFactError(span.to, opts);
3069
+ if (futureError) {
3070
+ rejected.push({ record: r, errors: [futureError] });
3071
+ continue;
3072
+ }
3040
3073
  accepted.push({
3041
3074
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.bill, "", span.from, span.to),
3042
3075
  eventType: ENERGY_EVENT.bill,
@@ -5645,10 +5678,17 @@ var FlowEngine = class {
5645
5678
  * 커넥터가 지난 기록으로도 보내면, 순번 이름으로는 그 둘이 다른 사실이 되어 **발전량이 두 배로**
5646
5679
  * 잡힌다. 유입 문은 이미 내용으로 이름을 짓고 있었는데(§`periodFactId`) 커널 자신만 아니었다 —
5647
5680
  * 한 종류에 규칙이 두 벌이었다.
5681
+ *
5682
+ * ── 시각도 내용에서 온다 ─────────────────────────────────────────────────
5683
+ * `emitOp` 은 **지금**을 찍는다. 마감된 구간에 그것을 찍으면 「우리가 언제 계산했나」가 되고, 재기동이
5684
+ * 늦으면 어제의 사실이 오늘 시각을 갖는다. 실제로 그렇게 났다 — 부팅에서 닫힌 8월 29일치가 22:24 를
5685
+ * 달고 나왔고, 시각 구간으로 읽는 중복 판정이 그것을 못 봐서 같은 하루가 12줄이 되었다(이름은 6개).
5686
+ *
5687
+ * 이 사실이 성립하는 것은 **기간의 끝**이다. 유입 문도 그 시각을 찍는다.
5648
5688
  */
5649
- emitPeriodFact(eventType, data, eventId) {
5689
+ emitPeriodFact(eventType, data, eventId, eventTime) {
5650
5690
  this.revision++;
5651
- const e = { eventId, eventType, eventTime: this.now(), tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data };
5691
+ const e = { eventId, eventType, eventTime, tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data };
5652
5692
  for (const h of this.handlers) h(e);
5653
5693
  }
5654
5694
  /**
@@ -8840,7 +8880,9 @@ var EmsKernel = class extends FlowEngine {
8840
8880
  subject: subj.subject,
8841
8881
  subjectBasis: subj.basis
8842
8882
  },
8843
- periodFactId(this.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, periodStart, periodEnd)
8883
+ periodFactId(this.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, periodStart, periodEnd),
8884
+ /* 이 사실이 성립한 것은 기간의 끝이다 — 우리가 계산한 시각이 아니다. */
8885
+ periodEnd
8844
8886
  );
8845
8887
  eq.generatedKWhLastPeriod = total;
8846
8888
  eq.generatedKWhLastPeriodEnd = new Date(endMs).toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.85",
3
+ "version": "0.7.87",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {