@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,3767 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// src/index.ts
|
|
20
|
+
var index_exports = {};
|
|
21
|
+
__export(index_exports, {
|
|
22
|
+
BIZSTEP: () => BIZSTEP,
|
|
23
|
+
BTT: () => BTT,
|
|
24
|
+
BTT_DELIVERY: () => BTT_DELIVERY,
|
|
25
|
+
BTT_PRODORDER: () => BTT_PRODORDER,
|
|
26
|
+
CAPABILITIES: () => CAPABILITIES,
|
|
27
|
+
CAPABILITY_KEYS: () => CAPABILITY_KEYS,
|
|
28
|
+
CBV_BIZSTEP: () => CBV_BIZSTEP,
|
|
29
|
+
CMD: () => CMD,
|
|
30
|
+
DISP: () => DISP,
|
|
31
|
+
DISPOSITION_DECISION: () => DISPOSITION_DECISION,
|
|
32
|
+
DOMAIN_CATALOG: () => DOMAIN_CATALOG,
|
|
33
|
+
DOMAIN_SYSTEMS: () => DOMAIN_SYSTEMS,
|
|
34
|
+
EMS_EQUIPMENT_TYPES: () => EMS_EQUIPMENT_TYPES,
|
|
35
|
+
EMS_LOCATION_TYPES: () => EMS_LOCATION_TYPES,
|
|
36
|
+
EMS_PROPERTY: () => EMS_PROPERTY,
|
|
37
|
+
EMS_PROPERTY_SPEC: () => EMS_PROPERTY_SPEC,
|
|
38
|
+
EMS_TYPES: () => EMS_TYPES,
|
|
39
|
+
ENERGY_EVENT: () => ENERGY_EVENT,
|
|
40
|
+
EPCIS_CONTEXT: () => EPCIS_CONTEXT,
|
|
41
|
+
EPCIS_EVENT: () => EPCIS_EVENT,
|
|
42
|
+
EQUIPMENT_LEVEL: () => EQUIPMENT_LEVEL,
|
|
43
|
+
GUARD_PRAGMA: () => GUARD_PRAGMA,
|
|
44
|
+
ILMD_ATTR: () => ILMD_ATTR,
|
|
45
|
+
LOCATION_SATURATION_NEAR: () => LOCATION_SATURATION_NEAR,
|
|
46
|
+
MATERIAL_PROPERTY: () => MATERIAL_PROPERTY,
|
|
47
|
+
MES_BIZSTEP: () => MES_BIZSTEP,
|
|
48
|
+
MES_LOCATION_TYPES: () => MES_LOCATION_TYPES,
|
|
49
|
+
MES_TYPES: () => MES_TYPES,
|
|
50
|
+
METER_DIRECTION: () => METER_DIRECTION,
|
|
51
|
+
OBSERVATION_BASIS: () => OBSERVATION_BASIS,
|
|
52
|
+
OP_EVENT: () => OP_EVENT,
|
|
53
|
+
OP_PARAM: () => OP_PARAM,
|
|
54
|
+
ORDER_TERMINAL_STATUS: () => ORDER_TERMINAL_STATUS,
|
|
55
|
+
PRIORITY_UNSET: () => PRIORITY_UNSET,
|
|
56
|
+
RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
|
|
57
|
+
TWIN_AXES: () => TWIN_AXES,
|
|
58
|
+
TWIN_PROPERTIES: () => TWIN_PROPERTIES,
|
|
59
|
+
TWIN_RELATIONS: () => TWIN_RELATIONS,
|
|
60
|
+
UTC_OFFSET: () => UTC_OFFSET,
|
|
61
|
+
VOCABULARY_EXCEPTIONS: () => VOCABULARY_EXCEPTIONS,
|
|
62
|
+
VOCABULARY_TYPE: () => VOCABULARY_TYPE,
|
|
63
|
+
WMS_LOCATION_TYPES: () => WMS_LOCATION_TYPES,
|
|
64
|
+
WMS_TYPES: () => WMS_TYPES,
|
|
65
|
+
YARD_BIZSTEP: () => YARD_BIZSTEP,
|
|
66
|
+
YMS_LOCATION_TYPES: () => YMS_LOCATION_TYPES,
|
|
67
|
+
YMS_TYPES: () => YMS_TYPES,
|
|
68
|
+
activeShiftAt: () => activeShiftAt,
|
|
69
|
+
activeShiftOf: () => activeShiftOf,
|
|
70
|
+
aggregationEvent: () => aggregationEvent,
|
|
71
|
+
analyzeCapacity: () => analyzeCapacity,
|
|
72
|
+
axesOfSystem: () => axesOfSystem,
|
|
73
|
+
axisAppliesTo: () => axisAppliesTo,
|
|
74
|
+
axisInfo: () => axisInfo,
|
|
75
|
+
axisSource: () => axisSource,
|
|
76
|
+
bizTransactionUri: () => bizTransactionUri,
|
|
77
|
+
capabilitiesForType: () => capabilitiesForType,
|
|
78
|
+
capabilityOf: () => capabilityOf,
|
|
79
|
+
classClosure: () => classClosure,
|
|
80
|
+
classIdentifierViolation: () => classIdentifierViolation,
|
|
81
|
+
commandsOf: () => commandsOf,
|
|
82
|
+
conversionFactorOf: () => conversionFactorOf,
|
|
83
|
+
criterionSaysNothing: () => criterionSaysNothing,
|
|
84
|
+
documentPath: () => documentPath,
|
|
85
|
+
dueStatusOf: () => dueStatusOf,
|
|
86
|
+
effectivityAt: () => effectivityAt,
|
|
87
|
+
electricalUpstreamOf: () => electricalUpstreamOf,
|
|
88
|
+
expiryFromAttributes: () => expiryFromAttributes,
|
|
89
|
+
gdtiUri: () => gdtiUri,
|
|
90
|
+
generationFractionAt: () => generationFractionAt,
|
|
91
|
+
graiUri: () => graiUri,
|
|
92
|
+
gs1KeyDigitViolation: () => gs1KeyDigitViolation,
|
|
93
|
+
hierarchyOf: () => hierarchyOf,
|
|
94
|
+
identityGroundingOf: () => identityGroundingOf,
|
|
95
|
+
inWorkCalendar: () => inWorkCalendar,
|
|
96
|
+
inWorkCalendarAt: () => inWorkCalendarAt,
|
|
97
|
+
ingest: () => ingest,
|
|
98
|
+
ingestEnergyBillRecords: () => ingestEnergyBillRecords,
|
|
99
|
+
ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
|
|
100
|
+
ingestEnergyGenerationPeriodRecords: () => ingestEnergyGenerationPeriodRecords,
|
|
101
|
+
ingestEnergyGenerationPriceRecords: () => ingestEnergyGenerationPriceRecords,
|
|
102
|
+
ingestEnergyGenerationRecords: () => ingestEnergyGenerationRecords,
|
|
103
|
+
ingestEnergyRecords: () => ingestEnergyRecords,
|
|
104
|
+
ingestEnergyTariffBasisRecords: () => ingestEnergyTariffBasisRecords,
|
|
105
|
+
ingestEnergyUsagePeriodRecords: () => ingestEnergyUsagePeriodRecords,
|
|
106
|
+
ingestMasterData: () => ingestMasterData,
|
|
107
|
+
ingestOperationalRecords: () => ingestOperationalRecords,
|
|
108
|
+
isAggregationRecord: () => isAggregationRecord,
|
|
109
|
+
isElectricalLocationType: () => isElectricalLocationType,
|
|
110
|
+
isEnergyBillRecord: () => isEnergyBillRecord,
|
|
111
|
+
isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
|
|
112
|
+
isEnergyGenerationPeriodRecord: () => isEnergyGenerationPeriodRecord,
|
|
113
|
+
isEnergyGenerationPriceRecord: () => isEnergyGenerationPriceRecord,
|
|
114
|
+
isEnergyGenerationRecord: () => isEnergyGenerationRecord,
|
|
115
|
+
isEnergyRecord: () => isEnergyRecord,
|
|
116
|
+
isEnergyTariffBasisRecord: () => isEnergyTariffBasisRecord,
|
|
117
|
+
isEnergyUsagePeriodRecord: () => isEnergyUsagePeriodRecord,
|
|
118
|
+
isEpcisEventType: () => isEpcisEventType,
|
|
119
|
+
isEquipmentLevel: () => isEquipmentLevel,
|
|
120
|
+
isMasterDataRecord: () => isMasterDataRecord,
|
|
121
|
+
isOperationalRecord: () => isOperationalRecord,
|
|
122
|
+
isOrderTerminal: () => isOrderTerminal,
|
|
123
|
+
isTransformationRecord: () => isTransformationRecord,
|
|
124
|
+
isoDurationHours: () => isoDurationHours,
|
|
125
|
+
itemKeyOf: () => itemKeyOf,
|
|
126
|
+
judgeAgainstSpec: () => judgeAgainstSpec,
|
|
127
|
+
levelOfLocationType: () => levelOfLocationType,
|
|
128
|
+
lgtinClass: () => lgtinClass,
|
|
129
|
+
localDayIndexAt: () => localDayIndexAt,
|
|
130
|
+
localDayStartMs: () => localDayStartMs,
|
|
131
|
+
locationStatusOf: () => locationStatusOf,
|
|
132
|
+
lotFromAttributes: () => lotFromAttributes,
|
|
133
|
+
mapRecord: () => mapRecord,
|
|
134
|
+
mapRecordChecked: () => mapRecordChecked,
|
|
135
|
+
meetsTests: () => meetsTests,
|
|
136
|
+
minuteOfDayAt: () => minuteOfDayAt,
|
|
137
|
+
objectEvent: () => objectEvent,
|
|
138
|
+
objectUri: () => objectUri,
|
|
139
|
+
observationAt: () => observationAt,
|
|
140
|
+
offCalendarAt: () => offCalendarAt,
|
|
141
|
+
offCalendarReasonAt: () => offCalendarReasonAt,
|
|
142
|
+
operationalKindOf: () => operationalKindOf,
|
|
143
|
+
operationsCapabilityOf: () => operationsCapabilityOf,
|
|
144
|
+
outsideLimit: () => outsideLimit,
|
|
145
|
+
parseEpc: () => parseEpc,
|
|
146
|
+
parseIsoDuration: () => parseIsoDuration,
|
|
147
|
+
periodFactId: () => periodFactId,
|
|
148
|
+
priorityRank: () => priorityRank,
|
|
149
|
+
procedureViolations: () => procedureViolations,
|
|
150
|
+
propertiesOf: () => propertiesOf,
|
|
151
|
+
quantityIn: () => quantityIn,
|
|
152
|
+
readBoardAssets: () => readBoardAssets,
|
|
153
|
+
readBoardEquipment: () => readBoardEquipment,
|
|
154
|
+
readBoardLocations: () => readBoardLocations,
|
|
155
|
+
relationsFrom: () => relationsFrom,
|
|
156
|
+
relationsTo: () => relationsTo,
|
|
157
|
+
requiredTestsFor: () => requiredTestsFor,
|
|
158
|
+
resolveSubject: () => resolveSubject,
|
|
159
|
+
retiredVocabularyIn: () => retiredVocabularyIn,
|
|
160
|
+
sgtinClass: () => sgtinClass,
|
|
161
|
+
sgtinUri: () => sgtinUri,
|
|
162
|
+
ssccUri: () => ssccUri,
|
|
163
|
+
stateFieldsOf: () => stateFieldsOf,
|
|
164
|
+
subLotIdOf: () => subLotIdOf,
|
|
165
|
+
testEvidenceGaps: () => testEvidenceGaps,
|
|
166
|
+
testPassedAt: () => testPassedAt,
|
|
167
|
+
transactionEvent: () => transactionEvent,
|
|
168
|
+
transformationEvent: () => transformationEvent,
|
|
169
|
+
validateDomainDefinition: () => validateDomainDefinition,
|
|
170
|
+
validateEpcisEvent: () => validateEpcisEvent,
|
|
171
|
+
validateScenario: () => validateScenario,
|
|
172
|
+
weekdayAt: () => weekdayAt,
|
|
173
|
+
workingHoursBetween: () => workingHoursBetween,
|
|
174
|
+
workingTimeOfWeek: () => workingTimeOfWeek
|
|
175
|
+
});
|
|
176
|
+
module.exports = __toCommonJS(index_exports);
|
|
177
|
+
|
|
178
|
+
// src/capability.ts
|
|
179
|
+
var CAPABILITIES = {
|
|
180
|
+
operable: {
|
|
181
|
+
key: "operable",
|
|
182
|
+
label: "twin.capability.operable",
|
|
183
|
+
semantics: "\uB2A5\uB3D9 \uC790\uC6D0\uC758 \uC6B4\uC601 \uC0C1\uD0DC(\uC720\uD734/\uAC00\uB3D9/\uACE0\uC7A5). status \uAD50\uCC28 \uAD00\uC2EC\uC0AC\uB97C \uC5EC\uAE30 \uD558\uB098\uB85C.",
|
|
184
|
+
commands: ["resource.hold", "resource.resume", "resource.down", "resource.repair", "resource.reset-metrics"],
|
|
185
|
+
stateFields: ["status"],
|
|
186
|
+
results: ["statusChanged"]
|
|
187
|
+
},
|
|
188
|
+
storable: {
|
|
189
|
+
key: "storable",
|
|
190
|
+
label: "twin.capability.storable",
|
|
191
|
+
semantics: "\uC544\uC774\uD15C\uC744 \uBCF4\uC720\uD558\uB294 \uC704\uCE58 \u2014 \uC810\uC720/\uC6A9\uB7C9. (\uC52C \uAE30\uC81C: Capacity)",
|
|
192
|
+
commands: [],
|
|
193
|
+
stateFields: ["occupancy", "capacity"],
|
|
194
|
+
invariants: ["0 <= occupancy <= capacity (capacity>0)"],
|
|
195
|
+
results: ["occupancyChanged"]
|
|
196
|
+
},
|
|
197
|
+
mobile: {
|
|
198
|
+
key: "mobile",
|
|
199
|
+
label: "twin.capability.mobile",
|
|
200
|
+
semantics: "\uC790\uC6D0 \uC790\uC2E0\uC774 \uC790\uB9AC \uAC04 \uC774\uB3D9. Transferable(\uC544\uC774\uD15C \uC774\uB3D9)\uACFC \uB2E4\uB984. (\uC52C \uAE30\uC81C: CarrierLine)",
|
|
201
|
+
commands: [],
|
|
202
|
+
stateFields: ["location", "motion"],
|
|
203
|
+
models: ["Motion"],
|
|
204
|
+
results: ["moved", "motionTick"]
|
|
205
|
+
},
|
|
206
|
+
processable: {
|
|
207
|
+
key: "processable",
|
|
208
|
+
label: "twin.capability.processable",
|
|
209
|
+
semantics: "\uBCC0\uD658/\uAC00\uACF5 \uC218\uD589 \u2014 \uC0B0\uCD9C(\uC591\uD488/\uBD88\uB7C9). \uC6B4\uC601 status \uB294 Operable \uC870\uD569. progress \uBC29\uCD9C\uC740 \uD6C4\uC18D.",
|
|
210
|
+
commands: [],
|
|
211
|
+
stateFields: ["output"],
|
|
212
|
+
results: ["completed"]
|
|
213
|
+
},
|
|
214
|
+
/*
|
|
215
|
+
* ── 「위치를 읽는다」 ≠ 「조작할 수 있다」 (2026-08-14) ─────────────────────
|
|
216
|
+
* 차단기는 이 둘이 갈리는 자리다. 상태를 읽어 계통 구성을 알지만 **우리는 조작하지 않는다**
|
|
217
|
+
* (안전 계통은 범위 밖: profiles/ems.md §1). 그런데 능력을 `operable` 로 주면 저작면이 그 설비에
|
|
218
|
+
* **제어 컴포넌트를 권한다** — 화면이 우리가 하지 않기로 한 것을 하라고 부추긴다.
|
|
219
|
+
*
|
|
220
|
+
* 그래서 개폐 기기의 관측을 따로 세운다. 이름을 `observable` 로 하지 않은 이유: 그러면 상태 필드가
|
|
221
|
+
* `status` 여야 하고 그건 `operable` 의 것이다(원칙 ② 직교). 없는 필드를 지어내는 대신 **표준이 주는
|
|
222
|
+
* 이름**을 쓴다 — 개폐 기기의 관측은 `Pos`(위치)다.
|
|
223
|
+
*
|
|
224
|
+
* `operable` 은 그대로 「명령을 받는 능동 자원」의 자리로 남는다.
|
|
225
|
+
*/
|
|
226
|
+
switching: {
|
|
227
|
+
key: "switching",
|
|
228
|
+
label: "twin.capability.switching",
|
|
229
|
+
semantics: "\uD68C\uB85C\uB97C \uC5F4\uACE0 \uB2EB\uB294 \uAE30\uAE30\uC758 **\uC704\uCE58\uB97C \uC77D\uB294\uB2E4** \u2014 \uC6B0\uB9AC\uB294 \uC870\uC791\uD558\uC9C0 \uC54A\uB294\uB2E4(\uBA85\uB839 \uC5C6\uC74C). IEC 61850 XCBR/XSWI Pos.",
|
|
230
|
+
commands: [],
|
|
231
|
+
stateFields: ["position"],
|
|
232
|
+
models: ["SwitchingState"],
|
|
233
|
+
results: ["positionChanged"]
|
|
234
|
+
},
|
|
235
|
+
trackable: {
|
|
236
|
+
key: "trackable",
|
|
237
|
+
label: "twin.capability.trackable",
|
|
238
|
+
semantics: "\uC624\uB354/\uC544\uC774\uD15C \uC0DD\uC560 \uCD94\uC801 \u2014 \uC0DD\uC560\uB2E8\uACC4(\uB3C4\uBA54\uC778 \uB77C\uBCA8, \uBB34\uBC29\uC5B8)\xB7\uC9C4\uD589\xB7\uBCF4\uB958.",
|
|
239
|
+
commands: ["order.hold", "order.resume", "order.release"],
|
|
240
|
+
stateFields: ["lifecycle", "progress", "held"],
|
|
241
|
+
results: ["lifecycleChanged"]
|
|
242
|
+
},
|
|
243
|
+
/*
|
|
244
|
+
* ── 에너지 능력 넷 ──────────────────────────────────────────────────────
|
|
245
|
+
* 상태 필드는 기존 능력과 **직교**한다(원칙 ②) — 어느 필드도 두 능력에 겹치지 않는다.
|
|
246
|
+
* 계량은 `operable`(가동 상태)과도 직교한다: 도는 것과 재는 것은 다른 사실이고, 멈춘 설비도
|
|
247
|
+
* 대기전력을 쓴다.
|
|
248
|
+
*/
|
|
249
|
+
metered: {
|
|
250
|
+
key: "metered",
|
|
251
|
+
label: "twin.capability.metered",
|
|
252
|
+
semantics: "\uC790\uAE30 \uC18C\uBE44\uB97C \uC7B0\uB2E4 \u2014 \uC720\uD6A8\uC804\uB825\xB7\uB204\uC801\uB7C9\xB7\uC5ED\uB960. \uAC12\uC774 \uC5C6\uC73C\uBA74 \uBAA8\uB974\uB294 \uAC83\uC774\uB2E4(0 \uC774 \uC544\uB2C8\uB2E4).",
|
|
253
|
+
commands: [],
|
|
254
|
+
stateFields: ["kW", "kWh", "powerFactor"],
|
|
255
|
+
models: ["MeteredState"],
|
|
256
|
+
results: ["measured"]
|
|
257
|
+
},
|
|
258
|
+
curtailable: {
|
|
259
|
+
key: "curtailable",
|
|
260
|
+
label: "twin.capability.curtailable",
|
|
261
|
+
semantics: "\uC904\uC77C \uC218 \uC788\uB2E4 \u2014 \uCD5C\uC18C \uC720\uC9C0 \uC544\uB798\uB85C\uB294 \uB0B4\uB9AC\uC9C0 \uC54A\uB294\uB2E4. \uD310\uC815\uB9CC \uD558\uACE0 \uC9D1\uD589\uC740 \uC0AC\uB78C\uACFC \uADF8 \uC2DC\uC2A4\uD15C\uC758 \uBAAB\uC774\uB2E4.",
|
|
262
|
+
commands: [],
|
|
263
|
+
stateFields: ["curtailable", "minKW"],
|
|
264
|
+
models: ["CurtailableState"],
|
|
265
|
+
invariants: ["minKW >= 0"],
|
|
266
|
+
results: ["drSuggested"]
|
|
267
|
+
},
|
|
268
|
+
energyGenerating: {
|
|
269
|
+
key: "energyGenerating",
|
|
270
|
+
label: "twin.capability.energyGenerating",
|
|
271
|
+
semantics: "\uC804\uAE30\uB97C \uB9CC\uB4E0\uB2E4 \u2014 \uC5ED\uC1A1\uC740 \uC18C\uBE44\uC758 \uC74C\uC218\uAC00 \uC544\uB2C8\uB77C \uBCC4\uAC1C \uC0AC\uC2E4\uC774\uB2E4.",
|
|
272
|
+
commands: [],
|
|
273
|
+
/*
|
|
274
|
+
* ── 적산을 더한다 (2026-08-26) ─────────────────────────────────────────────
|
|
275
|
+
* 이 능력이 순시 출력만 선언하고 있었다. 그래서 「지금 발전 중」은 말하고 「얼마나 발전했나」는 말할
|
|
276
|
+
* 수 없었다 — 사건 이름(`energy.generated`)과 설계 문서에는 처음부터 있던 사실이다.
|
|
277
|
+
*
|
|
278
|
+
* 계량 능력(`metered`)이 `kW` 와 `kWh` 를 함께 선언한 것과 같은 짝이다. 적산은 발전 설비의 보편적
|
|
279
|
+
* 사실이고(태양광·열병합·디젤), 그것이 없으면 성능비·발전시간 같은 성과를 아무도 셀 수 없다.
|
|
280
|
+
*/
|
|
281
|
+
stateFields: ["generatedKW", "exportKW", "generatedKWh", "generatedKWhResetAt", "generatedKWhSince", "generatedKWhBasis", "generatedKWhAccumulation", "generatedKWhLastPeriod", "generatedKWhLastPeriodEnd", "generatedKWhLastPeriodObservedFrom", "generatedKWhLastPeriodObservedTo", "dcVoltage", "dcCurrent", "acVoltage", "acCurrent"],
|
|
282
|
+
models: ["GeneratingState"],
|
|
283
|
+
results: ["generated"]
|
|
284
|
+
},
|
|
285
|
+
/*
|
|
286
|
+
* ── 왜 `storing` 이 아니라 `energyStoring` 인가 (2026-08-15) ────────────────
|
|
287
|
+
* 같은 카탈로그에 `storable`(물건을 담는 자리)이 있다. 전기를 담는 것과 물건을 담는 것이 한 글자
|
|
288
|
+
* 차이로 붙어 있어서, 컴포넌트를 만들 때 `twin-storage` 를 피해 `twin-storage-energy` 로 도망쳤다 —
|
|
289
|
+
* 한 이름을 두 번 피해 갔다면 그 이름이 틀린 것이다.
|
|
290
|
+
*
|
|
291
|
+
* 도메인(EMS)이 아니라 **양(에너지)** 을 이름에 넣는다. 그래서 MES 트윈 안의 디젤 발전기도 그대로
|
|
292
|
+
* `energyGenerating` 이다 — 능력은 도메인이 아니라 관측의 면이다(방언 없음).
|
|
293
|
+
*/
|
|
294
|
+
energyStoring: {
|
|
295
|
+
key: "energyStoring",
|
|
296
|
+
label: "twin.capability.energyStoring",
|
|
297
|
+
semantics: "\uB2F4\uACE0 \uB0B8\uB2E4 \u2014 \uCDA9\uC804\uACFC \uBC29\uC804\uC744 \uB098\uB208\uB2E4(\uC190\uC2E4\xB7\uC218\uBA85 \uD310\uB2E8\uC774 \uB458\uC744 \uAD6C\uBCC4\uD574\uC57C \uD55C\uB2E4).",
|
|
298
|
+
commands: [],
|
|
299
|
+
stateFields: ["soc", "chargeKW", "dischargeKW"],
|
|
300
|
+
models: ["StoringState"],
|
|
301
|
+
invariants: ["0 <= soc <= 100"],
|
|
302
|
+
results: ["stored", "discharged"]
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "switching", "metered", "curtailable", "energyGenerating", "energyStoring"];
|
|
306
|
+
function stateFieldsOf(caps) {
|
|
307
|
+
const out = /* @__PURE__ */ new Set();
|
|
308
|
+
for (const c of caps) for (const f of CAPABILITIES[c]?.stateFields ?? []) out.add(f);
|
|
309
|
+
return [...out];
|
|
310
|
+
}
|
|
311
|
+
function commandsOf(caps) {
|
|
312
|
+
const out = /* @__PURE__ */ new Set();
|
|
313
|
+
for (const c of caps) for (const cmd of CAPABILITIES[c]?.commands ?? []) out.add(cmd);
|
|
314
|
+
return [...out];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/contract.ts
|
|
318
|
+
var ORDER_TERMINAL_STATUS = ["completed", "cancelled"];
|
|
319
|
+
function isOrderTerminal(o) {
|
|
320
|
+
const status = String(o?.status ?? "").trim().toLowerCase();
|
|
321
|
+
if (ORDER_TERMINAL_STATUS.includes(status)) return true;
|
|
322
|
+
const requested = o?.requested;
|
|
323
|
+
const fulfilled = o?.fulfilled;
|
|
324
|
+
if (typeof requested !== "number" || !(requested > 0)) return false;
|
|
325
|
+
return typeof fulfilled === "number" && fulfilled >= requested;
|
|
326
|
+
}
|
|
327
|
+
var LOCATION_SATURATION_NEAR = 0.9;
|
|
328
|
+
function locationStatusOf(n) {
|
|
329
|
+
const cap = n.capacity;
|
|
330
|
+
if (!(typeof cap === "number" && cap > 0)) return void 0;
|
|
331
|
+
const r = (n.occupancy ?? 0) / cap;
|
|
332
|
+
return r >= 1 ? "full" : r >= LOCATION_SATURATION_NEAR ? "near-full" : "available";
|
|
333
|
+
}
|
|
334
|
+
var EQUIPMENT_LEVEL = [
|
|
335
|
+
"Enterprise",
|
|
336
|
+
"Site",
|
|
337
|
+
"Area",
|
|
338
|
+
"ProcessCell",
|
|
339
|
+
"Unit",
|
|
340
|
+
"ProductionLine",
|
|
341
|
+
"WorkCell",
|
|
342
|
+
"ProductionUnit",
|
|
343
|
+
"StorageZone",
|
|
344
|
+
"StorageUnit",
|
|
345
|
+
"WorkCenter",
|
|
346
|
+
"WorkUnit",
|
|
347
|
+
"EquipmentModule",
|
|
348
|
+
"ControlModule",
|
|
349
|
+
"Other"
|
|
350
|
+
];
|
|
351
|
+
function isEquipmentLevel(v) {
|
|
352
|
+
return typeof v === "string" && EQUIPMENT_LEVEL.includes(v);
|
|
353
|
+
}
|
|
354
|
+
function hierarchyOf(s, levelOfType) {
|
|
355
|
+
const push = (m, k, v) => {
|
|
356
|
+
const cur = m.get(k);
|
|
357
|
+
if (cur) cur.push(v);
|
|
358
|
+
else m.set(k, [v]);
|
|
359
|
+
};
|
|
360
|
+
const parent = /* @__PURE__ */ new Map();
|
|
361
|
+
const kids = /* @__PURE__ */ new Map();
|
|
362
|
+
const known = new Set(s.locations.map((n) => n.id));
|
|
363
|
+
for (const n of s.locations) {
|
|
364
|
+
if (!n.parentId || n.parentId === n.id) continue;
|
|
365
|
+
parent.set(n.id, n.parentId);
|
|
366
|
+
if (known.has(n.parentId)) push(kids, n.parentId, n.id);
|
|
367
|
+
}
|
|
368
|
+
for (const start of known) {
|
|
369
|
+
const seen = [start];
|
|
370
|
+
for (let at = parent.get(start); at !== void 0; at = parent.get(at)) {
|
|
371
|
+
if (seen.includes(at)) throw new Error(`location hierarchy has a cycle: ${[...seen, at].join(" \u2192 ")}`);
|
|
372
|
+
seen.push(at);
|
|
373
|
+
if (!known.has(at)) break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const homes = /* @__PURE__ */ new Map();
|
|
377
|
+
for (const m of s.equipment ?? []) if (m.homeLocation) push(homes, m.homeLocation, m.id);
|
|
378
|
+
const ancestorsOf = (id) => {
|
|
379
|
+
const out = [];
|
|
380
|
+
for (let at = parent.get(id); at !== void 0; at = parent.get(at)) {
|
|
381
|
+
out.push(at);
|
|
382
|
+
if (!known.has(at)) break;
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
};
|
|
386
|
+
const descendantsOf = (id) => {
|
|
387
|
+
const out = [];
|
|
388
|
+
const stack = [...kids.get(id) ?? []];
|
|
389
|
+
while (stack.length) {
|
|
390
|
+
const cur = stack.pop();
|
|
391
|
+
out.push(cur);
|
|
392
|
+
stack.push(...kids.get(cur) ?? []);
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
};
|
|
396
|
+
const typeOf = new Map(s.locations.map((n) => [n.id, n.type]));
|
|
397
|
+
const levelOf = new Map(s.locations.map((n) => [n.id, n.level ?? (n.type ? levelOfType?.(n.type) : void 0)]));
|
|
398
|
+
return {
|
|
399
|
+
childrenOf: (id) => [...kids.get(id) ?? []],
|
|
400
|
+
ancestorsOf,
|
|
401
|
+
descendantsOf,
|
|
402
|
+
rollupOf: (id) => ancestorsOf(id).find((a) => !known.has(a)),
|
|
403
|
+
ancestorOfLevel: (id, level) => ancestorsOf(id).find((a) => known.has(a) && levelOf.get(a) === level),
|
|
404
|
+
ancestorOfType: (id, type) => ancestorsOf(id).find((a) => known.has(a) && typeOf.get(a) === type),
|
|
405
|
+
equipmentOf: (id, opts) => {
|
|
406
|
+
const scope = opts?.deep ? [id, ...descendantsOf(id)] : [id];
|
|
407
|
+
return scope.flatMap((n) => homes.get(n) ?? []);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
var DISPOSITION_DECISION = ["rework", "use-as-is", "scrap", "return-to-supplier", "hold"];
|
|
412
|
+
function testPassedAt(r, at) {
|
|
413
|
+
if (r.result !== "pass") return false;
|
|
414
|
+
if (!r.expiresAt || !at) return r.result === "pass";
|
|
415
|
+
return parsedMs(at) <= parsedMs(r.expiresAt);
|
|
416
|
+
}
|
|
417
|
+
function meetsTests(required, results, at) {
|
|
418
|
+
if (!required.length) return true;
|
|
419
|
+
const bySpec = new Map((results ?? []).map((r) => [r.specId, r]));
|
|
420
|
+
for (const specId of required) {
|
|
421
|
+
const r = bySpec.get(specId);
|
|
422
|
+
if (!r) continue;
|
|
423
|
+
if (!testPassedAt(r, at)) return false;
|
|
424
|
+
}
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
function criterionSaysNothing(c) {
|
|
428
|
+
if (c?.expression?.trim()) return false;
|
|
429
|
+
const l = c?.limit;
|
|
430
|
+
return !(typeof l?.minimum === "number" || typeof l?.maximum === "number");
|
|
431
|
+
}
|
|
432
|
+
function outsideLimit(criterion, observed) {
|
|
433
|
+
const l = criterion?.limit;
|
|
434
|
+
if (!l) return void 0;
|
|
435
|
+
const hasMin = typeof l.minimum === "number";
|
|
436
|
+
const hasMax = typeof l.maximum === "number";
|
|
437
|
+
if (!hasMin && !hasMax) return void 0;
|
|
438
|
+
const raw = observed?.value;
|
|
439
|
+
if (raw === void 0 || raw === null || String(raw).trim() === "") return void 0;
|
|
440
|
+
const n = Number(raw);
|
|
441
|
+
if (!Number.isFinite(n)) return void 0;
|
|
442
|
+
const lu = l.uom?.trim();
|
|
443
|
+
const ou = observed?.uom?.trim();
|
|
444
|
+
if (lu && ou && lu !== ou) return void 0;
|
|
445
|
+
if (hasMin && n < l.minimum) return true;
|
|
446
|
+
if (hasMax && n > l.maximum) return true;
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
function testEvidenceGaps(spec, result) {
|
|
450
|
+
const criteria = spec?.criteria ?? [];
|
|
451
|
+
const measurements = result?.propertyMeasurements ?? [];
|
|
452
|
+
const wanted = new Set(criteria.map((c) => c.evaluatedPropertyId).filter((k) => !!k));
|
|
453
|
+
const got = new Set(measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k));
|
|
454
|
+
return {
|
|
455
|
+
unmeasured: criteria.filter((c) => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId)).map((c) => c.id),
|
|
456
|
+
unmatched: measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k && !wanted.has(k))
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function judgeAgainstSpec(spec, result) {
|
|
460
|
+
const criteria = (spec?.criteria ?? []).filter((c) => !criterionSaysNothing(c));
|
|
461
|
+
if (!criteria.length) return void 0;
|
|
462
|
+
const measurements = result?.propertyMeasurements ?? [];
|
|
463
|
+
let allJudged = true;
|
|
464
|
+
for (const c of criteria) {
|
|
465
|
+
const m = c.evaluatedPropertyId ? measurements.find((x) => x.testableObjectPropertyId === c.evaluatedPropertyId) : void 0;
|
|
466
|
+
const out = outsideLimit(c, m);
|
|
467
|
+
if (out === true) return "fail";
|
|
468
|
+
if (out === void 0) allJudged = false;
|
|
469
|
+
}
|
|
470
|
+
return allJudged ? "pass" : void 0;
|
|
471
|
+
}
|
|
472
|
+
function effectivityAt(p, at, opts) {
|
|
473
|
+
if (!p || !at) return void 0;
|
|
474
|
+
const atMs = parsedMs(at);
|
|
475
|
+
if (!Number.isFinite(atMs)) return void 0;
|
|
476
|
+
const from = p.effectiveStart ? parsedMs(p.effectiveStart) : NaN;
|
|
477
|
+
if (Number.isFinite(from) && atMs < from) return "not-yet";
|
|
478
|
+
const to = p.effectiveEnd ? parsedMs(p.effectiveEnd) : NaN;
|
|
479
|
+
if (Number.isFinite(to) && (opts?.end === "exclusive" ? atMs >= to : atMs > to)) return "expired";
|
|
480
|
+
return void 0;
|
|
481
|
+
}
|
|
482
|
+
var PARSED_MAX = 64;
|
|
483
|
+
var parsedCache = /* @__PURE__ */ new Map();
|
|
484
|
+
function parsedMs(at) {
|
|
485
|
+
const hit = parsedCache.get(at);
|
|
486
|
+
if (hit !== void 0) return hit;
|
|
487
|
+
const ms = Date.parse(at);
|
|
488
|
+
if (parsedCache.size >= PARSED_MAX) parsedCache.clear();
|
|
489
|
+
parsedCache.set(at, ms);
|
|
490
|
+
return ms;
|
|
491
|
+
}
|
|
492
|
+
var classIndexCache = /* @__PURE__ */ new WeakMap();
|
|
493
|
+
function classIndex(defs) {
|
|
494
|
+
if (!defs) return EMPTY_CLASS_INDEX;
|
|
495
|
+
const hit = classIndexCache.get(defs);
|
|
496
|
+
if (hit) return hit;
|
|
497
|
+
const built = new Map(defs.map((d) => [d.id, d]));
|
|
498
|
+
classIndexCache.set(defs, built);
|
|
499
|
+
return built;
|
|
500
|
+
}
|
|
501
|
+
var EMPTY_CLASS_INDEX = /* @__PURE__ */ new Map();
|
|
502
|
+
function classClosure(directIds, defs, at) {
|
|
503
|
+
const byId = classIndex(defs);
|
|
504
|
+
const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
|
|
505
|
+
const out = /* @__PURE__ */ new Set();
|
|
506
|
+
const stack = [...directIds ?? []];
|
|
507
|
+
while (stack.length) {
|
|
508
|
+
const id = stack.pop();
|
|
509
|
+
if (out.has(id)) continue;
|
|
510
|
+
const def = byId.get(id);
|
|
511
|
+
if (!inWindow(def)) continue;
|
|
512
|
+
out.add(id);
|
|
513
|
+
for (const b of def?.baseIds ?? []) if (!out.has(b)) stack.push(b);
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
var PRIORITY_UNSET = Number.POSITIVE_INFINITY;
|
|
518
|
+
function priorityRank(p) {
|
|
519
|
+
return typeof p === "number" && Number.isFinite(p) ? p : PRIORITY_UNSET;
|
|
520
|
+
}
|
|
521
|
+
function dueStatusOf(x, nowIso) {
|
|
522
|
+
if (!x.endTime || !nowIso) return void 0;
|
|
523
|
+
const due = parsedMs(x.endTime);
|
|
524
|
+
const now = parsedMs(nowIso);
|
|
525
|
+
if (!Number.isFinite(due) || !Number.isFinite(now)) return void 0;
|
|
526
|
+
return now > due ? "late" : "on-time";
|
|
527
|
+
}
|
|
528
|
+
function subLotIdOf(classUri, location) {
|
|
529
|
+
return `${classUri}@${location}`;
|
|
530
|
+
}
|
|
531
|
+
function itemKeyOf(item) {
|
|
532
|
+
return item.subLotId ?? item.epc;
|
|
533
|
+
}
|
|
534
|
+
function quantityIn(item, uom, definitions) {
|
|
535
|
+
const hit = item.quantities?.find((q) => (q.uom ?? void 0) === (uom ?? void 0));
|
|
536
|
+
if (hit) return hit.value;
|
|
537
|
+
if ((item.uom ?? void 0) === (uom ?? void 0)) return item.qty;
|
|
538
|
+
return convertQuantity(item, uom, definitions);
|
|
539
|
+
}
|
|
540
|
+
var MATERIAL_PROPERTY = {
|
|
541
|
+
/** 기준 단위 하나당 이 단위의 양. `uom` 이 대상 단위. */
|
|
542
|
+
perBaseUnit: "perBaseUnit"
|
|
543
|
+
};
|
|
544
|
+
function conversionFactorOf(def, uom) {
|
|
545
|
+
if (!def || !uom) return void 0;
|
|
546
|
+
for (const p of def.properties ?? []) {
|
|
547
|
+
if (p.id !== MATERIAL_PROPERTY.perBaseUnit || p.uom !== uom) continue;
|
|
548
|
+
const n = typeof p.value === "number" ? p.value : Number(p.value);
|
|
549
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
550
|
+
}
|
|
551
|
+
return void 0;
|
|
552
|
+
}
|
|
553
|
+
function convertQuantity(item, uom, definitions) {
|
|
554
|
+
if (!definitions || !uom) return void 0;
|
|
555
|
+
if (typeof item.qty !== "number" || !Number.isFinite(item.qty)) return void 0;
|
|
556
|
+
if (item.uom) return void 0;
|
|
557
|
+
const def = definitions.get(item.definitionId ?? item.gtin ?? "");
|
|
558
|
+
const factor = conversionFactorOf(def, uom);
|
|
559
|
+
return factor === void 0 ? void 0 : item.qty * factor;
|
|
560
|
+
}
|
|
561
|
+
function minutesOfDay(hhmm) {
|
|
562
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm ?? "");
|
|
563
|
+
if (!m) return void 0;
|
|
564
|
+
const h = Number(m[1]);
|
|
565
|
+
const mi = Number(m[2]);
|
|
566
|
+
if (h > 23 || mi > 59) return void 0;
|
|
567
|
+
return h * 60 + mi;
|
|
568
|
+
}
|
|
569
|
+
var isAbsolute = (e) => !!(e.startDateTime || e.finishDateTime);
|
|
570
|
+
var needsDate = (e) => isAbsolute(e) || !!e.daysOfWeek?.length;
|
|
571
|
+
function onDeclaredDay(e, minuteOfDay, weekday) {
|
|
572
|
+
if (!e.daysOfWeek?.length) return true;
|
|
573
|
+
const from = minutesOfDay(e.fromTime);
|
|
574
|
+
const to = minutesOfDay(e.toTime);
|
|
575
|
+
const crosses = from !== void 0 && to !== void 0 && from > to;
|
|
576
|
+
const startedDay = crosses && minuteOfDay < to ? (weekday + 6) % 7 : weekday;
|
|
577
|
+
return e.daysOfWeek.includes(startedDay);
|
|
578
|
+
}
|
|
579
|
+
function weekdayAt(ms, utcOffsetMinutes) {
|
|
580
|
+
return new Date(ms + (utcOffsetMinutes ?? 0) * 6e4).getUTCDay();
|
|
581
|
+
}
|
|
582
|
+
function localDayIndexAt(ms, utcOffsetMinutes) {
|
|
583
|
+
return Math.floor((ms + (utcOffsetMinutes ?? 0) * 6e4) / 864e5);
|
|
584
|
+
}
|
|
585
|
+
function localDayStartMs(dayIndex, utcOffsetMinutes) {
|
|
586
|
+
return dayIndex * 864e5 - (utcOffsetMinutes ?? 0) * 6e4;
|
|
587
|
+
}
|
|
588
|
+
function coversInstant(e, atMs) {
|
|
589
|
+
const from = e.startDateTime ? Date.parse(e.startDateTime) : NaN;
|
|
590
|
+
const to = e.finishDateTime ? Date.parse(e.finishDateTime) : NaN;
|
|
591
|
+
if (!Number.isFinite(from) && !Number.isFinite(to)) return false;
|
|
592
|
+
if (Number.isFinite(from) && atMs < from) return false;
|
|
593
|
+
if (Number.isFinite(to) && atMs >= to) return false;
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
function coversMinute(e, minuteOfDay) {
|
|
597
|
+
const from = minutesOfDay(e.fromTime);
|
|
598
|
+
const to = minutesOfDay(e.toTime);
|
|
599
|
+
if (from === void 0 || to === void 0) return false;
|
|
600
|
+
return from <= to ? minuteOfDay >= from && minuteOfDay < to : minuteOfDay >= from || minuteOfDay < to;
|
|
601
|
+
}
|
|
602
|
+
function inWorkCalendar(entries, minuteOfDay) {
|
|
603
|
+
return judgeCalendar(entries, (e) => coversMinute(e, minuteOfDay), false);
|
|
604
|
+
}
|
|
605
|
+
function inWorkCalendarAt(entries, atMs, utcOffsetMinutes) {
|
|
606
|
+
const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
|
|
607
|
+
const day = weekdayAt(atMs, utcOffsetMinutes);
|
|
608
|
+
const covers = (e) => {
|
|
609
|
+
if (isAbsolute(e)) return coversInstant(e, atMs);
|
|
610
|
+
if (!coversMinute(e, minute)) return false;
|
|
611
|
+
return onDeclaredDay(e, minute, day);
|
|
612
|
+
};
|
|
613
|
+
return judgeCalendar(entries, covers, true);
|
|
614
|
+
}
|
|
615
|
+
function judgeCalendar(entries, covers, withAbsolute) {
|
|
616
|
+
if (!entries?.length) return true;
|
|
617
|
+
const usable = withAbsolute ? entries : entries.filter((e) => !needsDate(e));
|
|
618
|
+
if (!usable.length) return true;
|
|
619
|
+
const working = usable.filter((e) => (e.entryType ?? "working") === "working");
|
|
620
|
+
const off = usable.filter((e) => e.entryType === "non-working");
|
|
621
|
+
if (off.some(covers)) return false;
|
|
622
|
+
if (!working.length) return true;
|
|
623
|
+
return working.some(covers);
|
|
624
|
+
}
|
|
625
|
+
function activeShiftOf(entries, minuteOfDay) {
|
|
626
|
+
if (!entries?.length) return void 0;
|
|
627
|
+
if (!inWorkCalendar(entries, minuteOfDay)) return void 0;
|
|
628
|
+
for (const e of entries) {
|
|
629
|
+
if (needsDate(e) || (e.entryType ?? "working") !== "working" || !e.id) continue;
|
|
630
|
+
if (coversMinute(e, minuteOfDay)) return e.id;
|
|
631
|
+
}
|
|
632
|
+
return void 0;
|
|
633
|
+
}
|
|
634
|
+
function activeShiftAt(entries, atMs, utcOffsetMinutes) {
|
|
635
|
+
if (!entries?.length) return void 0;
|
|
636
|
+
if (!inWorkCalendarAt(entries, atMs, utcOffsetMinutes)) return void 0;
|
|
637
|
+
const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
|
|
638
|
+
const day = weekdayAt(atMs, utcOffsetMinutes);
|
|
639
|
+
for (const e of entries) {
|
|
640
|
+
if (isAbsolute(e) || (e.entryType ?? "working") !== "working" || !e.id) continue;
|
|
641
|
+
if (!coversMinute(e, minute)) continue;
|
|
642
|
+
if (!onDeclaredDay(e, minute, day)) continue;
|
|
643
|
+
return e.id;
|
|
644
|
+
}
|
|
645
|
+
return void 0;
|
|
646
|
+
}
|
|
647
|
+
function minuteOfDayAt(ms, utcOffsetMinutes) {
|
|
648
|
+
const shifted = ms + (utcOffsetMinutes ?? 0) * 6e4;
|
|
649
|
+
const DAY = 864e5;
|
|
650
|
+
const inDay = (shifted % DAY + DAY) % DAY;
|
|
651
|
+
return Math.floor(inDay / 6e4);
|
|
652
|
+
}
|
|
653
|
+
function offCalendarReasonAt(r, ms, utcOffsetMinutes) {
|
|
654
|
+
if (!offCalendarAt(r, ms, utcOffsetMinutes)) return void 0;
|
|
655
|
+
const entries = r.workCalendar;
|
|
656
|
+
if (!entries?.length) return "off-hours";
|
|
657
|
+
const minute = minuteOfDayAt(ms, utcOffsetMinutes);
|
|
658
|
+
const day = weekdayAt(ms, utcOffsetMinutes);
|
|
659
|
+
for (const e of entries) {
|
|
660
|
+
if (e.entryType !== "non-working") continue;
|
|
661
|
+
const covered = isAbsolute(e) ? coversInstant(e, ms) : coversMinute(e, minute) && onDeclaredDay(e, minute, day);
|
|
662
|
+
if (covered) return "non-working";
|
|
663
|
+
}
|
|
664
|
+
return "off-hours";
|
|
665
|
+
}
|
|
666
|
+
function offCalendarAt(r, ms, utcOffsetMinutes) {
|
|
667
|
+
const minute = minuteOfDayAt(ms, utcOffsetMinutes);
|
|
668
|
+
if (r.workCalendar?.length) return !inWorkCalendarAt(r.workCalendar, ms, utcOffsetMinutes);
|
|
669
|
+
const w = r.window;
|
|
670
|
+
if (!w) return false;
|
|
671
|
+
const h = Math.floor(minute / 60);
|
|
672
|
+
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
673
|
+
}
|
|
674
|
+
function requiredTestsFor(directIds, defs, at) {
|
|
675
|
+
if (!defs?.length) return [];
|
|
676
|
+
const cache = requiredTestsCache.get(defs) ?? /* @__PURE__ */ new Map();
|
|
677
|
+
if (!requiredTestsCache.has(defs)) requiredTestsCache.set(defs, cache);
|
|
678
|
+
const key = `${at ?? ""}\0${(directIds ?? []).join("")}`;
|
|
679
|
+
const hit = cache.get(key);
|
|
680
|
+
if (hit) return hit;
|
|
681
|
+
const closure = classClosure(directIds, defs, at);
|
|
682
|
+
const required = [];
|
|
683
|
+
for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
|
|
684
|
+
if (cache.size >= REQUIRED_TESTS_MAX) cache.clear();
|
|
685
|
+
cache.set(key, required);
|
|
686
|
+
return required;
|
|
687
|
+
}
|
|
688
|
+
var REQUIRED_TESTS_MAX = 512;
|
|
689
|
+
var requiredTestsCache = /* @__PURE__ */ new WeakMap();
|
|
690
|
+
function capabilityOf(r, ctx) {
|
|
691
|
+
const at = ctx?.at;
|
|
692
|
+
const eff = effectivityAt(r, at);
|
|
693
|
+
if (eff === "not-yet") return { available: false, reason: "not-yet" };
|
|
694
|
+
if (eff === "expired") return { available: false, reason: "retired" };
|
|
695
|
+
if (r.held) return { available: false, reason: "held" };
|
|
696
|
+
if (r.status === "down") return { available: false, reason: "down" };
|
|
697
|
+
if (at) {
|
|
698
|
+
const ms = parsedMs(at);
|
|
699
|
+
if (Number.isFinite(ms)) {
|
|
700
|
+
const why = offCalendarReasonAt(r, ms, ctx?.utcOffsetMinutes);
|
|
701
|
+
if (why === "non-working") return { available: false, reason: "resting" };
|
|
702
|
+
if (why === "off-hours") return { available: false, reason: "off-shift" };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
|
|
706
|
+
return { available: false, reason: "test-expired" };
|
|
707
|
+
if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
|
|
708
|
+
return { available: true, reason: "available" };
|
|
709
|
+
}
|
|
710
|
+
function observationAt(observations, propertyId, at) {
|
|
711
|
+
const t = parsedMs(at);
|
|
712
|
+
if (!(t >= 0)) return void 0;
|
|
713
|
+
let best;
|
|
714
|
+
let bestStart = -1;
|
|
715
|
+
for (const o of observations ?? []) {
|
|
716
|
+
if (o.propertyId !== propertyId) continue;
|
|
717
|
+
const from = parsedMs(o.effectiveTime);
|
|
718
|
+
if (!(from >= 0) || from > t) continue;
|
|
719
|
+
if (o.effectiveEndTime) {
|
|
720
|
+
const to = parsedMs(o.effectiveEndTime);
|
|
721
|
+
if (to >= 0 && to < t) continue;
|
|
722
|
+
}
|
|
723
|
+
if (from > bestStart) {
|
|
724
|
+
best = o;
|
|
725
|
+
bestStart = from;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return best;
|
|
729
|
+
}
|
|
730
|
+
var OP_EVENT = {
|
|
731
|
+
task: "task.status",
|
|
732
|
+
equipment: "equipment.status",
|
|
733
|
+
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
734
|
+
person: "person.status",
|
|
735
|
+
/** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
|
|
736
|
+
asset: "asset.status",
|
|
737
|
+
order: "order.status",
|
|
738
|
+
quality: "quality.output",
|
|
739
|
+
// 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
|
|
740
|
+
/**
|
|
741
|
+
* **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
|
|
742
|
+
*
|
|
743
|
+
* `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
|
|
744
|
+
* 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
|
|
745
|
+
*
|
|
746
|
+
* **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
|
|
747
|
+
* 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
|
|
748
|
+
*
|
|
749
|
+
* 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
|
|
750
|
+
* 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
|
|
751
|
+
*/
|
|
752
|
+
test: "test.result",
|
|
753
|
+
/**
|
|
754
|
+
* **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나(재작업 · 특채 · 폐기 · 반품).
|
|
755
|
+
*
|
|
756
|
+
* ── 판정과 다른 사실이다 (2026-08-30) ─────────────────────────────────────
|
|
757
|
+
* `test.result` 에 필드로 붙이지 않는다. 셋 중 둘을 표현할 수 없게 되기 때문이다.
|
|
758
|
+
*
|
|
759
|
+
* 한 판정에 처분이 여럿 일부는 재작업하고 일부는 폐기한다
|
|
760
|
+
* 처분 없는 판정 기록만 하고 결정은 나중에 한다
|
|
761
|
+
* 판정 없는 처분 현장 재량으로 뺀다
|
|
762
|
+
*
|
|
763
|
+
* ── 왜 커널이 아는가 ──────────────────────────────────────────────────────
|
|
764
|
+
* 처분은 **자원에 효과를 준다.** 재작업은 자재를 다시 공정에 넣고, 폐기는 재고에서 뺀다. 효과를 주는
|
|
765
|
+
* 사실은 커널 1급이다.
|
|
766
|
+
*
|
|
767
|
+
* EPCIS 의 `disposition` 과 헷갈리지 않는다 — 그것은 **개체의 상태**(`damaged`·`destroyed`)이고
|
|
768
|
+
* 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
|
|
769
|
+
*/
|
|
770
|
+
disposition: "nonconformance.disposition",
|
|
771
|
+
/**
|
|
772
|
+
* **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
|
|
773
|
+
*
|
|
774
|
+
* ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
|
|
775
|
+
* 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
|
|
776
|
+
* **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
|
|
777
|
+
* 오지 않은 것이 되고 트윈에 영원히 남았다.
|
|
778
|
+
*
|
|
779
|
+
* 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
|
|
780
|
+
*
|
|
781
|
+
* ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
|
|
782
|
+
* 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
|
|
783
|
+
* 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
|
|
784
|
+
* 사라진다.
|
|
785
|
+
*
|
|
786
|
+
* ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
|
|
787
|
+
* **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
|
|
788
|
+
* 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
|
|
789
|
+
* 지워지지 않아야 한다.
|
|
790
|
+
*/
|
|
791
|
+
complete: "axis.complete",
|
|
792
|
+
/**
|
|
793
|
+
* 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
|
|
794
|
+
*
|
|
795
|
+
* 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
|
|
796
|
+
* 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
|
|
797
|
+
* 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
|
|
798
|
+
*/
|
|
799
|
+
/*
|
|
800
|
+
* 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
|
|
801
|
+
* "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
|
|
802
|
+
* 구별되지 않는다.
|
|
803
|
+
*/
|
|
804
|
+
attentionAck: "attention.acked",
|
|
805
|
+
/**
|
|
806
|
+
* **자리에서 관측된 물리량** — 냉장실 온도·습도, 세척수 유량 같은 것(§`LocationObservation`).
|
|
807
|
+
*
|
|
808
|
+
* ── 왜 채널이 필요한가 ────────────────────────────────────────────────────
|
|
809
|
+
* `LocationState.observations` 를 상태에 두었는데 그것을 낳는 사건이 없었다. 그러면 **상태 ⊆ 이벤트**
|
|
810
|
+
* 가 깨진다: 채워도 재기동에서 사라지고, 폴드가 되살릴 수 없고, 미러가 이어받을 수도 없다. 상태에만
|
|
811
|
+
* 있는 축은 조용히 사라지는 축이다.
|
|
812
|
+
*
|
|
813
|
+
* 이름을 `energy.measured` 와 같은 결로 둔다 — 그쪽이 에너지 계량의 도착이고 이쪽이 그 일반형이다.
|
|
814
|
+
* 두 채널을 합치지 않는 이유는 에너지 쪽이 **구간에 누적되는 표본**이라 처리가 다르기 때문이다.
|
|
815
|
+
*/
|
|
816
|
+
observation: "location.measured"
|
|
817
|
+
};
|
|
818
|
+
var ENERGY_EVENT = {
|
|
819
|
+
/** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
|
|
820
|
+
measured: "energy.measured",
|
|
821
|
+
/**
|
|
822
|
+
* 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
|
|
823
|
+
*
|
|
824
|
+
* 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
|
|
825
|
+
*/
|
|
826
|
+
equipment: "energy.equipment",
|
|
827
|
+
/** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
|
|
828
|
+
demandWindow: "energy.demand.window",
|
|
829
|
+
/**
|
|
830
|
+
* **마감된 사용 구간이 도착했다** — 우리가 마감한 것이 아니라 밖에서 확정되어 온 것이다.
|
|
831
|
+
*
|
|
832
|
+
* 계량 표본이 오지 않는 현장이 있다. 공급자의 조회나 계량 데이터 시스템이 「이 구간에 X 썼다」를
|
|
833
|
+
* 이미 마감해 준다. 그 값을 적산(`energy.measured` 의 `kWh`)으로 보내면 커널이 그것을 누적으로
|
|
834
|
+
* 읽고 차분을 또 구한다 — 차분의 차분이 되어 틀린다. 그래서 다른 사건이다.
|
|
835
|
+
*
|
|
836
|
+
* `demandWindow` 와 **합치지 않는다.** 하나는 우리가 표본에서 마감한 것이고 이것은 밖에서 온
|
|
837
|
+
* 것이다. 두 회계를 더하면 같은 전기를 두 번 센다. 둘 다 있으면 읽는 쪽이 하나를 고르고 어느
|
|
838
|
+
* 쪽인지 말한다.
|
|
839
|
+
*/
|
|
840
|
+
usagePeriod: "energy.usage.period",
|
|
841
|
+
/**
|
|
842
|
+
* **발전에 매겨지는 단가가 정해졌다** — 이 기간에 낸 전기 1kWh 의 값.
|
|
843
|
+
*
|
|
844
|
+
* 소비에 요금이 매겨지듯 발전에도 값이 매겨진다. 파는 발전소는 판 값이고, 자가소비형은 아꼈다고
|
|
845
|
+
* 볼 수 있는 값이다 — 어느 쪽인지는 현장이 정한다.
|
|
846
|
+
*
|
|
847
|
+
* **단가는 날마다 바뀐다.** 그래서 선언이 아니라 기간을 가진 사실로 받는다. 선언에 적어 두면
|
|
848
|
+
* 사람이 매일 갱신해야 하고, 안 하면 낡은 값으로 수익이 조용히 틀린다.
|
|
849
|
+
*
|
|
850
|
+
* 사건 시각은 기간의 시작이다 — 「이 시각부터 이 단가가 적용된다」이므로 기간이 끝나기 전에 성립한다.
|
|
851
|
+
*
|
|
852
|
+
* ── 담지 않은 것 ────────────────────────────────────────────────────────────
|
|
853
|
+
* 제도마다 있는 가산·인증서·가중치는 담지 않는다. 그 셈은 제도가 정하고 나라마다 다르므로,
|
|
854
|
+
* 커넥터가 계산해 **1kWh 당 얼마**로 옮겨 보낸다. 커널이 한 제도의 모양을 안으면 다른 제도에서
|
|
855
|
+
* 그 자리가 거짓이 된다.
|
|
856
|
+
*/
|
|
857
|
+
generationPrice: "energy.generation.price",
|
|
858
|
+
/**
|
|
859
|
+
* **이 주기에 적용되는 요금 기준이 정해졌다** — 요금적용전력과 기본요금 단가.
|
|
860
|
+
*
|
|
861
|
+
* 청구서와 다른 사건이다. 청구서는 끝난 기간의 정산이고 이것은 **지금 적용되는 기준**이다.
|
|
862
|
+
* 그래서 사건 시각도 다르다 — 청구서는 기간의 끝, 이것은 기간의 시작이다(그때부터 유효하다).
|
|
863
|
+
*
|
|
864
|
+
* 이 값은 매 주기 다시 정해진다. 모델의 선언에 적어 두면 사람이 매달 갱신해야 하고, 안 하면 낡은
|
|
865
|
+
* 값으로 기본요금이 조용히 틀린다. 공급자가 알려 주는 값을 그대로 받는 것이 맞다.
|
|
866
|
+
*
|
|
867
|
+
* **금액을 싣지 않는다.** 요금적용전력 × 단가는 커널이 계산한다. 곱을 받으면 그것이 정산인 척한다.
|
|
868
|
+
*/
|
|
869
|
+
tariffBasis: "energy.tariff.basis",
|
|
870
|
+
/**
|
|
871
|
+
* **청구서가 도착했다** — 공급자가 확정한 금액.
|
|
872
|
+
*
|
|
873
|
+
* 우리가 계산한 금액(§`electricityCost`)을 덮지 않는다. 두 값은 다른 것이다: 청구는 사실이고 우리
|
|
874
|
+
* 계산은 파생이다(구간 요금·예측·가정 비교에 쓴다). 계기 적산과 같은 규율이다 — 다를 때 맞는 쪽은
|
|
875
|
+
* 그 회계를 가진 쪽이다.
|
|
876
|
+
*
|
|
877
|
+
* 어긋나면 **그 사실을 낸다.** 우리 계산이 틀렸다는 뜻이고, 그것을 사람이 찾아내지 않아도 드러나야
|
|
878
|
+
* 한다(실제로 사람이 찾아낸 적이 있다 — 기본요금을 이 주기의 최대로 매기고 있었다).
|
|
879
|
+
*/
|
|
880
|
+
bill: "energy.bill",
|
|
881
|
+
/**
|
|
882
|
+
* 피크 경신 — 월 최대 수요가 갱신됐다.
|
|
883
|
+
*
|
|
884
|
+
* 구간 마감에서 파생되지만 **사실로 남긴다**: 요금의 근거이고, 나중에 "언제 무엇 때문에 올랐나" 를
|
|
885
|
+
* 물을 때 파생으로는 답할 수 없다(그 순간의 부하 구성이 사라진다).
|
|
886
|
+
*/
|
|
887
|
+
peak: "energy.peak",
|
|
888
|
+
/** 요금 구간 전환 — 경부하·중간부하·최대부하. 달력이 아니라 사건이다(계절제·특례로 바뀐다). */
|
|
889
|
+
tariffShift: "energy.tariff.shift",
|
|
890
|
+
/** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
|
|
891
|
+
generated: "energy.generated",
|
|
892
|
+
/**
|
|
893
|
+
* **기간 발전량 확정** — 한 기간이 끝났고 그 기간에 얼마 냈는지가 정해졌다.
|
|
894
|
+
*
|
|
895
|
+
* ISO 50001 의 성과지표와 기준선, 태양광 성능비가 모두 「기간당 에너지」로 정의된다. 적산값만 상태에
|
|
896
|
+
* 두면 그 어느 것도 계산할 수 없고, 지난 기간들을 합할 수도 없다(상태는 지금 하나만 든다).
|
|
897
|
+
*
|
|
898
|
+
* 계량이 수요 구간을 마감해 사실로 내는 것과 같은 짝이다(§`demandWindow`). 두 값을 더하지 말 것 —
|
|
899
|
+
* 발전량은 설비가 만든 양이고 구간 전력량은 계량 지점을 지난 양이다.
|
|
900
|
+
*/
|
|
901
|
+
generationPeriod: "energy.generation.period",
|
|
902
|
+
/** 저장(ESS 충전). */
|
|
903
|
+
stored: "energy.stored",
|
|
904
|
+
/** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
|
|
905
|
+
discharged: "energy.discharged",
|
|
906
|
+
/**
|
|
907
|
+
* 수요 제어 제안 — **제안이지 명령이 아니다.**
|
|
908
|
+
*
|
|
909
|
+
* 이 트윈은 차단·투입을 하지 않는다(안전 계통은 범위 밖: `ems.md` §1). 이대로면 계약을 넘는다는
|
|
910
|
+
* 판단과 무엇을 줄이면 되는지를 낼 뿐이고, 집행은 사람과 그 시스템의 몫이다. 이름을 `suggested`
|
|
911
|
+
* 로 둔 이유가 그것이다 — 저널만 보고도 "우리가 끈 것이 아니다" 를 알 수 있어야 한다.
|
|
912
|
+
*/
|
|
913
|
+
drSuggested: "energy.dr.suggested"
|
|
914
|
+
};
|
|
915
|
+
var OBSERVATION_BASIS = ["metered", "provider", "billed", "estimated"];
|
|
916
|
+
var CMD = {
|
|
917
|
+
orderHold: "order.hold",
|
|
918
|
+
orderResume: "order.resume",
|
|
919
|
+
orderRelease: "order.release",
|
|
920
|
+
attentionAck: "attention.ack",
|
|
921
|
+
// 주목 신호 확인(OPC UA A&C acknowledge) — args:{id}
|
|
922
|
+
// Operable 코어 — 모든 operable 자원(설비·이동설비) 공통. args:{resourceId}. capability-keyed(무방언, equipment.* 아님).
|
|
923
|
+
resourceHold: "resource.hold",
|
|
924
|
+
// 계획 정지(정비/오프라인) — 배정 스킵
|
|
925
|
+
resourceResume: "resource.resume",
|
|
926
|
+
// 계획 정지 해제
|
|
927
|
+
resourceDown: "resource.down",
|
|
928
|
+
// 비계획 고장 주입 — args:{resourceId, durationMs?}
|
|
929
|
+
resourceRepair: "resource.repair",
|
|
930
|
+
// 즉시 수리
|
|
931
|
+
resourceResetMetrics: "resource.reset-metrics",
|
|
932
|
+
// OEE 계측 창 리셋
|
|
933
|
+
resourceAdd: "resource.add"
|
|
934
|
+
// 라이브 자원(설비) 추가 — args:{kind, homeLocation, count?}. 런타임 구조 변이(what-if 아닌 실제 act)
|
|
935
|
+
};
|
|
936
|
+
function readBoardLocations(def) {
|
|
937
|
+
const d = def;
|
|
938
|
+
return d.locations ?? d.nodes ?? [];
|
|
939
|
+
}
|
|
940
|
+
function readBoardEquipment(def) {
|
|
941
|
+
const d = def;
|
|
942
|
+
const list = d.equipment ?? d.equipmentList ?? d.movers ?? [];
|
|
943
|
+
return list.map((e) => normalizeHomeLocation(e));
|
|
944
|
+
}
|
|
945
|
+
function normalizeHomeLocation(entry) {
|
|
946
|
+
const legacy = entry.homeNode;
|
|
947
|
+
if (legacy === void 0 || entry.homeLocation !== void 0) return entry;
|
|
948
|
+
const { homeNode: _drop, ...rest } = entry;
|
|
949
|
+
return { ...rest, homeLocation: legacy };
|
|
950
|
+
}
|
|
951
|
+
function readBoardAssets(def) {
|
|
952
|
+
const list = def.assets ?? [];
|
|
953
|
+
return list.map((a) => normalizeHomeLocation(a));
|
|
954
|
+
}
|
|
955
|
+
function identityGroundingOf(spec, kernelConstantPrefix) {
|
|
956
|
+
const declared = spec?.identity?.namespaces?.filter((n) => typeof n === "string" && n.length > 0) ?? [];
|
|
957
|
+
if (spec?.identity?.issuedClaim && declared.length) {
|
|
958
|
+
return { grounding: "issued", basis: "claimed-issued", namespaces: [...declared] };
|
|
959
|
+
}
|
|
960
|
+
if (declared.length) return { grounding: "declared", basis: "declared-namespace", namespaces: [...declared] };
|
|
961
|
+
if (spec?.companyPrefix && spec.binding) {
|
|
962
|
+
return { grounding: "declared", basis: "gs1-shaped", namespaces: gs1Namespaces(spec.companyPrefix) };
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
grounding: "fabricated",
|
|
966
|
+
basis: "kernel-constant",
|
|
967
|
+
namespaces: kernelConstantPrefix ? gs1Namespaces(kernelConstantPrefix) : []
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
function gs1Namespaces(prefix) {
|
|
971
|
+
return [`urn:epc:idpat:sgtin:${prefix}.`, `urn:epc:class:lgtin:${prefix}.`, `urn:epc:id:sgtin:${prefix}.`];
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/capacity.ts
|
|
975
|
+
function isoDurationHours(iso) {
|
|
976
|
+
if (!iso) return 0;
|
|
977
|
+
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(iso);
|
|
978
|
+
if (!m) throw new Error(`\uAE30\uAC04 \uD45C\uAE30\uB97C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${iso}`);
|
|
979
|
+
return Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0) + Number(m[3] ?? 0) / 60 + Number(m[4] ?? 0) / 3600;
|
|
980
|
+
}
|
|
981
|
+
function paramOf(op, id) {
|
|
982
|
+
return op.parameters?.find((p) => p.id === id)?.value;
|
|
983
|
+
}
|
|
984
|
+
function workingTimeOfWeek(calendar, weekStartMs, utcOffsetMinutes) {
|
|
985
|
+
if (!calendar?.length) return { hoursPerWeek: 7 * 24, daysPerWeek: 7 };
|
|
986
|
+
let minutes = 0;
|
|
987
|
+
const touched = /* @__PURE__ */ new Set();
|
|
988
|
+
for (let m = 0; m < 7 * 24 * 60; m++) {
|
|
989
|
+
if (!inWorkCalendarAt(calendar, weekStartMs + m * 6e4, utcOffsetMinutes)) continue;
|
|
990
|
+
minutes++;
|
|
991
|
+
touched.add(Math.floor((m + (utcOffsetMinutes ?? 0)) / (24 * 60)));
|
|
992
|
+
}
|
|
993
|
+
const declared = /* @__PURE__ */ new Set();
|
|
994
|
+
for (const e of calendar) if (e.entryType !== "non-working") for (const d of e.daysOfWeek ?? []) declared.add(d);
|
|
995
|
+
return { hoursPerWeek: minutes / 60, daysPerWeek: declared.size || touched.size };
|
|
996
|
+
}
|
|
997
|
+
function availabilityOf(records) {
|
|
998
|
+
const ratios = records.map((r) => r.mtbfMs && r.mttrMs ? r.mtbfMs / (r.mtbfMs + r.mttrMs) : 1);
|
|
999
|
+
return ratios.length ? ratios.reduce((a, b) => a + b, 0) / ratios.length : 1;
|
|
1000
|
+
}
|
|
1001
|
+
function analyzeCapacity(input) {
|
|
1002
|
+
const { hoursPerWeek, daysPerWeek } = workingTimeOfWeek(input.calendar, input.sampleWeekStartMs, input.utcOffsetMinutes);
|
|
1003
|
+
const demandPerHour = hoursPerWeek > 0 ? input.unitsPerDay * daysPerWeek / hoursPerWeek : 0;
|
|
1004
|
+
const equipmentByKind = /* @__PURE__ */ new Map();
|
|
1005
|
+
for (const e of input.equipment ?? []) {
|
|
1006
|
+
const list = equipmentByKind.get(e.kind) ?? [];
|
|
1007
|
+
list.push(e);
|
|
1008
|
+
equipmentByKind.set(e.kind, list);
|
|
1009
|
+
}
|
|
1010
|
+
const countByClass = (rows, field) => {
|
|
1011
|
+
const out = /* @__PURE__ */ new Map();
|
|
1012
|
+
for (const r of rows ?? []) for (const c of r[field] ?? []) out.set(c, (out.get(c) ?? 0) + 1);
|
|
1013
|
+
return out;
|
|
1014
|
+
};
|
|
1015
|
+
const personsByClass = countByClass(input.persons, "personnelClassIds");
|
|
1016
|
+
const assetsByClass = countByClass(input.assets, "assetClassIds");
|
|
1017
|
+
const slotsByLocationType = /* @__PURE__ */ new Map();
|
|
1018
|
+
for (const l of input.locations ?? [])
|
|
1019
|
+
if (l.type) slotsByLocationType.set(l.type, (slotsByLocationType.get(l.type) ?? 0) + Math.max(l.capacity ?? 1, 1));
|
|
1020
|
+
const order = input.route?.length ? input.route.map((k) => input.operations.find((o) => o.key === k)).filter((o) => !!o) : [...input.operations];
|
|
1021
|
+
let downstream = 1;
|
|
1022
|
+
const reversed = [];
|
|
1023
|
+
for (const op of [...order].reverse()) {
|
|
1024
|
+
const cycleHours = isoDurationHours(op.duration) + isoDurationHours(paramOf(op, "setupDuration"));
|
|
1025
|
+
downstream *= Number(paramOf(op, "yield") ?? 1);
|
|
1026
|
+
const requiredPerHour = downstream > 0 ? demandPerHour / downstream : Infinity;
|
|
1027
|
+
const requirements = [];
|
|
1028
|
+
const add = (axis, className, quantity, available, availability) => {
|
|
1029
|
+
const q = Math.max(quantity, 1);
|
|
1030
|
+
requirements.push({
|
|
1031
|
+
axis,
|
|
1032
|
+
className,
|
|
1033
|
+
quantity: q,
|
|
1034
|
+
available,
|
|
1035
|
+
availability,
|
|
1036
|
+
perHour: cycleHours > 0 ? Math.floor(available / q) / cycleHours * availability : Infinity
|
|
1037
|
+
});
|
|
1038
|
+
};
|
|
1039
|
+
const equipSpecs = op.equipmentSpecification?.length ? op.equipmentSpecification.map((s) => ({ className: s.equipmentClass ?? op.resourceType ?? "", quantity: s.quantity })) : op.resourceType ? [{ className: op.resourceType, quantity: 1 }] : [];
|
|
1040
|
+
for (const s of equipSpecs) {
|
|
1041
|
+
if (!s.className) continue;
|
|
1042
|
+
const records = equipmentByKind.get(s.className) ?? [];
|
|
1043
|
+
add("equipment", s.className, s.quantity, records.length, availabilityOf(records));
|
|
1044
|
+
}
|
|
1045
|
+
for (const s of op.personnelSpecification ?? [])
|
|
1046
|
+
if (s.personnelClass) add("personnel", s.personnelClass, s.quantity, personsByClass.get(s.personnelClass) ?? 0, 1);
|
|
1047
|
+
for (const s of op.physicalAssetSpecification ?? [])
|
|
1048
|
+
if (s.assetClass) add("asset", s.assetClass, s.quantity, assetsByClass.get(s.assetClass) ?? 0, 1);
|
|
1049
|
+
if (op.locationType) add("location", op.locationType, 1, slotsByLocationType.get(op.locationType) ?? 0, 1);
|
|
1050
|
+
const constraint = requirements.length ? requirements.reduce((a, b) => a.perHour <= b.perHour ? a : b) : void 0;
|
|
1051
|
+
const perHour = constraint ? constraint.perHour : Infinity;
|
|
1052
|
+
reversed.push({ operation: op.key, cycleHours, perHour, requiredPerHour, ok: perHour >= requiredPerHour, constraint, requirements });
|
|
1053
|
+
}
|
|
1054
|
+
const operations = reversed.reverse();
|
|
1055
|
+
const tightest = operations.length ? operations.reduce((a, b) => a.perHour / a.requiredPerHour <= b.perHour / b.requiredPerHour ? a : b) : void 0;
|
|
1056
|
+
const headroom = operations.length ? Math.min(...operations.map((o) => o.perHour / o.requiredPerHour)) : Infinity;
|
|
1057
|
+
return {
|
|
1058
|
+
workingHoursPerWeek: hoursPerWeek,
|
|
1059
|
+
workingDaysPerWeek: daysPerWeek,
|
|
1060
|
+
demandPerHour,
|
|
1061
|
+
operations,
|
|
1062
|
+
...tightest ? { bottleneck: { operation: tightest.operation, axis: tightest.constraint?.axis, className: tightest.constraint?.className } } : {},
|
|
1063
|
+
maxUnitsPerDay: input.unitsPerDay * headroom,
|
|
1064
|
+
ok: operations.every((o) => o.ok)
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/wms-profile.ts
|
|
1069
|
+
var BIZSTEP = {
|
|
1070
|
+
receiving: "urn:epcglobal:cbv:bizstep:receiving",
|
|
1071
|
+
storing: "urn:epcglobal:cbv:bizstep:storing",
|
|
1072
|
+
picking: "urn:epcglobal:cbv:bizstep:picking",
|
|
1073
|
+
packing: "urn:epcglobal:cbv:bizstep:packing",
|
|
1074
|
+
staging_outbound: "urn:epcglobal:cbv:bizstep:staging_outbound",
|
|
1075
|
+
shipping: "urn:epcglobal:cbv:bizstep:shipping",
|
|
1076
|
+
replenishing: "urn:epcglobal:cbv:bizstep:replenishing"
|
|
1077
|
+
};
|
|
1078
|
+
var BTT = {
|
|
1079
|
+
po: "urn:epcglobal:cbv:btt:po",
|
|
1080
|
+
so: "urn:epcglobal:cbv:btt:so"
|
|
1081
|
+
};
|
|
1082
|
+
var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship", "vas-station"];
|
|
1083
|
+
var WMS_LOCATION_LEVEL = {
|
|
1084
|
+
dock: "StorageZone",
|
|
1085
|
+
storage: "StorageUnit",
|
|
1086
|
+
staging: "StorageZone",
|
|
1087
|
+
"dock-ship": "StorageZone",
|
|
1088
|
+
"vas-station": "WorkCell"
|
|
1089
|
+
};
|
|
1090
|
+
var WMS_TYPES = [
|
|
1091
|
+
...WMS_LOCATION_TYPES.filter((k) => k !== "vas-station").map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, level: WMS_LOCATION_LEVEL[k], capabilities: ["storable"] })),
|
|
1092
|
+
{ key: "vas-station", role: "location", label: "twin.type.vas-station", standardClass: { epcis: "bizLocation", isa95: "WorkCenter" }, identity: { scheme: "gs1:SGLN" }, level: WMS_LOCATION_LEVEL["vas-station"], capabilities: ["storable", "processable"] },
|
|
1093
|
+
{ key: "forklift", role: "equipment", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] },
|
|
1094
|
+
/** 유통가공 작업자·작업대 설비 — 가공을 수행하는 능동 자원(ISA-95 `Equipment`). */
|
|
1095
|
+
{ key: "packer", role: "equipment", label: "twin.type.packer", standardClass: { epcis: "object", isa95: "Equipment" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
|
|
1096
|
+
];
|
|
1097
|
+
|
|
1098
|
+
// src/yms-profile.ts
|
|
1099
|
+
var YARD_BIZSTEP = {
|
|
1100
|
+
arriving: "urn:epcglobal:cbv:bizstep:arriving",
|
|
1101
|
+
// 게이트-인
|
|
1102
|
+
staging: "urn:epcglobal:cbv:bizstep:staging",
|
|
1103
|
+
// 야드 슬롯 주차·대기
|
|
1104
|
+
loading: "urn:epcglobal:cbv:bizstep:loading",
|
|
1105
|
+
// 도크 작업(상차: 트레일러←화물)
|
|
1106
|
+
unloading: "urn:epcglobal:cbv:bizstep:unloading",
|
|
1107
|
+
// 도크 작업(하차: 트레일러→화물)
|
|
1108
|
+
departing: "urn:epcglobal:cbv:bizstep:departing"
|
|
1109
|
+
// 게이트-아웃
|
|
1110
|
+
};
|
|
1111
|
+
var BTT_DELIVERY = "urn:epcglobal:cbv:btt:deliv";
|
|
1112
|
+
function graiUri(companyPrefix, assetType, serial) {
|
|
1113
|
+
return `urn:epc:id:grai:${companyPrefix}.${assetType}.${String(serial).padStart(6, "0")}`;
|
|
1114
|
+
}
|
|
1115
|
+
var YMS_LOCATION_TYPES = ["gate", "yard-slot", "dock-door", "staging"];
|
|
1116
|
+
var YMS_LOCATION_LEVEL = {
|
|
1117
|
+
gate: "Other",
|
|
1118
|
+
"yard-slot": "StorageUnit",
|
|
1119
|
+
"dock-door": "StorageUnit",
|
|
1120
|
+
staging: "StorageZone"
|
|
1121
|
+
};
|
|
1122
|
+
var YMS_TYPES = [
|
|
1123
|
+
...YMS_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, level: YMS_LOCATION_LEVEL[k], capabilities: ["storable"] })),
|
|
1124
|
+
{ key: "hostler", role: "equipment", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
|
|
1125
|
+
];
|
|
1126
|
+
|
|
1127
|
+
// src/mes-profile.ts
|
|
1128
|
+
var MES_BIZSTEP = {
|
|
1129
|
+
receiving: "urn:epcglobal:cbv:bizstep:receiving",
|
|
1130
|
+
// 원자재 수령
|
|
1131
|
+
producing: "urn:epcglobal:cbv:bizstep:commissioning",
|
|
1132
|
+
// 생산(제품 최초 생성)
|
|
1133
|
+
storing: "urn:epcglobal:cbv:bizstep:storing"
|
|
1134
|
+
// 완제품 저장
|
|
1135
|
+
};
|
|
1136
|
+
var BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
|
|
1137
|
+
function sgtinUri(companyPrefix, itemRef, serial) {
|
|
1138
|
+
return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
|
|
1139
|
+
}
|
|
1140
|
+
var MES_LOCATION_TYPES = ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
|
|
1141
|
+
var MES_LOCATION_CLS = {
|
|
1142
|
+
"raw-store": { epcis: "bizLocation" },
|
|
1143
|
+
"cut-station": { isa95: "WorkCenter", epcis: "bizLocation" },
|
|
1144
|
+
"weld-station": { isa95: "WorkCenter", epcis: "bizLocation" },
|
|
1145
|
+
"paint-booth": { isa95: "WorkCenter", epcis: "bizLocation" },
|
|
1146
|
+
"assembly-line": { isa95: "WorkCenter", epcis: "bizLocation" },
|
|
1147
|
+
"fg-store": { epcis: "bizLocation" }
|
|
1148
|
+
};
|
|
1149
|
+
var MES_LOCATION_LEVEL = {
|
|
1150
|
+
"raw-store": "StorageZone",
|
|
1151
|
+
"cut-station": "WorkCell",
|
|
1152
|
+
"weld-station": "WorkCell",
|
|
1153
|
+
"paint-booth": "WorkCell",
|
|
1154
|
+
"assembly-line": "ProductionLine",
|
|
1155
|
+
"fg-store": "StorageZone"
|
|
1156
|
+
};
|
|
1157
|
+
var MES_TYPES = [
|
|
1158
|
+
...MES_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, level: MES_LOCATION_LEVEL[k], capabilities: ["storable"] })),
|
|
1159
|
+
{ key: "cutter", role: "equipment", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
|
|
1160
|
+
{ key: "welder", role: "equipment", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
|
|
1161
|
+
{ key: "painter", role: "equipment", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
|
|
1162
|
+
{ key: "assembler", role: "equipment", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
|
|
1163
|
+
];
|
|
1164
|
+
|
|
1165
|
+
// src/ems-profile.ts
|
|
1166
|
+
var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
|
|
1167
|
+
var METER_DIRECTION = ["import", "export", "bidirectional"];
|
|
1168
|
+
var EMS_EQUIPMENT_TYPES = ["meter", "breaker", "pv-array", "battery", "utility", "curtailable-load"];
|
|
1169
|
+
var EMS_PROPERTY = {
|
|
1170
|
+
/** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
|
|
1171
|
+
contractKW: "contract.kW",
|
|
1172
|
+
/*
|
|
1173
|
+
* ── 계량 지점의 방향 (2026-08-25) ──────────────────────────────────────────
|
|
1174
|
+
* **요금은 계통 접속점의 순 유입에 매겨진다.** 그러니 그 지점의 계량기가 무엇을 재는지 모르면
|
|
1175
|
+
* 요금을 사실대로 말할 수 없다 — 내보낸 양을 쓴 양으로 세면 계약 대비 판단이 반대로 뒤집힌다.
|
|
1176
|
+
*
|
|
1177
|
+
* 이 축이 없는 동안 계량 지점의 kW 를 전부 부하로 셌다. 상계 거래를 하는 현장, 축전지를 붙인 현장,
|
|
1178
|
+
* 열병합이 있는 현장 — 접속점 계량기가 양쪽을 재는 곳은 늘 있다.
|
|
1179
|
+
*
|
|
1180
|
+
* **발전기를 계량기로 만드는 것과 다른 일이다.** 발전은 설비의 사실이고(`energyGenerating`), 그것을
|
|
1181
|
+
* 계량 지점으로 보내는 것은 여전히 하지 않는다. 이 축은 **접속점 계량기**가 무엇을 재는지 말하는
|
|
1182
|
+
* 자리다.
|
|
1183
|
+
*
|
|
1184
|
+
* 방향은 **관측이 아니라 선언**이다. 표본마다 바뀌는 값이 아니고, 그 계량기가 무엇을 재도록
|
|
1185
|
+
* 설치되었는지는 현장이 안다. 그래서 표본(`EnergyRecord`)에 넣지 않고 자원 속성으로 둔다.
|
|
1186
|
+
*
|
|
1187
|
+
* 값은 계량기 자신의 낱말을 쓴다(적산 레지스터가 그렇게 갈려 있다). §`METER_DIRECTION`.
|
|
1188
|
+
*/
|
|
1189
|
+
meterDirection: "meter.direction",
|
|
1190
|
+
/*
|
|
1191
|
+
* ── 부하 계수 — 시뮬레이션이 전기를 만들 수 있게 (2026-08-14) ───────────────
|
|
1192
|
+
* 「이 설비가 돌면 몇 kW 인가」는 **현장이 아는 값**이다. 그래서 타입 기본값을 두지 않는다:
|
|
1193
|
+
* 선언하지 않은 설비는 부하를 만들지 않는다. 기본값을 두면 아무도 그 수가 짐작인 줄 모른 채
|
|
1194
|
+
* 요금 판정이 그 위에 선다.
|
|
1195
|
+
*/
|
|
1196
|
+
/** 가동 중 소비(kW). */
|
|
1197
|
+
ratedKW: "power.ratedKW",
|
|
1198
|
+
/** 멈춰 있을 때의 소비(kW). 없으면 멈춘 동안을 **비운다** — 0 이라고 주장하지 않는다. */
|
|
1199
|
+
standbyKW: "power.standbyKW",
|
|
1200
|
+
/*
|
|
1201
|
+
* ── 요금 단가 — 수를 금액으로 바꾸는 선언 (2026-08-17) ─────────────────────
|
|
1202
|
+
*
|
|
1203
|
+
* 그동안 트윈은 「최대수요 328kW」·「전력량 1,547kWh」까지 말하고 멈췄다. 그런데 피크를 깎는 일이
|
|
1204
|
+
* 돈이 되는 이유는 **요금이 둘로 나뉘기** 때문이다: 사용량(kWh)에 붙는 요금과 **최대수요(kW)에 붙는
|
|
1205
|
+
* 기본요금**. 단가가 없으면 그 절반을 말할 수 없고, 그래서 「피크를 깎아 얼마를 아끼나」에 답하지 못했다.
|
|
1206
|
+
*
|
|
1207
|
+
* 단가는 **현장의 계약**이다(같은 나라 안에서도 사업자·요금제마다 다르다). 그래서 기본값을 두지
|
|
1208
|
+
* 않는다 — 선언하지 않으면 금액을 계산하지 않는다. 짐작한 단가로 낸 금액은 숫자가 있다는 것만으로
|
|
1209
|
+
* 사람을 결정으로 밀어붙인다.
|
|
1210
|
+
*
|
|
1211
|
+
* 계약전력(`contract.kW`)과 같은 자리(수전)에 선언한다.
|
|
1212
|
+
*/
|
|
1213
|
+
/** 기본요금 단가 — 최대수요 1kW 당(청구 주기 기준). */
|
|
1214
|
+
demandChargePerKW: "tariff.demandChargePerKW",
|
|
1215
|
+
/**
|
|
1216
|
+
* **요금적용전력**(kW) — 기본요금이 실제로 매겨지는 기준. 계약전력과 다른 값이다.
|
|
1217
|
+
*
|
|
1218
|
+
* 기본요금이 걸리는 수는 이 주기에 잰 최대가 아닌 경우가 많다 — 지난 몇 달의 최고를 끌고 가거나,
|
|
1219
|
+
* 약정 용량으로 매기거나, 사업자가 따로 정한다. 그 규칙은 나라와 계약마다 다르므로 커널이 계산하지
|
|
1220
|
+
* 않고 **계산된 결과를 받는다.** 이 값이 있으면 기본요금이 조건부 파생이 아니라 사실이 된다.
|
|
1221
|
+
*
|
|
1222
|
+
* 계약전력(`contract.kW`)을 이 자리에 넣지 말 것. 넘었을 때의 뜻이 다르다 — 계약 초과는 약정
|
|
1223
|
+
* 위반이고, 이쪽은 다음 주기의 기본요금이 오른다는 뜻이다.
|
|
1224
|
+
*/
|
|
1225
|
+
billingDemandKW: "tariff.billingDemandKW",
|
|
1226
|
+
/** 사용량 단가 — 1kWh 당. */
|
|
1227
|
+
energyChargePerKWh: "tariff.energyChargePerKWh",
|
|
1228
|
+
/** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
|
|
1229
|
+
currency: "tariff.currency",
|
|
1230
|
+
/*
|
|
1231
|
+
* ── 축전지의 용량과 방전 정책 (2026-08-17) ─────────────────────────────────
|
|
1232
|
+
*
|
|
1233
|
+
* 「배터리로 피크를 깎는다」를 시뮬이 보이려면 방전을 만들어야 하고, 그것은 **선언에서** 나와야 한다.
|
|
1234
|
+
* 임계·최대율·예비를 우리가 정하면 그 수가 어디서 왔는지 아무도 설명할 수 없다.
|
|
1235
|
+
*
|
|
1236
|
+
* ── 선언한다는 것은 「그 자동화가 있다」는 주장이다 ─────────────────────────
|
|
1237
|
+
* 이 정책을 모델에 적으면 **라이브가 아닌 모든 구동이 그 규칙으로 돈다.** 현장에 BESS 제어기가 없는데
|
|
1238
|
+
* 적으면 파생 부하가 실제보다 낙관적으로 나온다(피크가 깎인 것으로 보인다). 그러니 「도입하면?」을
|
|
1239
|
+
* 묻는 것이라면 선언이 아니라 **what-if 가 덮어쓸 일**이다.
|
|
1240
|
+
*
|
|
1241
|
+
* 용량이 없으면 방전하지 않는다 — 용량을 모르면 「얼마나 버티나」를 답할 수 없고, 버티는 시간을
|
|
1242
|
+
* 모른 채 깎으면 무한히 깎을 수 있다고 주장하는 셈이다.
|
|
1243
|
+
*/
|
|
1244
|
+
/** 축전지 용량(kWh) — SOC 를 에너지로 바꾸는 값. 없으면 방전을 만들지 않는다. */
|
|
1245
|
+
capacityKWh: "storage.capacityKWh",
|
|
1246
|
+
/** 이 순수요를 넘으면 방전한다(kW) — 피크 억제 임계. */
|
|
1247
|
+
dischargeAboveKW: "dispatch.dischargeAboveKW",
|
|
1248
|
+
/** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
|
|
1249
|
+
maxDischargeKW: "dispatch.maxDischargeKW",
|
|
1250
|
+
/** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
|
|
1251
|
+
reserveSoc: "dispatch.reserveSoc",
|
|
1252
|
+
/**
|
|
1253
|
+
* 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
|
|
1254
|
+
*
|
|
1255
|
+
* 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
|
|
1256
|
+
* 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
|
|
1257
|
+
* 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
|
|
1258
|
+
*
|
|
1259
|
+
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
|
|
1260
|
+
*/
|
|
1261
|
+
initialSoc: "storage.initialSoc",
|
|
1262
|
+
/*
|
|
1263
|
+
* ── 발전 — 태양광이 서 있기만 하던 자리 (2026-08-18) ────────────────────────
|
|
1264
|
+
*
|
|
1265
|
+
* 계통도에 태양광을 그려 놓고 시뮬에서는 한 톨도 만들지 못했다. 미러는 원천이 발전량을 보내 주지만,
|
|
1266
|
+
* 시뮬 트윈에는 그것을 만들 근거가 없었다 — 그래서 「태양광을 늘리면 피크가 얼마나 내려가나」를 물을 수
|
|
1267
|
+
* 없었다(what-if 의 값이 절반만 성립했다).
|
|
1268
|
+
*
|
|
1269
|
+
* **형상을 우리가 지어내지 않는다.** 위도·계절·날씨로 곡선을 만들면 그 수가 어디서 왔는지 아무도
|
|
1270
|
+
* 설명할 수 없고, 흐린 날 현장의 실적과 어긋난다. 그래서 현장이 **하루 형상을 적는다**: 24개 비율
|
|
1271
|
+
* (0~1)이면 그것이 그 현장의 곡선이다(측정한 형상을 그대로 붙일 수 있다).
|
|
1272
|
+
*
|
|
1273
|
+
* 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
|
|
1274
|
+
*/
|
|
1275
|
+
/**
|
|
1276
|
+
* 발전 정격(kW) — **교류 쪽 최대 출력**. 계통으로 나가는 값이다.
|
|
1277
|
+
*
|
|
1278
|
+
* 이용률의 분모가 이 값이다(만든 양 ÷ 정격 × 시간). **직류 쪽 정격(§`genRatedKWdc`)과 다르고 더 작다** —
|
|
1279
|
+
* 두 값을 한 이름에 담으면 이용률이 그 차이만큼 틀리고, 틀린 이유가 아무 데도 표시되지 않는다.
|
|
1280
|
+
*
|
|
1281
|
+
* 설비마다 선언하거나, 설비별 값을 모르는 현장은 **자리(수전)에 합계로** 선언한다.
|
|
1282
|
+
*/
|
|
1283
|
+
genRatedKW: "generation.ratedKW",
|
|
1284
|
+
/**
|
|
1285
|
+
* 발전 정격(kW) — **직류 쪽**. 변환기(인버터)의 입력 쪽 정격이고, 태양광이면 패널 정격의 합이다.
|
|
1286
|
+
*
|
|
1287
|
+
* 이용률의 분모로 쓰지 않는다. 이 값이 교류 정격보다 큰 것은 설계이고(변환기를 그렇게 고른다),
|
|
1288
|
+
* 그 차이가 「맑은 정오에도 교류 출력이 더 오르지 않는 이유」를 설명한다.
|
|
1289
|
+
*/
|
|
1290
|
+
genRatedKWdc: "generation.ratedKWdc",
|
|
1291
|
+
/**
|
|
1292
|
+
* 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
|
|
1293
|
+
*
|
|
1294
|
+
* 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
|
|
1295
|
+
*/
|
|
1296
|
+
genDailyProfile: "generation.dailyProfile"
|
|
1297
|
+
};
|
|
1298
|
+
var EMS_PROPERTY_SPEC = {
|
|
1299
|
+
[EMS_PROPERTY.contractKW]: { uom: "kW", dataType: "xs:double", note: "contracted power at the metering point, in kW." },
|
|
1300
|
+
/* 값이 셋 중 하나여야 한다 — 그 밖의 낱말은 받지 않는다(뜻이 통할 것 같은 말도 받지 않는다). */
|
|
1301
|
+
[EMS_PROPERTY.meterDirection]: {
|
|
1302
|
+
dataType: "xs:string",
|
|
1303
|
+
note: 'what this metering point measures: "import" (drawn from the grid), "export" (generation sent out), or "bidirectional" (one meter for both, sign carries the direction). Left undeclared, the point counts as load \u2014 leaving it out never makes demand look smaller than it is.'
|
|
1304
|
+
},
|
|
1305
|
+
[EMS_PROPERTY.ratedKW]: { uom: "kW", dataType: "xs:double", note: "power drawn while running, in kW." },
|
|
1306
|
+
[EMS_PROPERTY.standbyKW]: { uom: "kW", dataType: "xs:double", note: "power drawn while idle, in kW." },
|
|
1307
|
+
[EMS_PROPERTY.demandChargePerKW]: { dataType: "xs:double", note: "demand charge per kW of billing-period peak, in the declared currency." },
|
|
1308
|
+
[EMS_PROPERTY.billingDemandKW]: {
|
|
1309
|
+
uom: "kW",
|
|
1310
|
+
dataType: "xs:double",
|
|
1311
|
+
note: "billing demand in kW \u2014 the basis the demand charge is actually billed on (often a rolling 12-month peak). Not the contracted power."
|
|
1312
|
+
},
|
|
1313
|
+
[EMS_PROPERTY.energyChargePerKWh]: { dataType: "xs:double", note: "energy charge per kWh, in the declared currency." },
|
|
1314
|
+
[EMS_PROPERTY.currency]: { dataType: "xs:string", note: "ISO 4217 currency code, e.g. USD or KRW." },
|
|
1315
|
+
[EMS_PROPERTY.capacityKWh]: { uom: "kWh", dataType: "xs:double", note: "usable energy of the storage, in kWh." },
|
|
1316
|
+
[EMS_PROPERTY.dischargeAboveKW]: { uom: "kW", dataType: "xs:double", note: "net demand above which the storage discharges, in kW." },
|
|
1317
|
+
[EMS_PROPERTY.maxDischargeKW]: { uom: "kW", dataType: "xs:double", note: "inverter limit on discharge rate, in kW." },
|
|
1318
|
+
/* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
|
|
1319
|
+
[EMS_PROPERTY.reserveSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below." },
|
|
1320
|
+
[EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." },
|
|
1321
|
+
[EMS_PROPERTY.genRatedKW]: {
|
|
1322
|
+
uom: "kW",
|
|
1323
|
+
dataType: "xs:double",
|
|
1324
|
+
note: "rated AC generation output, in kW \u2014 the denominator of capacity factor. Declare per equipment, or on the incoming location as a site total."
|
|
1325
|
+
},
|
|
1326
|
+
[EMS_PROPERTY.genRatedKWdc]: {
|
|
1327
|
+
uom: "kW",
|
|
1328
|
+
dataType: "xs:double",
|
|
1329
|
+
note: "rated DC generation capacity, in kW (panel rating for PV). Larger than the AC rating by design; not the capacity-factor denominator."
|
|
1330
|
+
},
|
|
1331
|
+
[EMS_PROPERTY.genDailyProfile]: {
|
|
1332
|
+
dataType: "xs:string",
|
|
1333
|
+
note: "daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time \u2014 the site declares its own curve; we do not invent one."
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
function generationFractionAt(profile, hourOfDay) {
|
|
1337
|
+
const parts = String(profile ?? "").split(",").map((v2) => v2.trim()).filter((v2) => v2 !== "");
|
|
1338
|
+
if (parts.length !== 24) return void 0;
|
|
1339
|
+
const h = Math.floor(hourOfDay);
|
|
1340
|
+
if (!Number.isFinite(h) || h < 0 || h > 23) return void 0;
|
|
1341
|
+
const v = Number(parts[h]);
|
|
1342
|
+
if (!Number.isFinite(v) || v < 0 || v > 1) return void 0;
|
|
1343
|
+
return v;
|
|
1344
|
+
}
|
|
1345
|
+
var EMS_TYPES = [
|
|
1346
|
+
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
1347
|
+
{
|
|
1348
|
+
key: "incoming",
|
|
1349
|
+
role: "location",
|
|
1350
|
+
/* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
|
|
1351
|
+
level: "Other",
|
|
1352
|
+
label: "twin.type.incoming",
|
|
1353
|
+
/* 수전 지점 — 계약전력이 걸리는 자리이고, 요금의 근거가 되는 수요는 여기서 잰다.
|
|
1354
|
+
ISO 50001 의 「에너지 유입」 경계이기도 하다(조직의 에너지 검토가 여기서 시작한다). */
|
|
1355
|
+
standardClass: { iec61850: "MMTR", iso50001: "EnergyInput" },
|
|
1356
|
+
identity: { scheme: "kernel:id" },
|
|
1357
|
+
capabilities: ["metered"]
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
key: "feeder",
|
|
1361
|
+
role: "location",
|
|
1362
|
+
/* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
|
|
1363
|
+
level: "Other",
|
|
1364
|
+
label: "twin.type.feeder",
|
|
1365
|
+
/* 분기 회로 — 부하 분해의 단위. 계약전력의 하위 배분이 여기서 정해진다. */
|
|
1366
|
+
standardClass: { iec61850: "Feeder", iso50001: "EnergyUse" },
|
|
1367
|
+
identity: { scheme: "kernel:id" },
|
|
1368
|
+
capabilities: ["metered"]
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
key: "submeter-zone",
|
|
1372
|
+
role: "location",
|
|
1373
|
+
/* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
|
|
1374
|
+
level: "Other",
|
|
1375
|
+
label: "twin.type.submeter-zone",
|
|
1376
|
+
/*
|
|
1377
|
+
* 구역 계량 — 여러 부하를 한 계량기로 묶어 재는 자리(공조·조명처럼 개별 계량이 없는 것들).
|
|
1378
|
+
*
|
|
1379
|
+
* ISO 50001 의 **SEU**(유의 에너지 사용처)가 대개 이 알갱이다. 개별 설비까지 재지 못하는 현장이
|
|
1380
|
+
* 많고, 그것을 「모른다」로 두는 대신 **묶음으로 아는 것**이 정직하다.
|
|
1381
|
+
*/
|
|
1382
|
+
standardClass: { iec61850: "MMXU", iso50001: "SEU" },
|
|
1383
|
+
identity: { scheme: "kernel:id" },
|
|
1384
|
+
capabilities: ["metered"]
|
|
1385
|
+
},
|
|
1386
|
+
/* ── 설비: 계량 지점과 에너지 자원 ──────────────────────────────────────── */
|
|
1387
|
+
{
|
|
1388
|
+
key: "meter",
|
|
1389
|
+
role: "equipment",
|
|
1390
|
+
label: "twin.type.meter",
|
|
1391
|
+
/* 계량기 — 측정 논리 노드(MMXU=측정단위, MMTR=적산). 자산으로도 하나다(ISO 55000). */
|
|
1392
|
+
standardClass: { iec61850: "MMXU", iso55000: "Asset", iso50001: "MeasurementPoint" },
|
|
1393
|
+
identity: { scheme: "kernel:id" },
|
|
1394
|
+
capabilities: ["metered", "operable"]
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
key: "breaker",
|
|
1398
|
+
role: "equipment",
|
|
1399
|
+
label: "twin.type.breaker",
|
|
1400
|
+
/*
|
|
1401
|
+
* 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
|
|
1402
|
+
* 상태를 읽어 계통 구성을 알 뿐이다.
|
|
1403
|
+
*
|
|
1404
|
+
* 능력은 `switching` 이다 — `operable` 이 아니다(2026-08-14). `operable` 이던 동안 저작면이 차단기에
|
|
1405
|
+
* **제어 컴포넌트**를 권했다: 우리가 하지 않기로 한 조작을 화면이 부추긴 것이다. 「위치를 읽는다」와
|
|
1406
|
+
* 「명령을 받는다」는 다른 능력이고, 이제 그 둘이 갈려 있다(capability.ts 주석).
|
|
1407
|
+
*/
|
|
1408
|
+
standardClass: { iec61850: "XCBR", iso55000: "Asset" },
|
|
1409
|
+
identity: { scheme: "kernel:id" },
|
|
1410
|
+
capabilities: ["switching"]
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
key: "pv-array",
|
|
1414
|
+
role: "equipment",
|
|
1415
|
+
label: "twin.type.pv-array",
|
|
1416
|
+
/* 태양광 어레이 — IEC 61850-7-420(분산자원)의 `DPVA`. 발전과 계량은 다른 능력이다. */
|
|
1417
|
+
standardClass: { iec61850: "DPVA", iso55000: "Asset", iso50001: "RenewableSupply" },
|
|
1418
|
+
identity: { scheme: "kernel:id" },
|
|
1419
|
+
capabilities: ["energyGenerating", "metered", "operable"]
|
|
1420
|
+
},
|
|
1421
|
+
{
|
|
1422
|
+
key: "battery",
|
|
1423
|
+
role: "equipment",
|
|
1424
|
+
label: "twin.type.battery",
|
|
1425
|
+
/* 축전지(ESS) — `ZBAT`. 충전·방전을 나눠 재고, 저장은 보관(`storable`)이 아니다(물건이 아니다). */
|
|
1426
|
+
standardClass: { iec61850: "ZBAT", iso55000: "Asset" },
|
|
1427
|
+
identity: { scheme: "kernel:id" },
|
|
1428
|
+
capabilities: ["energyStoring", "metered", "operable"]
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
key: "utility",
|
|
1432
|
+
role: "equipment",
|
|
1433
|
+
label: "twin.type.utility",
|
|
1434
|
+
/*
|
|
1435
|
+
* 공통 설비 — 공조·컴프레서·칠러·조명·폐수처리처럼 **어느 공정에도 귀속되지 않는** 소비처.
|
|
1436
|
+
*
|
|
1437
|
+
* ── 왜 따로 있나 (2026-08-14) ────────────────────────────────────────────
|
|
1438
|
+
* 물류·생산 트윈에서 이런 것들은 **설비가 아니다**(공정에 매핑되지 않으므로 자원 축에 없다).
|
|
1439
|
+
* 그런데 에너지에서는 소비의 절반을 차지하고 감축 후보 1순위다 — 담을 자리가 반드시 있어야 한다.
|
|
1440
|
+
*
|
|
1441
|
+
* 처음에는 `curtailable-load` 하나로 받으려 했다. 그런데 그 이름은 **「줄일 수 있다」고 주장**한다:
|
|
1442
|
+
* 폐수처리·방폭 환기·서버실 냉방은 공통이지만 줄일 수 없고, 그것을 감축 가능으로 두면 트윈이
|
|
1443
|
+
* 「이걸 줄이면 됩니다」라는 거짓 제안을 한다. 공통성과 감축 가능성은 **다른 축**이다.
|
|
1444
|
+
*
|
|
1445
|
+
* 표준: IEC 61850 에 「공통 설비」라는 논리 노드는 없다(설비 종류마다 다른 노드다) — 비운다.
|
|
1446
|
+
* ISO 50001 의 SEU 는 이 부류를 가장 많이 가리킨다(유의 에너지 사용처).
|
|
1447
|
+
*/
|
|
1448
|
+
standardClass: { iso50001: "SEU", iso55000: "Asset" },
|
|
1449
|
+
identity: { scheme: "kernel:id" },
|
|
1450
|
+
capabilities: ["metered", "operable"]
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
key: "curtailable-load",
|
|
1454
|
+
role: "equipment",
|
|
1455
|
+
label: "twin.type.curtailable-load",
|
|
1456
|
+
/*
|
|
1457
|
+
* 감축 가능 부하 — 줄일 수 있는 소비처(공조·충전기·비상시 미가동 라인).
|
|
1458
|
+
*
|
|
1459
|
+
* 표준에 이 이름은 없다: IEC 61850 은 설비를 종류로 부르고 「감축 가능」은 **운영 정책**이다.
|
|
1460
|
+
* 그래서 `iec61850` 칸을 비우고 ISO 50001 의 SEU 로만 대응한다 — 억지로 논리 노드를 적으면
|
|
1461
|
+
* 적합성 표가 거짓을 말한다.
|
|
1462
|
+
*/
|
|
1463
|
+
standardClass: { iso50001: "SEU" },
|
|
1464
|
+
identity: { scheme: "kernel:id" },
|
|
1465
|
+
capabilities: ["curtailable", "metered", "operable"]
|
|
1466
|
+
}
|
|
1467
|
+
];
|
|
1468
|
+
function electricalUpstreamOf(locations, id) {
|
|
1469
|
+
const byId = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
|
|
1470
|
+
const self = byId.get(String(id));
|
|
1471
|
+
if (!self) return void 0;
|
|
1472
|
+
if (self.upstreamId) return String(self.upstreamId);
|
|
1473
|
+
const parent = self.parentId ? byId.get(String(self.parentId)) : void 0;
|
|
1474
|
+
if (!parent) return void 0;
|
|
1475
|
+
return isElectricalLocationType(String(parent.type ?? "")) ? String(parent.id) : void 0;
|
|
1476
|
+
}
|
|
1477
|
+
function isElectricalLocationType(type) {
|
|
1478
|
+
return EMS_LOCATION_TYPES.includes(type);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/domain-catalog.ts
|
|
1482
|
+
var locationKeys = (types) => types.filter((t) => t.role === "location").map((t) => t.key);
|
|
1483
|
+
var DOMAIN_CATALOG = {
|
|
1484
|
+
// label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
|
|
1485
|
+
wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, locationTypes: locationKeys(WMS_TYPES) },
|
|
1486
|
+
yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
|
|
1487
|
+
mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) },
|
|
1488
|
+
ems: { system: "ems", label: "twin.system.ems", types: EMS_TYPES, locationTypes: locationKeys(EMS_TYPES) }
|
|
1489
|
+
};
|
|
1490
|
+
var LOGISTICS = ["wms", "yms", "mes"];
|
|
1491
|
+
var TWIN_AXES = [
|
|
1492
|
+
{
|
|
1493
|
+
axis: "locations",
|
|
1494
|
+
path: "locations",
|
|
1495
|
+
label: "twin.axis.locations",
|
|
1496
|
+
kind: "instance",
|
|
1497
|
+
typeRole: "location",
|
|
1498
|
+
standardClass: { epcis: "SGLN" }
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
axis: "equipment",
|
|
1502
|
+
path: "equipment",
|
|
1503
|
+
label: "twin.axis.equipment",
|
|
1504
|
+
kind: "instance",
|
|
1505
|
+
typeRole: "equipment",
|
|
1506
|
+
standardClass: { isa95: "Equipment", iso55000: "Asset", epcis: "GIAI" }
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
axis: "persons",
|
|
1510
|
+
path: "persons",
|
|
1511
|
+
label: "twin.axis.persons",
|
|
1512
|
+
kind: "instance",
|
|
1513
|
+
standardClass: { isa95: "Person" },
|
|
1514
|
+
systems: LOGISTICS
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
axis: "assets",
|
|
1518
|
+
path: "assets",
|
|
1519
|
+
label: "twin.axis.assets",
|
|
1520
|
+
kind: "instance",
|
|
1521
|
+
standardClass: { isa95: "PhysicalAsset", iso55000: "Asset" }
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
axis: "personnelClasses",
|
|
1525
|
+
path: "personnelClasses",
|
|
1526
|
+
label: "twin.axis.personnelClasses",
|
|
1527
|
+
kind: "class",
|
|
1528
|
+
standardClass: { isa95: "PersonnelClass" },
|
|
1529
|
+
systems: LOGISTICS
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
axis: "equipmentClasses",
|
|
1533
|
+
path: "equipmentClasses",
|
|
1534
|
+
label: "twin.axis.equipmentClasses",
|
|
1535
|
+
kind: "class",
|
|
1536
|
+
standardClass: { isa95: "EquipmentClass" }
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
axis: "assetClasses",
|
|
1540
|
+
path: "assetClasses",
|
|
1541
|
+
label: "twin.axis.assetClasses",
|
|
1542
|
+
kind: "class",
|
|
1543
|
+
standardClass: { isa95: "PhysicalAssetClass" }
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
axis: "materialDefinitions",
|
|
1547
|
+
path: "materialDefinitions",
|
|
1548
|
+
label: "twin.axis.materialDefinitions",
|
|
1549
|
+
kind: "class",
|
|
1550
|
+
standardClass: { isa95: "MaterialDefinition" },
|
|
1551
|
+
systems: LOGISTICS
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
axis: "materialClasses",
|
|
1555
|
+
path: "materialClasses",
|
|
1556
|
+
label: "twin.axis.materialClasses",
|
|
1557
|
+
kind: "class",
|
|
1558
|
+
standardClass: { isa95: "MaterialClass" },
|
|
1559
|
+
systems: LOGISTICS
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
axis: "operations",
|
|
1563
|
+
path: "operations",
|
|
1564
|
+
label: "twin.axis.operations",
|
|
1565
|
+
kind: "spec",
|
|
1566
|
+
standardClass: { isa95: "OperationsSegment" },
|
|
1567
|
+
systems: LOGISTICS
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
axis: "productionSpec",
|
|
1571
|
+
path: "productionSpec",
|
|
1572
|
+
label: "twin.axis.productionSpec",
|
|
1573
|
+
kind: "spec",
|
|
1574
|
+
standardClass: { isa95: "OperationsSegment" },
|
|
1575
|
+
systems: LOGISTICS
|
|
1576
|
+
},
|
|
1577
|
+
/*
|
|
1578
|
+
* 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
|
|
1579
|
+
* `materials` 가 최상위 `materialDefinitions` 와 **둘 다** 있는 것은 지금 상태 그대로다 —
|
|
1580
|
+
* 전자는 도메인 정의의 가벼운 목록(`{key,label}`), 후자는 ISA-95 풍부형이다. 같은 것의 집이
|
|
1581
|
+
* 둘이라는 사실을 **선언이 드러낸다**(감추면 화면이 어느 쪽을 보는지 아무도 모른다).
|
|
1582
|
+
*/
|
|
1583
|
+
{
|
|
1584
|
+
axis: "recipes",
|
|
1585
|
+
path: "productionSpec.definition.recipes",
|
|
1586
|
+
label: "twin.axis.recipes",
|
|
1587
|
+
kind: "spec",
|
|
1588
|
+
standardClass: { isa95: "OperationsSegment" },
|
|
1589
|
+
systems: LOGISTICS
|
|
1590
|
+
},
|
|
1591
|
+
{
|
|
1592
|
+
axis: "routes",
|
|
1593
|
+
path: "productionSpec.definition.routes",
|
|
1594
|
+
label: "twin.axis.routes",
|
|
1595
|
+
kind: "spec",
|
|
1596
|
+
standardClass: { isa95: "OperationsSegment" },
|
|
1597
|
+
systems: LOGISTICS
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
axis: "materials",
|
|
1601
|
+
path: "productionSpec.definition.materials",
|
|
1602
|
+
label: "twin.axis.materials",
|
|
1603
|
+
kind: "class",
|
|
1604
|
+
standardClass: { isa95: "MaterialDefinition" },
|
|
1605
|
+
systems: LOGISTICS
|
|
1606
|
+
},
|
|
1607
|
+
/*
|
|
1608
|
+
* 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
|
|
1609
|
+
*
|
|
1610
|
+
* 이 축이 없던 동안 `testSpecificationIds` 는 허공을 가리켰다(값은 실리는데 무엇인지 아무도 모른다).
|
|
1611
|
+
* 축이 서면 인스펙션의 선 채움이 **끊어진 참조를 센다** — 없는 명세를 가리키면 화면이 말한다.
|
|
1612
|
+
*
|
|
1613
|
+
* `kind: 'spec'` 이다: 자원도 등급도 아니고 **판정의 정의**다(공정·레시피와 같은 부류).
|
|
1614
|
+
*/
|
|
1615
|
+
{
|
|
1616
|
+
axis: "testSpecifications",
|
|
1617
|
+
path: "testSpecifications",
|
|
1618
|
+
label: "twin.axis.testSpecifications",
|
|
1619
|
+
kind: "spec",
|
|
1620
|
+
standardClass: { isa95: "TestSpecification" }
|
|
1621
|
+
},
|
|
1622
|
+
/*
|
|
1623
|
+
* ── ISA-95 Part 4 — 일정과 실적 ────────────────────────────────────────────────
|
|
1624
|
+
*
|
|
1625
|
+
* 여기부터는 **선언이 아니다.** 오더와 작업은 사람이 board 에 적어 두는 값이 아니라 현장이 낳는
|
|
1626
|
+
* 사실이고, 그 집은 커널 상태와 저널이다. 그래서 `source` 가 `'state'` 이고 `path` 가 없다.
|
|
1627
|
+
*
|
|
1628
|
+
* ── 왜 축이어야 하나 ──────────────────────────────────────────────────────────
|
|
1629
|
+
* 이 둘이 축이 아니던 동안 "이 트윈이 표준의 어디를 채우나" 라는 질문은 **절반만** 답했다. Part 2
|
|
1630
|
+
* 자원 모델은 여덟이 완전한데 Part 4 는 통째로 비어 있었다 — 런타임에는 실재하는데 조회 모델에는
|
|
1631
|
+
* 없었기 때문이다. 실적을 성과 화면이 답한다는 것은 사실이지만, 그건 **집계**이고 개념 자체를
|
|
1632
|
+
* 가리켜 걸어 들어갈 자리는 없었다(어느 오더가 어느 공정에서 무엇을 먹었나).
|
|
1633
|
+
*
|
|
1634
|
+
* ── 왜 테이블로 투영하지 않나 ─────────────────────────────────────────────────
|
|
1635
|
+
* 정본이 저널이다. 구조 엔티티는 **원본이 낼 수 있는 것만** 담고(ADR-0032-D), 오더는 원본이 낸
|
|
1636
|
+
* 것이지만 트윈이 **관측한** 것이다. 투영은 규모에서 성능이 필요해질 때 KPI 폴드와 같은 급의
|
|
1637
|
+
* 별도 결정으로 한다.
|
|
1638
|
+
*/
|
|
1639
|
+
{
|
|
1640
|
+
axis: "orders",
|
|
1641
|
+
label: "twin.axis.orders",
|
|
1642
|
+
kind: "instance",
|
|
1643
|
+
source: "state",
|
|
1644
|
+
historical: true,
|
|
1645
|
+
standardClass: { isa95: "OperationsRequest", epcis: "TransactionEvent" },
|
|
1646
|
+
systems: LOGISTICS
|
|
1647
|
+
},
|
|
1648
|
+
{
|
|
1649
|
+
axis: "tasks",
|
|
1650
|
+
label: "twin.axis.tasks",
|
|
1651
|
+
kind: "instance",
|
|
1652
|
+
source: "state",
|
|
1653
|
+
historical: true,
|
|
1654
|
+
standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" },
|
|
1655
|
+
systems: LOGISTICS
|
|
1656
|
+
},
|
|
1657
|
+
/*
|
|
1658
|
+
* ── 물품이 축이 아니었다 (2026-08-22) ──────────────────────────────────────
|
|
1659
|
+
* 트윈에서 **수가 가장 많은 것**이 물품인데(실측: 엔티티 3,611 중 2,400 · hatio-us 는 2,805) 그것을
|
|
1660
|
+
* 가리켜 걸어 들어갈 자리가 없었다. 지도와 집약 태그에는 이미 보이는데 「모델 살펴보기」에는 문이
|
|
1661
|
+
* 없었다 — 사용자가 트윈에 가장 자주 묻는 것이 「내 물건이 어디 있나」이므로 그것은 접근 장벽이다.
|
|
1662
|
+
*
|
|
1663
|
+
* 성격은 `orders`·`tasks` 와 같다: 상태에 살고, 저널에 이력이 있고, 원본이 낸 것을 트윈이 관측한다.
|
|
1664
|
+
* 그래서 같은 조합(`instance` · `state` · `historical`)이다.
|
|
1665
|
+
*
|
|
1666
|
+
* ── 표준 대응 ─────────────────────────────────────────────────────────────
|
|
1667
|
+
* ISA-95 는 `MaterialLot` 이다 — 물품은 「무슨 품목인가」(정의)가 아니라 「그 품목의 이 덩어리」이고,
|
|
1668
|
+
* 위치·수량·부분(`MaterialSubLot`)을 그 자리가 든다. EPCIS 는 `ObjectEvent` 다: 개체가 생기고
|
|
1669
|
+
* 관측되고 사라지는 것을 그 사건이 말한다.
|
|
1670
|
+
*
|
|
1671
|
+
* ── 무엇이 이 항목을 가리키나 ─────────────────────────────────────────────
|
|
1672
|
+
* `subLotId ?? epc` 다. 짐작에 맡기면 `gtin` 으로 떨어지고, 그러면 **같은 품목의 물품 전부가 한
|
|
1673
|
+
* 식별자로 뭉친다** — 화면이 2,400개를 몇 개로 보인다.
|
|
1674
|
+
*/
|
|
1675
|
+
{
|
|
1676
|
+
axis: "items",
|
|
1677
|
+
label: "twin.axis.items",
|
|
1678
|
+
kind: "instance",
|
|
1679
|
+
source: "state",
|
|
1680
|
+
historical: true,
|
|
1681
|
+
idField: ["subLotId", "epc"],
|
|
1682
|
+
standardClass: { isa95: "MaterialLot", epcis: "ObjectEvent" },
|
|
1683
|
+
systems: LOGISTICS
|
|
1684
|
+
},
|
|
1685
|
+
/*
|
|
1686
|
+
* ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
|
|
1687
|
+
*
|
|
1688
|
+
* 에너지 트윈의 개체 대부분은 **이미 있는 축**이 답한다: 전기 구간은 `locations`, 계량기·차단기·
|
|
1689
|
+
* 태양광·축전지·감축 부하는 `equipment` 다(카탈로그가 그 타입들을 그 역할로 선언한다). 그것들을
|
|
1690
|
+
* 새 축으로 다시 세우면 같은 것이 두 곳에서 세어진다 — 개념 지도가 계량기를 두 번 보여 준다.
|
|
1691
|
+
*
|
|
1692
|
+
* 정말로 새로운 것은 **수요 구간**이다: 자원이 아니고, 선언이 아니고, 15분마다 닫히는 **사실**이다.
|
|
1693
|
+
* 그것이 요금의 알갱이이고 피크의 근거다(피크는 마감된 구간의 최대이므로 파생이다 — 축이 아니다).
|
|
1694
|
+
*
|
|
1695
|
+
* ── 표준 칸을 비운다 ──────────────────────────────────────────────────────
|
|
1696
|
+
* 15분 수요 구간은 **요금 제도의 알갱이**다(계약·TOU). ISO 50001 은 경영 체계를, IEC 61850 은 설비
|
|
1697
|
+
* 데이터 모델을 말하고, 둘 다 이 구간을 정의하지 않는다. 가까운 이름을 적으면 적합성 표가 거짓을
|
|
1698
|
+
* 말하므로 비워 둔다 — 「표준에 자리가 없으면 빈 객체」라는 이 선언의 규율 그대로다.
|
|
1699
|
+
*
|
|
1700
|
+
* 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
|
|
1701
|
+
* 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
|
|
1702
|
+
*/
|
|
1703
|
+
/*
|
|
1704
|
+
* **무엇이 이 항목을 가리키나** — `idField` 가 말한다.
|
|
1705
|
+
*
|
|
1706
|
+
* 수요 구간에는 `id` 가 없다. 그것이 결함은 아니다: 구간의 정체성은 **그 구간이 언제 시작했나**이고
|
|
1707
|
+
* (요금의 알갱이가 그 시각으로 정해진다) 커널도 그것으로 키를 만든다(`contract-projected-over:<startMs>`).
|
|
1708
|
+
*
|
|
1709
|
+
* 그런데 소비처가 `id`·`key`·`gtin` 을 **짐작**하고 있어서, 화면이 여섯 구간 모두를 「식별자 없음 —
|
|
1710
|
+
* 참조할 수 없는 항목」으로 보였다. 값은 실재하는데 가리킬 수 없다고 말한 것이다. 짐작을 없애고
|
|
1711
|
+
* 선언이 답하게 한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
|
|
1712
|
+
*/
|
|
1713
|
+
{
|
|
1714
|
+
axis: "demandWindows",
|
|
1715
|
+
path: "energy.closed",
|
|
1716
|
+
idField: "startMs",
|
|
1717
|
+
label: "twin.axis.demandWindows",
|
|
1718
|
+
kind: "instance",
|
|
1719
|
+
source: "state",
|
|
1720
|
+
historical: true,
|
|
1721
|
+
standardClass: {},
|
|
1722
|
+
systems: ["ems"]
|
|
1723
|
+
}
|
|
1724
|
+
];
|
|
1725
|
+
var TWIN_RELATIONS = [
|
|
1726
|
+
/* 자리가 속한 구역 — **board 밖**(호스트의 `TwinArea`). 해소는 호스트가 한다. */
|
|
1727
|
+
{ from: "locations", field: "parentId", target: { kind: "external", entity: "space.area" }, via: "twin.rel.area", optional: true },
|
|
1728
|
+
/* 자원이 사는 자리 */
|
|
1729
|
+
{ from: "equipment", field: "homeLocation", target: { kind: "axis", axis: "locations" }, via: "twin.rel.home", optional: true },
|
|
1730
|
+
{ from: "persons", field: "homeLocation", target: { kind: "axis", axis: "locations" }, via: "twin.rel.home", optional: true },
|
|
1731
|
+
{ from: "assets", field: "homeLocation", target: { kind: "axis", axis: "locations" }, via: "twin.rel.home", optional: true },
|
|
1732
|
+
/* 자원의 등급 — 설비만 `kind`(타입)로 가고 사람·자산은 등급 목록으로 간다. 표준의 비대칭 그대로. */
|
|
1733
|
+
{ from: "persons", field: "personnelClassIds[]", target: { kind: "axis", axis: "personnelClasses" }, via: "twin.rel.class", optional: true },
|
|
1734
|
+
{ from: "assets", field: "assetClassIds[]", target: { kind: "axis", axis: "assetClasses" }, via: "twin.rel.class", optional: true },
|
|
1735
|
+
{ from: "equipment", field: "kind", target: { kind: "type", role: "equipment" }, via: "twin.rel.type" },
|
|
1736
|
+
{ from: "locations", field: "type", target: { kind: "type", role: "location" }, via: "twin.rel.type" },
|
|
1737
|
+
/* 생산 — 레시피에서 시작해 라우트·공정·품목으로 퍼진다 */
|
|
1738
|
+
{ from: "recipes", field: "route", target: { kind: "axis", axis: "routes" }, via: "twin.rel.route", optional: true },
|
|
1739
|
+
{ from: "recipes", field: "inputs[].material", target: { kind: "axis", axis: "materials" }, via: "twin.rel.input" },
|
|
1740
|
+
{ from: "recipes", field: "outputs[].material", target: { kind: "axis", axis: "materials" }, via: "twin.rel.output" },
|
|
1741
|
+
{ from: "routes", field: "steps[]", target: { kind: "axis", axis: "operations" }, via: "twin.rel.step" },
|
|
1742
|
+
/*
|
|
1743
|
+
* 일정·실적의 관계 — **관측 관계다.** 선언 관계는 "그렇게 만들기로 했다" 이고, 이것은 "그렇게
|
|
1744
|
+
* 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 연결되지 않은 카드가 된다(그러면 걸어 들어갈 수 없다).
|
|
1745
|
+
*/
|
|
1746
|
+
/*
|
|
1747
|
+
* 오더가 무엇을 만드나 — **자재 키가 아니라 GS1 품목 참조다**(`lines[].gtin`). 자재와 이어 주는
|
|
1748
|
+
* 것은 `productionSpec.binding` 이고 그 해소는 호스트가 한다 — 자리·구역과 같은 `external` 이다.
|
|
1749
|
+
* 축을 직접 가리키게 적으면 없는 필드를 가리키는 선언이 된다(계약을 보고 고쳤다).
|
|
1750
|
+
*/
|
|
1751
|
+
{ from: "orders", field: "lines[].gtin", target: { kind: "external", entity: "gs1.itemRef" }, via: "twin.rel.item", optional: true },
|
|
1752
|
+
{ from: "tasks", field: "orderId", target: { kind: "axis", axis: "orders" }, via: "twin.rel.order", optional: true },
|
|
1753
|
+
/* 작업의 `kind` 는 그 공정의 키다(라우트의 단계 이름 그대로 — `mes-kernel` 의 공정 목록이 그 증거). */
|
|
1754
|
+
{ from: "tasks", field: "kind", target: { kind: "axis", axis: "operations" }, via: "twin.rel.operation", optional: true },
|
|
1755
|
+
{ from: "tasks", field: "fromNode", target: { kind: "axis", axis: "locations" }, via: "twin.rel.from", optional: true },
|
|
1756
|
+
{ from: "tasks", field: "toNode", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at", optional: true },
|
|
1757
|
+
{ from: "tasks", field: "resourceRef", target: { kind: "axis", axis: "equipment" }, via: "twin.rel.by", optional: true },
|
|
1758
|
+
{ from: "tasks", field: "personnel[]", target: { kind: "axis", axis: "persons" }, via: "twin.rel.crew", optional: true },
|
|
1759
|
+
/*
|
|
1760
|
+
* 물품의 관계 (2026-08-22) — 없으면 축이 **걸어 들어갈 수 없는 목록**이 된다.
|
|
1761
|
+
*
|
|
1762
|
+
* `location` 은 필수다 — 물품은 언제나 어딘가에 있다(그것이 물품의 뜻이다). 나머지는 선택이다:
|
|
1763
|
+
* 물류단위에 담기지 않은 물품, 자산에 실리지 않은 팔레트가 정상이다.
|
|
1764
|
+
*
|
|
1765
|
+
* `parent` 는 **물품 축을 자기 자신으로** 가리킨다(팔레트에 담긴 상자 — EPCIS `AggregationEvent`).
|
|
1766
|
+
* `carriedBy` 는 다른 축이다 — 반복사용 자산(GRAI)이 물류단위를 실어 나른다(§`FlowItem.carriedBy`).
|
|
1767
|
+
*
|
|
1768
|
+
* 관계 이름은 **소문자 한 낱말**이다(`twin.rel.<name>`) — 기존 열여덟 개가 그 규율이고 시험이 지킨다.
|
|
1769
|
+
*
|
|
1770
|
+
* 품목(`gtin`)은 오더와 **같은 규율**이다: 자재 키가 아니라 GS1 품목 참조이므로 축을 직접 가리키지
|
|
1771
|
+
* 않고 `external` 로 둔다. 축을 가리키게 적으면 없는 필드를 가리키는 선언이 된다.
|
|
1772
|
+
*/
|
|
1773
|
+
{ from: "items", field: "location", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at" },
|
|
1774
|
+
{ from: "items", field: "parent", target: { kind: "axis", axis: "items" }, via: "twin.rel.parent", optional: true },
|
|
1775
|
+
{ from: "items", field: "carriedBy", target: { kind: "axis", axis: "assets" }, via: "twin.rel.asset", optional: true },
|
|
1776
|
+
{ from: "items", field: "gtin", target: { kind: "external", entity: "gs1.itemRef" }, via: "twin.rel.item", optional: true },
|
|
1777
|
+
/*
|
|
1778
|
+
* 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
|
|
1779
|
+
* 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
|
|
1780
|
+
*
|
|
1781
|
+
* **자리(`locations`)에는 없다** — 표준이 자리를 시험 대상으로 두지 않았고 그 비대칭은 표준의
|
|
1782
|
+
* 판단이다(`contract.ts` 가 "우리도 자리에 넣지 않는다" 고 적었다).
|
|
1783
|
+
*/
|
|
1784
|
+
{ from: "persons", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1785
|
+
{ from: "personnelClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1786
|
+
{ from: "equipment", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1787
|
+
{ from: "equipmentClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1788
|
+
{ from: "assets", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1789
|
+
{ from: "assetClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1790
|
+
{ from: "materialDefinitions", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1791
|
+
{ from: "materialClasses", field: "testSpecificationIds[]", target: { kind: "axis", axis: "testSpecifications" }, via: "twin.rel.test", optional: true },
|
|
1792
|
+
/* 공정이 도는 자리·자원 — 항목이 아니라 **타입**을 가리킨다 */
|
|
1793
|
+
{ from: "operations", field: "locationType", target: { kind: "type", role: "location" }, via: "twin.rel.at", optional: true },
|
|
1794
|
+
{ from: "operations", field: "resourceType", target: { kind: "type", role: "equipment" }, via: "twin.rel.by", optional: true }
|
|
1795
|
+
];
|
|
1796
|
+
var TWIN_PROPERTIES = [
|
|
1797
|
+
/* 공정 — 선언(ISO 8601 기간)과 관측(저널에서 계산한 분포)이 둘 다 있는 유일한 짝. */
|
|
1798
|
+
{
|
|
1799
|
+
axis: "operations",
|
|
1800
|
+
key: "duration",
|
|
1801
|
+
label: "twin.prop.duration",
|
|
1802
|
+
kind: "distribution",
|
|
1803
|
+
declaredField: "duration",
|
|
1804
|
+
observedBy: "kpi-fold.workTime",
|
|
1805
|
+
uom: "ms"
|
|
1806
|
+
},
|
|
1807
|
+
/* 자리 — 용량은 선언, 점유는 관측. 단위가 다른 것이 아니라 **같은 축의 두 값**이다. */
|
|
1808
|
+
{
|
|
1809
|
+
axis: "locations",
|
|
1810
|
+
key: "capacity",
|
|
1811
|
+
label: "twin.prop.capacity",
|
|
1812
|
+
kind: "level",
|
|
1813
|
+
declaredField: "capacity",
|
|
1814
|
+
observedBy: "state.location.occupancy"
|
|
1815
|
+
},
|
|
1816
|
+
/* 설비 — 고장 모델은 선언, 가동은 관측(OEE). 선언 없이도 관측은 선다. */
|
|
1817
|
+
{
|
|
1818
|
+
axis: "equipment",
|
|
1819
|
+
key: "mtbf",
|
|
1820
|
+
label: "twin.prop.mtbf",
|
|
1821
|
+
kind: "distribution",
|
|
1822
|
+
declaredField: "mtbfMs",
|
|
1823
|
+
observedBy: "oee.availability",
|
|
1824
|
+
uom: "ms"
|
|
1825
|
+
},
|
|
1826
|
+
{
|
|
1827
|
+
axis: "equipment",
|
|
1828
|
+
key: "mttr",
|
|
1829
|
+
label: "twin.prop.mttr",
|
|
1830
|
+
kind: "distribution",
|
|
1831
|
+
declaredField: "mttrMs",
|
|
1832
|
+
observedBy: "oee.availability",
|
|
1833
|
+
uom: "ms"
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
axis: "equipment",
|
|
1837
|
+
key: "availability",
|
|
1838
|
+
label: "twin.prop.availability",
|
|
1839
|
+
kind: "rate",
|
|
1840
|
+
observedBy: "oee.availability",
|
|
1841
|
+
uom: "ratio"
|
|
1842
|
+
}
|
|
1843
|
+
];
|
|
1844
|
+
var propertiesOf = (axis) => TWIN_PROPERTIES.filter((p) => p.axis === axis);
|
|
1845
|
+
var axisSource = (axis) => TWIN_AXES.find((a) => a.axis === axis)?.source ?? "document";
|
|
1846
|
+
function documentPath(axis) {
|
|
1847
|
+
const info = TWIN_AXES.find((a) => a.axis === axis);
|
|
1848
|
+
if (!info) throw new Error(`unknown twin axis: "${axis}"`);
|
|
1849
|
+
if (axisSource(axis) !== "document" || !info.path)
|
|
1850
|
+
throw new Error(`axis "${axis}" is not read from the document (source: ${axisSource(axis)}) \u2014 read it from the ${axisSource(axis)}`);
|
|
1851
|
+
return info.path;
|
|
1852
|
+
}
|
|
1853
|
+
var relationsFrom = (axis) => TWIN_RELATIONS.filter((r) => r.from === axis);
|
|
1854
|
+
var relationsTo = (axis) => TWIN_RELATIONS.filter((r) => r.target.kind === "axis" && r.target.axis === axis);
|
|
1855
|
+
function axisInfo(axis) {
|
|
1856
|
+
return TWIN_AXES.find((a) => a.axis === axis);
|
|
1857
|
+
}
|
|
1858
|
+
var DOMAIN_SYSTEMS = ["wms", "yms", "mes", "ems"];
|
|
1859
|
+
var axisAppliesTo = (axis, system) => {
|
|
1860
|
+
const declared = TWIN_AXES.find((a) => a.axis === axis)?.systems;
|
|
1861
|
+
if (!declared || !system) return true;
|
|
1862
|
+
if (!DOMAIN_SYSTEMS.includes(system)) return true;
|
|
1863
|
+
return declared.includes(system);
|
|
1864
|
+
};
|
|
1865
|
+
var levelOfLocationType = (typeKey, system) => {
|
|
1866
|
+
const pick = (sys) => DOMAIN_CATALOG[sys]?.types.find((t) => t.role === "location" && t.key === typeKey)?.level;
|
|
1867
|
+
if (system && DOMAIN_SYSTEMS.includes(system)) return pick(system);
|
|
1868
|
+
const found = [...new Set(DOMAIN_SYSTEMS.map(pick).filter(Boolean))];
|
|
1869
|
+
return found.length === 1 ? found[0] : void 0;
|
|
1870
|
+
};
|
|
1871
|
+
var axesOfSystem = (system) => TWIN_AXES.filter((a) => axisAppliesTo(a.axis, system));
|
|
1872
|
+
function capabilitiesForType(system, typeKey) {
|
|
1873
|
+
return DOMAIN_CATALOG[system]?.types.find((t) => t.key === typeKey)?.capabilities ?? [];
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/domain-definition.ts
|
|
1877
|
+
var OP_PARAM = {
|
|
1878
|
+
/** 양품률(0..1, 무차원). 없으면 커널 기본값 — 기본값을 쓴 사실은 `specCoverage()` 가 밝힌다. */
|
|
1879
|
+
yield: "yield",
|
|
1880
|
+
/** 셋업·체인지오버 소요(ISO 8601 기간 문자열). ISA-95 는 셋업을 별도 세그먼트로도 표현하지만,
|
|
1881
|
+
* 현재 커널은 작업에 붙는 셋업으로 다루므로 모수로 받는다. */
|
|
1882
|
+
setupDuration: "setupDuration"
|
|
1883
|
+
};
|
|
1884
|
+
function procedureViolations(route) {
|
|
1885
|
+
const elements = route.procedure ?? [];
|
|
1886
|
+
if (!elements.length) return [];
|
|
1887
|
+
const errors = [];
|
|
1888
|
+
const byKey = new Map(elements.map((e) => [e.key, e]));
|
|
1889
|
+
const hasChild = new Set(elements.map((e) => e.parent).filter((k) => !!k));
|
|
1890
|
+
const stepSet = new Set(route.steps ?? []);
|
|
1891
|
+
const seenStep = /* @__PURE__ */ new Map();
|
|
1892
|
+
for (const n of elements) {
|
|
1893
|
+
if (n.parent && !byKey.has(n.parent)) errors.push(`${n.key}: \uC0C1\uC704 '${n.parent}' \uAC00 \uC774 \uC808\uCC28\uC5D0 \uC5C6\uB2E4`);
|
|
1894
|
+
if (n.step === void 0) continue;
|
|
1895
|
+
if (hasChild.has(n.key)) errors.push(`${n.key}: \uC544\uB798 \uC694\uC18C\uAC00 \uC788\uB294\uB370 \uB2E8\uACC4\uB97C \uB4E0\uB2E4 \u2014 \uC2E4\uD589\uB418\uB294 \uAC83\uC740 \uC78E\uBFD0\uC774\uB2E4`);
|
|
1896
|
+
if (!stepSet.has(n.step)) errors.push(`${n.key}: \uBAA8\uB974\uB294 \uB2E8\uACC4 '${n.step}' \u2014 steps \uC5D0 \uC5C6\uB2E4`);
|
|
1897
|
+
const already = seenStep.get(n.step);
|
|
1898
|
+
if (already) errors.push(`\uB2E8\uACC4 '${n.step}' \uAC00 '${already}' \uC640 '${n.key}' \uB458\uC5D0 \uC2E4\uB838\uB2E4 \u2014 \uC808\uCC28 \uC5B4\uB514\uC778\uC9C0 \uB9D0\uD560 \uC218 \uC5C6\uB2E4`);
|
|
1899
|
+
else seenStep.set(n.step, n.key);
|
|
1900
|
+
}
|
|
1901
|
+
return errors;
|
|
1902
|
+
}
|
|
1903
|
+
var INTENTS = ["transport", "process", "dwell"];
|
|
1904
|
+
function dupes(keys) {
|
|
1905
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1906
|
+
const dup = /* @__PURE__ */ new Set();
|
|
1907
|
+
for (const k of keys) {
|
|
1908
|
+
if (seen.has(k)) dup.add(k);
|
|
1909
|
+
else seen.add(k);
|
|
1910
|
+
}
|
|
1911
|
+
return [...dup];
|
|
1912
|
+
}
|
|
1913
|
+
function validateDomainDefinition(def) {
|
|
1914
|
+
const v = [];
|
|
1915
|
+
if (!def || typeof def !== "object") return ["domain definition \uC5C6\uC74C/\uAC1D\uCCB4 \uC544\uB2D8"];
|
|
1916
|
+
if (typeof def.id !== "string" || !def.id) v.push("id \uB204\uB77D");
|
|
1917
|
+
if (typeof def.label !== "string" || !def.label) v.push("label \uB204\uB77D");
|
|
1918
|
+
if (!Array.isArray(def.locationTypes) || def.locationTypes.length === 0) v.push("locationTypes \uBE44\uC5B4\uC788\uC74C");
|
|
1919
|
+
if (!Array.isArray(def.resourceTypes)) v.push("resourceTypes \uBC30\uC5F4 \uC544\uB2D8");
|
|
1920
|
+
const locationKeys2 = new Set((def.locationTypes || []).map((n) => n.key));
|
|
1921
|
+
const resKeys = new Set((def.resourceTypes || []).map((r) => r.key));
|
|
1922
|
+
const matKeys = new Set((def.materials || []).map((m) => m.key));
|
|
1923
|
+
const matByKey = new Map((def.materials || []).map((m) => [m.key, m]));
|
|
1924
|
+
const opKeys = new Set((def.operations || []).map((o) => o.key));
|
|
1925
|
+
const routeKeys = new Set((def.routes || []).map((r) => r.key));
|
|
1926
|
+
for (const [name, arr] of [["locationTypes", def.locationTypes], ["resourceTypes", def.resourceTypes], ["materials", def.materials], ["operations", def.operations], ["routes", def.routes], ["recipes", def.recipes]]) {
|
|
1927
|
+
for (const d of dupes((arr || []).map((x) => x.key))) v.push(`${name} \uD0A4 \uC911\uBCF5: ${d}`);
|
|
1928
|
+
}
|
|
1929
|
+
for (const o of def.operations || []) {
|
|
1930
|
+
if (!INTENTS.includes(o.intent)) v.push(`operation '${o.key}' intent \uBD80\uC815: ${o.intent}`);
|
|
1931
|
+
if (o.locationType && !locationKeys2.has(o.locationType)) v.push(`operation '${o.key}' locationType '${o.locationType}' \uBBF8\uC815\uC758`);
|
|
1932
|
+
if (o.resourceType && !resKeys.has(o.resourceType)) v.push(`operation '${o.key}' resourceType '${o.resourceType}' \uBBF8\uC815\uC758`);
|
|
1933
|
+
if (o.intent === "dwell" && o.resourceType) v.push(`operation '${o.key}' dwell \uC778\uB370 resourceType \uC9C0\uC815\uB428(\uBB34\uC790\uC6D0\uC774\uC5B4\uC57C)`);
|
|
1934
|
+
}
|
|
1935
|
+
for (const r of def.routes || []) {
|
|
1936
|
+
if (!Array.isArray(r.steps) || r.steps.length === 0) v.push(`route '${r.key}' steps \uBE44\uC5B4\uC788\uC74C`);
|
|
1937
|
+
for (const s of r.steps || []) if (!opKeys.has(s)) v.push(`route '${r.key}' step '${s}' \uBBF8\uC815\uC758 operation`);
|
|
1938
|
+
}
|
|
1939
|
+
for (const rc of def.recipes || []) {
|
|
1940
|
+
if (rc.route && !routeKeys.has(rc.route)) v.push(`recipe '${rc.key}' route '${rc.route}' \uBBF8\uC815\uC758`);
|
|
1941
|
+
if (!rc.outputs?.length) v.push(`recipe '${rc.key}' outputs \uBE44\uC5B4\uC788\uC74C`);
|
|
1942
|
+
for (const p of [...rc.inputs || [], ...rc.outputs || []]) {
|
|
1943
|
+
if (!matKeys.has(p.material)) v.push(`recipe '${rc.key}' material '${p.material}' \uBBF8\uC815\uC758`);
|
|
1944
|
+
if (typeof p.qty !== "number" || p.qty <= 0) v.push(`recipe '${rc.key}' material '${p.material}' qty \uBD80\uC815`);
|
|
1945
|
+
const mat = matByKey.get(p.material);
|
|
1946
|
+
if (mat?.locationType && !locationKeys2.has(mat.locationType)) v.push(`recipe '${rc.key}' material '${p.material}' locationType '${mat.locationType}' \uBBF8\uC815\uC758`);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
return v;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// src/energy-ingest.ts
|
|
1953
|
+
function isEnergyRecord(record) {
|
|
1954
|
+
if (!record || typeof record !== "object") return false;
|
|
1955
|
+
const r = record;
|
|
1956
|
+
if (!(typeof r.meterId === "string" && r.meterId.trim().length > 0 && r.epc === void 0)) return false;
|
|
1957
|
+
return r.from === void 0 && r.to === void 0;
|
|
1958
|
+
}
|
|
1959
|
+
function ingestEnergyRecords(records, opts) {
|
|
1960
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
1961
|
+
const accepted = [];
|
|
1962
|
+
const rejected = [];
|
|
1963
|
+
let seq = 0;
|
|
1964
|
+
for (const record of arr) {
|
|
1965
|
+
const errors = [];
|
|
1966
|
+
const meterId = String(record?.meterId ?? "").trim();
|
|
1967
|
+
if (!meterId) errors.push("meterId \uC5C6\uC74C \u2014 \uC5B4\uB514\uC758 \uC18C\uBE44\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uB204\uC801\uD560 \uC218 \uC5C6\uB2E4");
|
|
1968
|
+
const at = String(record?.at ?? "").trim() || opts.defaultEventTime;
|
|
1969
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
1970
|
+
if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uB0A8\uC758 \uC218\uC694 \uAD6C\uAC04\uC5D0 \uC2E4\uB9B0\uB2E4");
|
|
1971
|
+
const num = (v, name) => {
|
|
1972
|
+
if (v === void 0 || v === null || v === "") return void 0;
|
|
1973
|
+
const n = Number(v);
|
|
1974
|
+
if (!Number.isFinite(n)) {
|
|
1975
|
+
errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
1976
|
+
return void 0;
|
|
1977
|
+
}
|
|
1978
|
+
return n;
|
|
1979
|
+
};
|
|
1980
|
+
const kW = num(record?.kW, "kW");
|
|
1981
|
+
const kWh = num(record?.kWh, "kWh");
|
|
1982
|
+
const powerFactor = num(record?.powerFactor, "powerFactor");
|
|
1983
|
+
if (errors.length) {
|
|
1984
|
+
rejected.push({ record, errors });
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
const eventTime = new Date(atMs).toISOString();
|
|
1988
|
+
const data = {
|
|
1989
|
+
meterId,
|
|
1990
|
+
/* 계약은 `kW` 를 필수로 두지만 **못 읽은 표본도 사실**이다 — 그 경우 값을 비우고 보낸다.
|
|
1991
|
+
커널이 「받았지만 부하를 못 읽었다」로 세고, 구간 마감에 그 이유를 싣는다. */
|
|
1992
|
+
...kW !== void 0 ? { kW } : {},
|
|
1993
|
+
...kWh !== void 0 ? { kWh } : {},
|
|
1994
|
+
...powerFactor !== void 0 ? { powerFactor } : {},
|
|
1995
|
+
at: eventTime
|
|
1996
|
+
};
|
|
1997
|
+
accepted.push({
|
|
1998
|
+
eventId: `${opts.tenantId}-energy-${++seq}`,
|
|
1999
|
+
eventType: ENERGY_EVENT.measured,
|
|
2000
|
+
eventTime,
|
|
2001
|
+
tenantId: opts.tenantId,
|
|
2002
|
+
data
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
return { accepted, rejected };
|
|
2006
|
+
}
|
|
2007
|
+
var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"]);
|
|
2008
|
+
function isEnergyEquipmentRecord(record) {
|
|
2009
|
+
if (!record || typeof record !== "object") return false;
|
|
2010
|
+
const r = record;
|
|
2011
|
+
return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0 && /* 적산을 실었으면 발전이다 — 이 문의 값은 상태이고, 적산은 자기 문이 있다. */
|
|
2012
|
+
r.kWh === void 0;
|
|
2013
|
+
}
|
|
2014
|
+
function ingestEnergyEquipmentRecords(records, opts) {
|
|
2015
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
2016
|
+
const accepted = [];
|
|
2017
|
+
const rejected = [];
|
|
2018
|
+
let seq = 0;
|
|
2019
|
+
for (const record of arr) {
|
|
2020
|
+
const errors = [];
|
|
2021
|
+
const r = record;
|
|
2022
|
+
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
2023
|
+
if (!equipmentId) errors.push("equipmentId \uC5C6\uC74C \u2014 \uC5B4\uB290 \uC124\uBE44\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uC2E4\uC744 \uC218 \uC5C6\uB2E4");
|
|
2024
|
+
const at = String(r?.at ?? "").trim() || opts.defaultEventTime;
|
|
2025
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
2026
|
+
if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uC5B8\uC81C\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uC54C \uC218 \uC5C6\uB2E4");
|
|
2027
|
+
const num = (v, name, min, max) => {
|
|
2028
|
+
if (v === void 0 || v === null || v === "") return void 0;
|
|
2029
|
+
const n = Number(v);
|
|
2030
|
+
if (!Number.isFinite(n)) {
|
|
2031
|
+
errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
2032
|
+
return void 0;
|
|
2033
|
+
}
|
|
2034
|
+
if (min !== void 0 && n < min) {
|
|
2035
|
+
errors.push(`${name} \uAC00 ${min} \uBCF4\uB2E4 \uC791\uB2E4: ${n}`);
|
|
2036
|
+
return void 0;
|
|
2037
|
+
}
|
|
2038
|
+
if (max !== void 0 && n > max) {
|
|
2039
|
+
errors.push(`${name} \uAC00 ${max} \uBCF4\uB2E4 \uD06C\uB2E4: ${n}`);
|
|
2040
|
+
return void 0;
|
|
2041
|
+
}
|
|
2042
|
+
return n;
|
|
2043
|
+
};
|
|
2044
|
+
const generatedKW = num(r?.generatedKW, "generatedKW", 0);
|
|
2045
|
+
const exportKW = num(r?.exportKW, "exportKW");
|
|
2046
|
+
const soc = num(r?.soc, "soc", 0, 100);
|
|
2047
|
+
const chargeKW = num(r?.chargeKW, "chargeKW", 0);
|
|
2048
|
+
const dischargeKW = num(r?.dischargeKW, "dischargeKW", 0);
|
|
2049
|
+
const minKW = num(r?.minKW, "minKW", 0);
|
|
2050
|
+
let curtailable;
|
|
2051
|
+
if (r?.curtailable !== void 0 && r?.curtailable !== null) {
|
|
2052
|
+
if (typeof r.curtailable !== "boolean") errors.push(`curtailable \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(r.curtailable)}`);
|
|
2053
|
+
else curtailable = r.curtailable;
|
|
2054
|
+
}
|
|
2055
|
+
const dcVoltage = num(r?.dcVoltage, "dcVoltage", 0);
|
|
2056
|
+
const dcCurrent = num(r?.dcCurrent, "dcCurrent", 0);
|
|
2057
|
+
const phases = (raw, name) => {
|
|
2058
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
2059
|
+
if (!Array.isArray(raw)) {
|
|
2060
|
+
errors.push(`${name} \uAC00 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4 \u2014 \uC0C1\uBCC4 \uAC12\uC774\uB77C \uBC30\uC5F4\uB85C \uBC1B\uB294\uB2E4(\uB2E8\uC0C1\uC774\uBA74 \uC6D0\uC18C \uD558\uB098): ${JSON.stringify(raw)}`);
|
|
2061
|
+
return void 0;
|
|
2062
|
+
}
|
|
2063
|
+
if (!raw.length) {
|
|
2064
|
+
errors.push(`${name} \uAC00 \uBE48 \uBC30\uC5F4\uC774\uB2E4 \u2014 \uC7B0 \uC0C1\uC774 \uC5C6\uC73C\uBA74 \uADF8 \uCE78\uC744 \uBCF4\uB0B4\uC9C0 \uC54A\uB294\uB2E4`);
|
|
2065
|
+
return void 0;
|
|
2066
|
+
}
|
|
2067
|
+
const out = [];
|
|
2068
|
+
for (const [i, v] of raw.entries()) {
|
|
2069
|
+
const n = Number(v);
|
|
2070
|
+
if (!Number.isFinite(n)) {
|
|
2071
|
+
errors.push(`${name}[${i}] \uB97C \uC218\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(v)}`);
|
|
2072
|
+
return void 0;
|
|
2073
|
+
}
|
|
2074
|
+
if (n < 0) {
|
|
2075
|
+
errors.push(`${name}[${i}] \uAC00 \uC74C\uC218\uB2E4(${n}) \u2014 \uD06C\uAE30\uB294 \uC74C\uC218\uAC00 \uB418\uC9C0 \uC54A\uB294\uB2E4(\uBC29\uD5A5\uC740 \uB2E4\uB978 \uCD95\uC774 \uB9D0\uD55C\uB2E4)`);
|
|
2076
|
+
return void 0;
|
|
2077
|
+
}
|
|
2078
|
+
out.push(n);
|
|
2079
|
+
}
|
|
2080
|
+
return out;
|
|
2081
|
+
};
|
|
2082
|
+
const acVoltage = phases(r?.acVoltage, "acVoltage");
|
|
2083
|
+
const acCurrent = phases(r?.acCurrent, "acCurrent");
|
|
2084
|
+
if (acVoltage && acCurrent && acVoltage.length !== acCurrent.length) {
|
|
2085
|
+
errors.push(`acVoltage(${acVoltage.length}\uC0C1)\uC640 acCurrent(${acCurrent.length}\uC0C1)\uC758 \uC0C1 \uC218\uAC00 \uB2E4\uB974\uB2E4`);
|
|
2086
|
+
}
|
|
2087
|
+
let position;
|
|
2088
|
+
if (r?.position !== void 0 && r?.position !== null && r?.position !== "") {
|
|
2089
|
+
const p = String(r.position);
|
|
2090
|
+
if (!POSITIONS.has(p)) errors.push(`position \uC774 \uD45C\uC900 \uAC12\uC774 \uC544\uB2C8\uB2E4(open\xB7closed\xB7intermediate\xB7bad): ${JSON.stringify(r.position)}`);
|
|
2091
|
+
else position = p;
|
|
2092
|
+
}
|
|
2093
|
+
const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position, dcVoltage, dcCurrent, acVoltage, acCurrent };
|
|
2094
|
+
if (!errors.length && Object.values(values).every((v) => v === void 0)) {
|
|
2095
|
+
errors.push("\uBC14\uAFC0 \uC0C1\uD0DC\uAC00 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uAC12 \uC5C6\uB294 \uBCF4\uACE0\uB294 \uC0AC\uC2E4\uB85C \uC801\uC744 \uAC83\uC774 \uC5C6\uB2E4");
|
|
2096
|
+
}
|
|
2097
|
+
if (errors.length) {
|
|
2098
|
+
rejected.push({ record, errors });
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
const eventTime = new Date(atMs).toISOString();
|
|
2102
|
+
const data = {
|
|
2103
|
+
equipmentId,
|
|
2104
|
+
at: eventTime,
|
|
2105
|
+
...generatedKW !== void 0 ? { generatedKW } : {},
|
|
2106
|
+
...exportKW !== void 0 ? { exportKW } : {},
|
|
2107
|
+
...soc !== void 0 ? { soc } : {},
|
|
2108
|
+
...chargeKW !== void 0 ? { chargeKW } : {},
|
|
2109
|
+
...dischargeKW !== void 0 ? { dischargeKW } : {},
|
|
2110
|
+
...curtailable !== void 0 ? { curtailable } : {},
|
|
2111
|
+
...minKW !== void 0 ? { minKW } : {},
|
|
2112
|
+
...position !== void 0 ? { position } : {},
|
|
2113
|
+
...dcVoltage !== void 0 ? { dcVoltage } : {},
|
|
2114
|
+
...dcCurrent !== void 0 ? { dcCurrent } : {},
|
|
2115
|
+
...acVoltage !== void 0 ? { acVoltage } : {},
|
|
2116
|
+
...acCurrent !== void 0 ? { acCurrent } : {}
|
|
2117
|
+
};
|
|
2118
|
+
accepted.push({
|
|
2119
|
+
eventId: `${opts.tenantId}-energy-eq-${++seq}`,
|
|
2120
|
+
eventType: ENERGY_EVENT.equipment,
|
|
2121
|
+
eventTime,
|
|
2122
|
+
tenantId: opts.tenantId,
|
|
2123
|
+
data
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
return { accepted, rejected };
|
|
2127
|
+
}
|
|
2128
|
+
function isEnergyGenerationRecord(record) {
|
|
2129
|
+
if (record?.from !== void 0 || record?.to !== void 0) return false;
|
|
2130
|
+
if (!record || typeof record !== "object") return false;
|
|
2131
|
+
const r = record;
|
|
2132
|
+
return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.kWh !== void 0 && r.meterId === void 0 && r.epc === void 0;
|
|
2133
|
+
}
|
|
2134
|
+
function ingestEnergyGenerationRecords(records, opts) {
|
|
2135
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
2136
|
+
const accepted = [];
|
|
2137
|
+
const rejected = [];
|
|
2138
|
+
let seq = 0;
|
|
2139
|
+
for (const r of list) {
|
|
2140
|
+
const errors = [];
|
|
2141
|
+
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
2142
|
+
if (!equipmentId) errors.push("equipmentId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC124\uBE44\uAC00 \uB9CC\uB4E0 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
2143
|
+
const STATE_FIELDS = ["generatedKW", "exportKW", "soc", "chargeKW", "dischargeKW", "curtailable", "minKW", "position"];
|
|
2144
|
+
const alsoState = STATE_FIELDS.filter((f) => r?.[f] !== void 0);
|
|
2145
|
+
if (alsoState.length) {
|
|
2146
|
+
errors.push(
|
|
2147
|
+
`\uC801\uC0B0\uACFC \uC124\uBE44 \uC0C1\uD0DC\uB97C \uD55C \uB808\uCF54\uB4DC\uC5D0 \uC2E4\uC5C8\uB2E4(${alsoState.join("\xB7")}) \u2014 \uC774 \uBB38\uC740 \uC801\uC0B0\uB9CC \uC77D\uC73C\uBBC0\uB85C \uADF8 \uAC12\uC774 \uC0AC\uB77C\uC9C4\uB2E4. \uAC08\uB77C \uBCF4\uB0BC \uAC83`
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
const raw = r?.kWh;
|
|
2151
|
+
const kWh = Number(raw);
|
|
2152
|
+
if (raw === void 0 || raw === null || !Number.isFinite(kWh)) {
|
|
2153
|
+
errors.push("kWh \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 \uC801\uC0B0 \uD558\uB098\uB2E4(\uC21C\uC2DC \uCD9C\uB825\uC740 \uC124\uBE44 \uC0C1\uD0DC\uB85C \uBCF4\uB0B8\uB2E4)");
|
|
2154
|
+
} else if (kWh < 0) {
|
|
2155
|
+
errors.push(`kWh \uAC00 \uC74C\uC218\uB2E4(${kWh}) \u2014 \uC801\uC0B0\uC740 \uB4A4\uB85C \uAC00\uB354\uB77C\uB3C4 \uC74C\uC218\uAC00 \uB418\uC9C0 \uC54A\uB294\uB2E4`);
|
|
2156
|
+
}
|
|
2157
|
+
const eventTime = String(r?.at ?? "").trim() || opts.defaultEventTime || "";
|
|
2158
|
+
if (!eventTime) errors.push("at \uC774 \uC5C6\uACE0 \uAE30\uBCF8 \uC2DC\uAC01\uB3C4 \uC8FC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uC5B8\uC81C \uC7B0 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
2159
|
+
let since;
|
|
2160
|
+
const rawSince = r?.kWhSince;
|
|
2161
|
+
if (rawSince !== void 0 && rawSince !== null && String(rawSince).trim()) {
|
|
2162
|
+
const text = String(rawSince).trim();
|
|
2163
|
+
const at = Date.parse(text);
|
|
2164
|
+
if (!Number.isFinite(at)) errors.push(`kWhSince \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(rawSince)}`);
|
|
2165
|
+
else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
|
|
2166
|
+
errors.push(`kWhSince(${text}) \uAC00 \uC7B0 \uC2DC\uAC01(${eventTime})\uBCF4\uB2E4 \uB4A4\uB2E4 \u2014 \uB458 \uC911 \uD558\uB098\uAC00 \uD2C0\uB838\uB2E4`);
|
|
2167
|
+
} else since = text;
|
|
2168
|
+
}
|
|
2169
|
+
const ACCUMULATIONS = ["lifetime", "daily", "monthly", "billing", "unknown"];
|
|
2170
|
+
let accumulation;
|
|
2171
|
+
const rawAcc = r?.kWhAccumulation;
|
|
2172
|
+
if (rawAcc !== void 0 && rawAcc !== null && String(rawAcc).trim()) {
|
|
2173
|
+
const text = String(rawAcc).trim();
|
|
2174
|
+
if (!ACCUMULATIONS.includes(text)) {
|
|
2175
|
+
errors.push(`kWhAccumulation \uC774 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${ACCUMULATIONS.join("\xB7")}): ${JSON.stringify(rawAcc)}`);
|
|
2176
|
+
} else accumulation = text;
|
|
2177
|
+
}
|
|
2178
|
+
if (errors.length) {
|
|
2179
|
+
rejected.push({ record: r, errors });
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
const data = {
|
|
2183
|
+
equipmentId,
|
|
2184
|
+
kWh,
|
|
2185
|
+
at: eventTime,
|
|
2186
|
+
...since ? { since } : {},
|
|
2187
|
+
...accumulation ? { accumulation } : {}
|
|
2188
|
+
};
|
|
2189
|
+
accepted.push({
|
|
2190
|
+
eventId: `${opts.tenantId}-generated-${++seq}`,
|
|
2191
|
+
eventType: ENERGY_EVENT.generated,
|
|
2192
|
+
eventTime,
|
|
2193
|
+
tenantId: opts.tenantId,
|
|
2194
|
+
data
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
return { accepted, rejected };
|
|
2198
|
+
}
|
|
2199
|
+
function isEnergyUsagePeriodRecord(record) {
|
|
2200
|
+
const r = record;
|
|
2201
|
+
return !!r && typeof r === "object" && r.meterId !== void 0 && r.from !== void 0 && r.to !== void 0 && r.kWh !== void 0;
|
|
2202
|
+
}
|
|
2203
|
+
function isEnergyGenerationPeriodRecord(record) {
|
|
2204
|
+
const r = record;
|
|
2205
|
+
if (!r || typeof r !== "object") return false;
|
|
2206
|
+
if (r.equipmentId === void 0 || r.from === void 0 || r.to === void 0) return false;
|
|
2207
|
+
return r.kWh !== void 0;
|
|
2208
|
+
}
|
|
2209
|
+
function ingestEnergyGenerationPeriodRecords(records, opts) {
|
|
2210
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
2211
|
+
const accepted = [];
|
|
2212
|
+
const rejected = [];
|
|
2213
|
+
for (const r of list) {
|
|
2214
|
+
const errors = [];
|
|
2215
|
+
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
2216
|
+
if (!equipmentId) errors.push("equipmentId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC124\uBE44\uAC00 \uB0B8 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
2217
|
+
const span = readSpan(r, errors);
|
|
2218
|
+
const kWh = readAmount(r?.kWh, "kWh", errors);
|
|
2219
|
+
if (kWh === void 0 && !errors.length) errors.push("kWh \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 \uADF8 \uAE30\uAC04\uC758 \uBC1C\uC804\uB7C9\uC774\uB2E4");
|
|
2220
|
+
const ACCUMULATIONS = ["lifetime", "daily", "monthly", "billing", "unknown"];
|
|
2221
|
+
let accumulation;
|
|
2222
|
+
const rawAcc = r?.accumulation;
|
|
2223
|
+
if (rawAcc !== void 0 && rawAcc !== null && String(rawAcc).trim()) {
|
|
2224
|
+
const text = String(rawAcc).trim();
|
|
2225
|
+
if (!ACCUMULATIONS.includes(text)) {
|
|
2226
|
+
errors.push(`accumulation \uC774 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${ACCUMULATIONS.join("\xB7")}): ${JSON.stringify(rawAcc)}`);
|
|
2227
|
+
} else accumulation = text;
|
|
2228
|
+
}
|
|
2229
|
+
const readAt = (raw, name) => {
|
|
2230
|
+
if (raw === void 0 || raw === null || !String(raw).trim()) return void 0;
|
|
2231
|
+
const text = String(raw).trim();
|
|
2232
|
+
if (!Number.isFinite(Date.parse(text))) {
|
|
2233
|
+
errors.push(`${name} \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(raw)}`);
|
|
2234
|
+
return void 0;
|
|
2235
|
+
}
|
|
2236
|
+
return text;
|
|
2237
|
+
};
|
|
2238
|
+
const observedFrom = readAt(r?.observedFrom, "observedFrom");
|
|
2239
|
+
const observedTo = readAt(r?.observedTo, "observedTo");
|
|
2240
|
+
if (errors.length || !span || kWh === void 0) {
|
|
2241
|
+
rejected.push({ record: r, errors });
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
const data = {
|
|
2245
|
+
equipmentId,
|
|
2246
|
+
kWh,
|
|
2247
|
+
periodStart: span.from,
|
|
2248
|
+
periodEnd: span.to,
|
|
2249
|
+
/* 관측 구간을 말하지 않으면 기간 전체를 잰 것으로 둔다 — 지난 기록은 그 원본이 하루를 마감해 준 값이다. */
|
|
2250
|
+
observedFrom: observedFrom ?? span.from,
|
|
2251
|
+
observedTo: observedTo ?? span.to,
|
|
2252
|
+
accumulation: accumulation ?? "daily",
|
|
2253
|
+
/* 밖에서 마감되어 온 것이다 — 우리가 시각 기준으로 닫은 것이 아니다. */
|
|
2254
|
+
boundary: "declared"
|
|
2255
|
+
};
|
|
2256
|
+
const subj = resolveSubject("equipment", equipmentId, opts);
|
|
2257
|
+
data.subject = subj.subject;
|
|
2258
|
+
data.subjectBasis = subj.basis;
|
|
2259
|
+
const futureError = futureFactError(span.to, opts);
|
|
2260
|
+
if (futureError) {
|
|
2261
|
+
rejected.push({ record: r, errors: [futureError] });
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
accepted.push({
|
|
2265
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
|
|
2266
|
+
eventType: ENERGY_EVENT.generationPeriod,
|
|
2267
|
+
eventTime: span.to,
|
|
2268
|
+
tenantId: opts.tenantId,
|
|
2269
|
+
data
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
return { accepted, rejected };
|
|
2273
|
+
}
|
|
2274
|
+
function isEnergyGenerationPriceRecord(record) {
|
|
2275
|
+
const r = record;
|
|
2276
|
+
if (!r || typeof r !== "object") return false;
|
|
2277
|
+
if (r.from === void 0 || r.to === void 0) return false;
|
|
2278
|
+
if (r.meterId !== void 0 || r.equipmentId !== void 0) return false;
|
|
2279
|
+
return r.unitPrice !== void 0;
|
|
2280
|
+
}
|
|
2281
|
+
function ingestEnergyGenerationPriceRecords(records, opts) {
|
|
2282
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
2283
|
+
const accepted = [];
|
|
2284
|
+
const rejected = [];
|
|
2285
|
+
for (const r of list) {
|
|
2286
|
+
const errors = [];
|
|
2287
|
+
const span = readSpan(r, errors);
|
|
2288
|
+
const unitPrice = readAmount(r?.unitPrice, "unitPrice", errors);
|
|
2289
|
+
if (unitPrice === void 0 && !errors.length) errors.push("unitPrice \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 1kWh \uB2F9 \uAC12 \uD558\uB098\uB2E4");
|
|
2290
|
+
const currency = String(r?.currency ?? "").trim();
|
|
2291
|
+
if (!currency) errors.push("currency \uAC00 \uC5C6\uB2E4 \u2014 \uB2E8\uC704 \uC5C6\uB294 \uAC12\uC740 \uB2E4\uB978 \uAE08\uC561\uACFC \uB354\uD560 \uC218 \uC5C6\uB2E4");
|
|
2292
|
+
if (errors.length || !span || unitPrice === void 0) {
|
|
2293
|
+
rejected.push({ record: r, errors });
|
|
2294
|
+
continue;
|
|
2295
|
+
}
|
|
2296
|
+
const data = { from: span.from, to: span.to, unitPrice, currency };
|
|
2297
|
+
accepted.push({
|
|
2298
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPrice, "", span.from, span.to),
|
|
2299
|
+
eventType: ENERGY_EVENT.generationPrice,
|
|
2300
|
+
eventTime: span.from,
|
|
2301
|
+
tenantId: opts.tenantId,
|
|
2302
|
+
data
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
return { accepted, rejected };
|
|
2306
|
+
}
|
|
2307
|
+
function isEnergyTariffBasisRecord(record) {
|
|
2308
|
+
const r = record;
|
|
2309
|
+
if (!r || typeof r !== "object") return false;
|
|
2310
|
+
if (r.from === void 0 || r.to === void 0) return false;
|
|
2311
|
+
if (r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0) return false;
|
|
2312
|
+
return r.billingDemandKW !== void 0 || r.demandChargePerKW !== void 0;
|
|
2313
|
+
}
|
|
2314
|
+
function ingestEnergyTariffBasisRecords(records, opts) {
|
|
2315
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
2316
|
+
const accepted = [];
|
|
2317
|
+
const rejected = [];
|
|
2318
|
+
for (const r of list) {
|
|
2319
|
+
const errors = [];
|
|
2320
|
+
const span = readSpan(r, errors);
|
|
2321
|
+
const billingDemandKW = readAmount(r?.billingDemandKW, "billingDemandKW", errors);
|
|
2322
|
+
const demandChargePerKW = readAmount(r?.demandChargePerKW, "demandChargePerKW", errors);
|
|
2323
|
+
if (billingDemandKW === void 0 && demandChargePerKW === void 0) {
|
|
2324
|
+
errors.push("\uC694\uAE08 \uAE30\uC900\uC774 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uC801\uC744 \uC0AC\uC2E4\uC774 \uC5C6\uB294 \uAE30\uC900\uC740 \uBC1B\uC9C0 \uC54A\uB294\uB2E4");
|
|
2325
|
+
}
|
|
2326
|
+
const currency = String(r?.currency ?? "").trim() || void 0;
|
|
2327
|
+
if (demandChargePerKW !== void 0 && !currency) errors.push("demandChargePerKW \uB97C \uC2E4\uC5C8\uB294\uB370 currency \uAC00 \uC5C6\uB2E4");
|
|
2328
|
+
if (r?.demandCharge !== void 0 || r?.total !== void 0) {
|
|
2329
|
+
errors.push("\uAE08\uC561(demandCharge\xB7total)\uC740 \uC774 \uBB38\uC774 \uBC1B\uC9C0 \uC54A\uB294\uB2E4 \u2014 \uC815\uC0B0 \uAE08\uC561\uC740 \uCCAD\uAD6C\uC11C \uBB38\uC73C\uB85C \uBCF4\uB0B8\uB2E4");
|
|
2330
|
+
}
|
|
2331
|
+
if (errors.length || !span) {
|
|
2332
|
+
rejected.push({ record: r, errors });
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
const data = {
|
|
2336
|
+
from: span.from,
|
|
2337
|
+
to: span.to,
|
|
2338
|
+
...billingDemandKW !== void 0 ? { billingDemandKW } : {},
|
|
2339
|
+
...demandChargePerKW !== void 0 ? { demandChargePerKW } : {},
|
|
2340
|
+
...currency ? { currency } : {}
|
|
2341
|
+
};
|
|
2342
|
+
accepted.push({
|
|
2343
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.tariffBasis, "", span.from, span.to),
|
|
2344
|
+
eventType: ENERGY_EVENT.tariffBasis,
|
|
2345
|
+
/* **시작 시각**이다 — 그때부터 유효하다(§ 위 주석). */
|
|
2346
|
+
eventTime: span.from,
|
|
2347
|
+
tenantId: opts.tenantId,
|
|
2348
|
+
data
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
return { accepted, rejected };
|
|
2352
|
+
}
|
|
2353
|
+
function isEnergyBillRecord(record) {
|
|
2354
|
+
const r = record;
|
|
2355
|
+
if (!r || typeof r !== "object") return false;
|
|
2356
|
+
if (r.from === void 0 || r.to === void 0) return false;
|
|
2357
|
+
return r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0;
|
|
2358
|
+
}
|
|
2359
|
+
function futureFactError(eventTime, opts) {
|
|
2360
|
+
const nowText = opts.nowISO ?? opts.defaultEventTime;
|
|
2361
|
+
if (!nowText) return void 0;
|
|
2362
|
+
const nowMs = Date.parse(nowText);
|
|
2363
|
+
const atMs = Date.parse(eventTime);
|
|
2364
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(atMs) || atMs <= nowMs) return void 0;
|
|
2365
|
+
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`;
|
|
2366
|
+
}
|
|
2367
|
+
function resolveSubject(kind, localId, opts) {
|
|
2368
|
+
const declared = opts.identityOf?.(kind, localId);
|
|
2369
|
+
if (declared) return { subject: declared, basis: "declared" };
|
|
2370
|
+
return { subject: opts.scopeId ? `${opts.scopeId}/${localId}` : localId, basis: "twin-local" };
|
|
2371
|
+
}
|
|
2372
|
+
function periodFactId(tenantId, kind, subject, from, to) {
|
|
2373
|
+
return [tenantId, kind, subject, from, to].join("|");
|
|
2374
|
+
}
|
|
2375
|
+
function readSpan(r, errors) {
|
|
2376
|
+
const from = String(r?.from ?? "").trim();
|
|
2377
|
+
const to = String(r?.to ?? "").trim();
|
|
2378
|
+
const fromMs = Date.parse(from);
|
|
2379
|
+
const toMs = Date.parse(to);
|
|
2380
|
+
if (!from || !Number.isFinite(fromMs)) errors.push(`from \uC744 \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(r?.from)}`);
|
|
2381
|
+
if (!to || !Number.isFinite(toMs)) errors.push(`to \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(r?.to)}`);
|
|
2382
|
+
if (Number.isFinite(fromMs) && Number.isFinite(toMs) && !(toMs > fromMs)) {
|
|
2383
|
+
errors.push(`\uAD6C\uAC04\uC774 \uB4A4\uC9D1\uD614\uAC70\uB098 \uAE38\uC774\uAC00 \uC5C6\uB2E4(${from} \u2192 ${to}) \u2014 \uC5B4\uB290 \uCABD\uC774 \uD2C0\uB838\uB294\uC9C0 \uC6B0\uB9AC\uAC00 \uC815\uD560 \uC218 \uC5C6\uB2E4`);
|
|
2384
|
+
}
|
|
2385
|
+
return errors.length ? void 0 : { from, to };
|
|
2386
|
+
}
|
|
2387
|
+
function readAmount(raw, name, errors) {
|
|
2388
|
+
if (raw === void 0 || raw === null || String(raw).trim() === "") return void 0;
|
|
2389
|
+
const v = Number(raw);
|
|
2390
|
+
if (!Number.isFinite(v)) {
|
|
2391
|
+
errors.push(`${name} \uB97C \uC218\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(raw)}`);
|
|
2392
|
+
return void 0;
|
|
2393
|
+
}
|
|
2394
|
+
if (v < 0) {
|
|
2395
|
+
errors.push(`${name} \uAC00 \uC74C\uC218\uB2E4(${v})`);
|
|
2396
|
+
return void 0;
|
|
2397
|
+
}
|
|
2398
|
+
return v;
|
|
2399
|
+
}
|
|
2400
|
+
function ingestEnergyUsagePeriodRecords(records, opts) {
|
|
2401
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
2402
|
+
const accepted = [];
|
|
2403
|
+
const rejected = [];
|
|
2404
|
+
for (const r of list) {
|
|
2405
|
+
const errors = [];
|
|
2406
|
+
const meterId = String(r?.meterId ?? "").trim();
|
|
2407
|
+
if (!meterId) errors.push("meterId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC9C0\uC810\uC774 \uC4F4 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
2408
|
+
const span = readSpan(r, errors);
|
|
2409
|
+
const rawKWh = r?.kWh;
|
|
2410
|
+
let kWh;
|
|
2411
|
+
if (rawKWh === void 0 || rawKWh === null || String(rawKWh).trim() === "") {
|
|
2412
|
+
errors.push("kWh \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 \uADF8 \uAD6C\uAC04\uC5D0 \uC4F4 \uC591\uC774\uB2E4");
|
|
2413
|
+
} else {
|
|
2414
|
+
kWh = readAmount(rawKWh, "kWh", errors);
|
|
2415
|
+
}
|
|
2416
|
+
const maxKW = readAmount(r?.maxKW, "maxKW", errors);
|
|
2417
|
+
const unitPrice = readAmount(r?.unitPrice, "unitPrice", errors);
|
|
2418
|
+
const currency = String(r?.currency ?? "").trim() || void 0;
|
|
2419
|
+
if (unitPrice !== void 0 && !currency) errors.push("unitPrice \uB97C \uC2E4\uC5C8\uB294\uB370 currency \uAC00 \uC5C6\uB2E4 \u2014 \uB2E8\uC704 \uC5C6\uB294 \uAE08\uC561\uC740 \uB354\uD560 \uC218 \uC5C6\uB2E4");
|
|
2420
|
+
let basis;
|
|
2421
|
+
const rawBasis = r?.basis;
|
|
2422
|
+
if (rawBasis !== void 0 && rawBasis !== null && String(rawBasis).trim()) {
|
|
2423
|
+
const text = String(rawBasis).trim();
|
|
2424
|
+
if (!OBSERVATION_BASIS.includes(text)) {
|
|
2425
|
+
errors.push(`basis \uAC00 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${OBSERVATION_BASIS.join("\xB7")}): ${JSON.stringify(rawBasis)}`);
|
|
2426
|
+
} else basis = text;
|
|
2427
|
+
}
|
|
2428
|
+
if (errors.length || !span || kWh === void 0) {
|
|
2429
|
+
rejected.push({ record: r, errors });
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
const subj = resolveSubject("meter", meterId, opts);
|
|
2433
|
+
const data = {
|
|
2434
|
+
meterId,
|
|
2435
|
+
subject: subj.subject,
|
|
2436
|
+
subjectBasis: subj.basis,
|
|
2437
|
+
from: span.from,
|
|
2438
|
+
to: span.to,
|
|
2439
|
+
kWh,
|
|
2440
|
+
...maxKW !== void 0 ? { maxKW } : {},
|
|
2441
|
+
...unitPrice !== void 0 ? { unitPrice } : {},
|
|
2442
|
+
...currency ? { currency } : {},
|
|
2443
|
+
...basis ? { basis } : {}
|
|
2444
|
+
};
|
|
2445
|
+
const futureError = futureFactError(span.to, opts);
|
|
2446
|
+
if (futureError) {
|
|
2447
|
+
rejected.push({ record: r, errors: [futureError] });
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
2450
|
+
accepted.push({
|
|
2451
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
|
|
2452
|
+
eventType: ENERGY_EVENT.usagePeriod,
|
|
2453
|
+
/* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
|
|
2454
|
+
eventTime: span.to,
|
|
2455
|
+
tenantId: opts.tenantId,
|
|
2456
|
+
data
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
return { accepted, rejected };
|
|
2460
|
+
}
|
|
2461
|
+
function ingestEnergyBillRecords(records, opts) {
|
|
2462
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
2463
|
+
const accepted = [];
|
|
2464
|
+
const rejected = [];
|
|
2465
|
+
for (const r of list) {
|
|
2466
|
+
const errors = [];
|
|
2467
|
+
const span = readSpan(r, errors);
|
|
2468
|
+
const energyCharge = readAmount(r?.energyCharge, "energyCharge", errors);
|
|
2469
|
+
const demandCharge = readAmount(r?.demandCharge, "demandCharge", errors);
|
|
2470
|
+
const total = readAmount(r?.total, "total", errors);
|
|
2471
|
+
const billingDemandKW = readAmount(r?.billingDemandKW, "billingDemandKW", errors);
|
|
2472
|
+
if (energyCharge === void 0 && demandCharge === void 0 && total === void 0) {
|
|
2473
|
+
errors.push("\uAE08\uC561\uC774 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uC801\uC744 \uC0AC\uC2E4\uC774 \uC5C6\uB294 \uCCAD\uAD6C\uC11C\uB294 \uBC1B\uC9C0 \uC54A\uB294\uB2E4");
|
|
2474
|
+
}
|
|
2475
|
+
const currency = String(r?.currency ?? "").trim() || void 0;
|
|
2476
|
+
if (!currency) errors.push("currency \uAC00 \uC5C6\uB2E4 \u2014 \uB2E8\uC704 \uC5C6\uB294 \uAE08\uC561\uC740 \uB2E4\uB978 \uAE08\uC561\uACFC \uB354\uD560 \uC218 \uC5C6\uB2E4");
|
|
2477
|
+
if (errors.length || !span) {
|
|
2478
|
+
rejected.push({ record: r, errors });
|
|
2479
|
+
continue;
|
|
2480
|
+
}
|
|
2481
|
+
const data = {
|
|
2482
|
+
from: span.from,
|
|
2483
|
+
to: span.to,
|
|
2484
|
+
...energyCharge !== void 0 ? { energyCharge } : {},
|
|
2485
|
+
...demandCharge !== void 0 ? { demandCharge } : {},
|
|
2486
|
+
...total !== void 0 ? { total } : {},
|
|
2487
|
+
...currency ? { currency } : {},
|
|
2488
|
+
...billingDemandKW !== void 0 ? { billingDemandKW } : {}
|
|
2489
|
+
};
|
|
2490
|
+
const futureError = futureFactError(span.to, opts);
|
|
2491
|
+
if (futureError) {
|
|
2492
|
+
rejected.push({ record: r, errors: [futureError] });
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
2495
|
+
accepted.push({
|
|
2496
|
+
eventId: periodFactId(opts.tenantId, ENERGY_EVENT.bill, "", span.from, span.to),
|
|
2497
|
+
eventType: ENERGY_EVENT.bill,
|
|
2498
|
+
eventTime: span.to,
|
|
2499
|
+
tenantId: opts.tenantId,
|
|
2500
|
+
data
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
return { accepted, rejected };
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// src/epcis.ts
|
|
2507
|
+
var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
|
|
2508
|
+
var UTC_OFFSET = "+00:00";
|
|
2509
|
+
var DISP = {
|
|
2510
|
+
in_progress: "urn:epcglobal:cbv:disp:in_progress",
|
|
2511
|
+
sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
|
|
2512
|
+
reserved: "urn:epcglobal:cbv:disp:reserved",
|
|
2513
|
+
in_transit: "urn:epcglobal:cbv:disp:in_transit",
|
|
2514
|
+
non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other",
|
|
2515
|
+
// 불량/scrap
|
|
2516
|
+
/**
|
|
2517
|
+
* **기한이 지났다** — CBV `expired`.
|
|
2518
|
+
*
|
|
2519
|
+
* ── 왜 `non_sellable` 로 계산하지 않나 (2026-08-24) ─────────────────────────────
|
|
2520
|
+
* 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
|
|
2521
|
+
* 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
|
|
2522
|
+
* 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
|
|
2523
|
+
*
|
|
2524
|
+
* 그리고 표준에 **정확한 낱말이 있다** — 합치는 것은 있는 낱말을 버리는 것이다.
|
|
2525
|
+
*
|
|
2526
|
+
* ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
|
|
2527
|
+
* `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
|
|
2528
|
+
* 「기한 지남」을 상태로 선언하는 시스템이 있다 — 그때 이 값은 관측이다.
|
|
2529
|
+
*
|
|
2530
|
+
* 둘이 어긋나면(날짜는 남았는데 상태가 지남, 또는 그 반대) **어느 쪽이 맞다고 정하지 않는다** —
|
|
2531
|
+
* 아직 그 판정을 세울 근거가 없다. 어긋남의 구분을 없애지 않는 것이 지금의 규율이다.
|
|
2532
|
+
*
|
|
2533
|
+
* ── 원문으로 확인했다 (2026-08-24) ────────────────────────────────────────
|
|
2534
|
+
* 1차 출처: **CBV Standard Release 2.0, Ratified Jun 2022** §7.2.3 처분 값 표(38개). 이 객체의
|
|
2535
|
+
* 다른 값들(`in_progress`·`sellable_accessible`·`reserved`·`in_transit`·`non_sellable_other`)도
|
|
2536
|
+
* 그 표에 있다.
|
|
2537
|
+
*
|
|
2538
|
+
* **`non_sellable_expired` 를 쓰지 않는 이유**: 그 값은 CBV 1.0 의 것이고 표준이 **폐기**했다 —
|
|
2539
|
+
* 「deprecated in favour of new disposition values expired, damaged, disposed, … introduced in
|
|
2540
|
+
* CBV 1.1」. 폐기된 값을 쓰면 새 소비처가 읽지 못한다.
|
|
2541
|
+
*
|
|
2542
|
+
* 참고: GS1 어휘 등록처(`ref.gs1.org/cbv/…`)로는 확인할 수 없었다 — **없는 값에도 같은 응답**을
|
|
2543
|
+
* 준다(지어낸 값의 JSON-LD 가 실재 값과 바이트까지 같았다). 그 경로를 근거로 삼지 말 것.
|
|
2544
|
+
*/
|
|
2545
|
+
expired: "urn:epcglobal:cbv:disp:expired",
|
|
2546
|
+
/**
|
|
2547
|
+
* **검사에 합격했다 / 불합격했다** — CBV `conformant` / `non_conformant`.
|
|
2548
|
+
*
|
|
2549
|
+
* 1차 출처(CBV 2.0 §7.2.3) 정의 그대로다.
|
|
2550
|
+
*
|
|
2551
|
+
* conformant Outcome of a successful/passed inspection in an inspecting or repairing step
|
|
2552
|
+
* non_conformant Outcome of an unsuccessful/failed inspection in an inspecting or repairing step
|
|
2553
|
+
*
|
|
2554
|
+
* ── 왜 시험 결과 축을 자원에 더하지 않고 이것을 쓰나 (2026-08-24) ────────────
|
|
2555
|
+
* 로트의 검사 판정을 담을 자리를 찾다가 `ItemState.testResults` 를 더하려 했다. 그런데 표준은 그
|
|
2556
|
+
* 사실을 **이미 처분으로 말한다**: `bizStep: inspecting` 사건에 이 처분이 붙는다.
|
|
2557
|
+
*
|
|
2558
|
+
* 처분을 쓰면 두 가지가 공짜로 성립한다.
|
|
2559
|
+
* ① **상태 ⊆ 이벤트** — 처분은 이미 사건에서 온다. 상태에만 있는 축을 만들지 않는다
|
|
2560
|
+
* ② **운영에 곧 닿는다** — 「이 자재를 쓸 수 있나」가 처분으로 답해진다(판정을 따로 읽지 않는다)
|
|
2561
|
+
*
|
|
2562
|
+
* 시험의 **자세한 내용**(어느 명세로, 무엇을 재어)은 다른 물음이고, 표준은 그것을 `TestResult` 로
|
|
2563
|
+
* 두며 결과가 대상을 가리킨다(`TestableObjectID`) — 대상이 결과를 들지 않는다. 그 축이 필요해지면
|
|
2564
|
+
* 그때 열되, **판정 자체는 여기서 끝난다.**
|
|
2565
|
+
*/
|
|
2566
|
+
conformant: "urn:epcglobal:cbv:disp:conformant",
|
|
2567
|
+
non_conformant: "urn:epcglobal:cbv:disp:non_conformant"
|
|
2568
|
+
};
|
|
2569
|
+
var CBV_BIZSTEP = {
|
|
2570
|
+
/** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
|
|
2571
|
+
consuming: "urn:epcglobal:cbv:bizstep:consuming",
|
|
2572
|
+
/** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
|
|
2573
|
+
commissioning: "urn:epcglobal:cbv:bizstep:commissioning",
|
|
2574
|
+
/**
|
|
2575
|
+
* **검사** — CBV `inspecting`. 1차 출처(CBV 2.0) 정의: 「Process of reviewing objects to address
|
|
2576
|
+
* potential physical or documentation defects」이고, 「표본과 달리 검사된 대상은 그대로 남는다」고
|
|
2577
|
+
* 이어진다(즉 검사는 물건을 소비하지 않는다).
|
|
2578
|
+
*
|
|
2579
|
+
* 이 단계에 `DISP.conformant`/`DISP.non_conformant` 가 붙어 판정이 처분으로 남는다 — 입고검수·
|
|
2580
|
+
* 공정 중 검사가 그 모양이다.
|
|
2581
|
+
*/
|
|
2582
|
+
inspecting: "urn:epcglobal:cbv:bizstep:inspecting",
|
|
2583
|
+
/**
|
|
2584
|
+
* **어느 단계로도 이름 붙지 않는 활동** — 원문: 「A business step not identified by any other」.
|
|
2585
|
+
*
|
|
2586
|
+
* 이것은 근사가 아니라 **표준이 준 낱말**이다. 그 구별이 중요하다: 재고 조정처럼 한 낱말이 두 일을
|
|
2587
|
+
* 하는 원천(세어 보고 맞춘 것 · 사람이 정정한 것)을 `cycle_counting` 으로 옮기면 **일어나지 않은
|
|
2588
|
+
* 계수를 기록**하게 된다. 그때 쓰는 것이 이 값이고, 무슨 일이었는지는 값으로 함께 나른다.
|
|
2589
|
+
*
|
|
2590
|
+
* 원문 확인: CBV Standard Release 2.0(Ratified Jun 2022) §7.1.
|
|
2591
|
+
*/
|
|
2592
|
+
other: "urn:epcglobal:cbv:bizstep:other"
|
|
2593
|
+
};
|
|
2594
|
+
var EPCIS_EVENT = {
|
|
2595
|
+
object: "epcis.ObjectEvent",
|
|
2596
|
+
aggregation: "epcis.AggregationEvent",
|
|
2597
|
+
transaction: "epcis.TransactionEvent",
|
|
2598
|
+
transformation: "epcis.TransformationEvent"
|
|
2599
|
+
};
|
|
2600
|
+
function isEpcisEventType(eventType) {
|
|
2601
|
+
return typeof eventType === "string" && eventType.startsWith("epcis.");
|
|
2602
|
+
}
|
|
2603
|
+
function ssccUri(companyPrefix, serial) {
|
|
2604
|
+
return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
|
|
2605
|
+
}
|
|
2606
|
+
function sgtinClass(companyPrefix, itemRef) {
|
|
2607
|
+
return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
|
|
2608
|
+
}
|
|
2609
|
+
var ILMD_ATTR = {
|
|
2610
|
+
/** 유통기한·만료(로트 단위). */
|
|
2611
|
+
expiry: "cbvmda:itemExpirationDate",
|
|
2612
|
+
/** 로트·배치 번호(직렬 개체에 로트를 붙일 때). */
|
|
2613
|
+
lot: "cbvmda:lotNumber"
|
|
2614
|
+
};
|
|
2615
|
+
function lgtinClass(companyPrefix, itemRefAndIndicator, lot) {
|
|
2616
|
+
return `urn:epc:class:lgtin:${companyPrefix}.${itemRefAndIndicator}.${encodeURIComponent(lot)}`;
|
|
2617
|
+
}
|
|
2618
|
+
function parseEpc(uri) {
|
|
2619
|
+
const raw = String(uri ?? "");
|
|
2620
|
+
if (/(?:\/obj\/|:obj:)[^/:\s]+$/.test(raw)) return { scheme: "unknown", instance: true, uri: raw };
|
|
2621
|
+
if (/(?:\/class\/|:class:)[^/:\s]+$/.test(raw)) return { scheme: "unknown", instance: false, uri: raw };
|
|
2622
|
+
const cls = raw.match(/^urn:epc:class:lgtin:(.+)$/);
|
|
2623
|
+
if (cls) {
|
|
2624
|
+
const seg = cls[1].split(".");
|
|
2625
|
+
const lot = seg.slice(2).join(".");
|
|
2626
|
+
return {
|
|
2627
|
+
scheme: "lgtin",
|
|
2628
|
+
instance: false,
|
|
2629
|
+
gtinKey: seg.slice(0, 2).join("."),
|
|
2630
|
+
lot: lot ? decodeURIComponent(lot) : void 0,
|
|
2631
|
+
uri: raw
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
const pat = raw.match(/^urn:epc:idpat:sgtin:(.+)$/);
|
|
2635
|
+
if (pat) {
|
|
2636
|
+
const seg = pat[1].split(".");
|
|
2637
|
+
return { scheme: "idpat", instance: false, gtinKey: seg.slice(0, 2).join("."), uri: raw };
|
|
2638
|
+
}
|
|
2639
|
+
const id = raw.match(/^urn:epc:id:([a-z]+):(.+)$/);
|
|
2640
|
+
if (id) {
|
|
2641
|
+
const scheme = id[1];
|
|
2642
|
+
const seg = id[2].split(".");
|
|
2643
|
+
const known = ["sgtin", "sscc", "gdti", "grai", "giai", "sgln"].includes(scheme);
|
|
2644
|
+
return {
|
|
2645
|
+
scheme: known ? scheme : "unknown",
|
|
2646
|
+
instance: true,
|
|
2647
|
+
...scheme === "sgtin" ? { gtinKey: seg.slice(0, 2).join("."), serial: seg[2] } : { serial: seg.slice(1).join(".") },
|
|
2648
|
+
uri: raw
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
return { scheme: "unknown", instance: false, uri: raw };
|
|
2652
|
+
}
|
|
2653
|
+
function gdtiUri(companyPrefix, docType, serial) {
|
|
2654
|
+
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
2655
|
+
}
|
|
2656
|
+
function objectUri(namespace, objId) {
|
|
2657
|
+
return underNamespace(namespace, "obj", objId);
|
|
2658
|
+
}
|
|
2659
|
+
function bizTransactionUri(namespace, transId) {
|
|
2660
|
+
return underNamespace(namespace, "bt", transId);
|
|
2661
|
+
}
|
|
2662
|
+
function underNamespace(namespace, marker, id) {
|
|
2663
|
+
const ns = namespace?.trim();
|
|
2664
|
+
if (!ns) return void 0;
|
|
2665
|
+
const v = String(id);
|
|
2666
|
+
if (!v || v.includes("/") || v.includes(":")) return void 0;
|
|
2667
|
+
if (/^https?:\/\/[^/\s]+/.test(ns)) return `${ns.replace(/\/+$/, "")}/${marker}/${v}`;
|
|
2668
|
+
if (/^urn:epc(global)?:/.test(ns)) return void 0;
|
|
2669
|
+
if (/^urn:[^:\s]+/.test(ns)) return `${ns.replace(/:+$/, "")}:${marker}:${v}`;
|
|
2670
|
+
return void 0;
|
|
2671
|
+
}
|
|
2672
|
+
function header(type, eventTime, bizStep, opts) {
|
|
2673
|
+
const h = {
|
|
2674
|
+
"@context": EPCIS_CONTEXT,
|
|
2675
|
+
type,
|
|
2676
|
+
eventTime,
|
|
2677
|
+
eventTimeZoneOffset: UTC_OFFSET,
|
|
2678
|
+
bizStep
|
|
2679
|
+
};
|
|
2680
|
+
if (opts?.eventID) h.eventID = opts.eventID;
|
|
2681
|
+
if (opts?.recordTime) h.recordTime = opts.recordTime;
|
|
2682
|
+
if (opts?.errorDeclaration) h.errorDeclaration = opts.errorDeclaration;
|
|
2683
|
+
if (opts?.ilmd) h.ilmd = opts.ilmd;
|
|
2684
|
+
if (opts?.sourceList) h.sourceList = opts.sourceList;
|
|
2685
|
+
if (opts?.destinationList) h.destinationList = opts.destinationList;
|
|
2686
|
+
if (opts?.persistentDisposition) h.persistentDisposition = opts.persistentDisposition;
|
|
2687
|
+
if (opts?.sensorElementList) h.sensorElementList = opts.sensorElementList;
|
|
2688
|
+
if (opts?.certificationInfo) h.certificationInfo = opts.certificationInfo;
|
|
2689
|
+
return h;
|
|
2690
|
+
}
|
|
2691
|
+
function common(type, eventTime, action, bizStep, opts) {
|
|
2692
|
+
return { ...header(type, eventTime, bizStep, opts), action };
|
|
2693
|
+
}
|
|
2694
|
+
function objectEvent(p) {
|
|
2695
|
+
const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep, p), epcList: p.epcList };
|
|
2696
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
2697
|
+
if (p.quantityList) e.quantityList = p.quantityList;
|
|
2698
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
2699
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
2700
|
+
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
2701
|
+
return e;
|
|
2702
|
+
}
|
|
2703
|
+
function aggregationEvent(p) {
|
|
2704
|
+
const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep, p), parentID: p.parentID };
|
|
2705
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
2706
|
+
if (p.childEPCs) e.childEPCs = p.childEPCs;
|
|
2707
|
+
if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
|
|
2708
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
2709
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
2710
|
+
return e;
|
|
2711
|
+
}
|
|
2712
|
+
function transactionEvent(p) {
|
|
2713
|
+
const e = {
|
|
2714
|
+
...common("TransactionEvent", p.eventTime, p.action, p.bizStep, p),
|
|
2715
|
+
bizTransactionList: p.bizTransactionList
|
|
2716
|
+
};
|
|
2717
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
2718
|
+
if (p.parentID) e.parentID = p.parentID;
|
|
2719
|
+
if (p.epcList) e.epcList = p.epcList;
|
|
2720
|
+
if (p.quantityList) e.quantityList = p.quantityList;
|
|
2721
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
2722
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
2723
|
+
return e;
|
|
2724
|
+
}
|
|
2725
|
+
function transformationEvent(p) {
|
|
2726
|
+
const e = header("TransformationEvent", p.eventTime, p.bizStep, p);
|
|
2727
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
2728
|
+
if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
|
|
2729
|
+
if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
|
|
2730
|
+
if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
|
|
2731
|
+
if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
|
|
2732
|
+
if (p.transformationID) e.transformationID = p.transformationID;
|
|
2733
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
2734
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
2735
|
+
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
2736
|
+
return e;
|
|
2737
|
+
}
|
|
2738
|
+
var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
2739
|
+
var TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
2740
|
+
var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
|
|
2741
|
+
var EPC_CLASS_PREFIXES = ["urn:epc:idpat:", "urn:epc:class:"];
|
|
2742
|
+
var DL_CANONICAL = "https://id.gs1.org/";
|
|
2743
|
+
var CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
|
|
2744
|
+
var CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
|
|
2745
|
+
var GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
|
|
2746
|
+
function gs1KeyDigitViolation(uri) {
|
|
2747
|
+
if (!uri) return void 0;
|
|
2748
|
+
const m = /^urn:epc:(?:id|idpat|class):([a-z]+):([^:]+)$/.exec(uri);
|
|
2749
|
+
if (!m) return void 0;
|
|
2750
|
+
const want = GS1_KEY_DIGITS[m[1]];
|
|
2751
|
+
if (!want) return void 0;
|
|
2752
|
+
const parts = m[2].split(".");
|
|
2753
|
+
if (parts.length < 2) return `${m[1]} \uD615\uC2DD \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4\uC640 \uCC38\uC870\uAC00 '.' \uB85C \uAC08\uB824\uC57C \uD55C\uB2E4`;
|
|
2754
|
+
const [prefix, ref] = parts;
|
|
2755
|
+
if (ref === "*") return void 0;
|
|
2756
|
+
if (!/^\d+$/.test(prefix) || !/^\d+$/.test(ref)) {
|
|
2757
|
+
return `${m[1]} \uD615\uC2DD \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4\uC640 \uCC38\uC870\uB294 \uC22B\uC790\uB2E4`;
|
|
2758
|
+
}
|
|
2759
|
+
const got = prefix.length + ref.length;
|
|
2760
|
+
if (got === want) return void 0;
|
|
2761
|
+
return `${m[1]} \uC790\uB9AC \uC218 \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4(${prefix.length}) + \uCC38\uC870(${ref.length}) = ${got} \uC774\uC9C0\uB9CC ${want} \uC5EC\uC57C \uD55C\uB2E4(GS1 TDS). \uD504\uB9AC\uD53D\uC2A4\uB97C \uBC14\uAFB8\uBA74 \uCC38\uC870 \uC790\uB9AC \uC218\uB97C \uD568\uAED8 \uB9DE\uCDB0\uC57C \uD55C\uB2E4.`;
|
|
2762
|
+
}
|
|
2763
|
+
function classIdentifierViolation(epcClass) {
|
|
2764
|
+
if (!epcClass) return `quantity epcClass \uBD80\uC815: ${epcClass}`;
|
|
2765
|
+
const digits = gs1KeyDigitViolation(epcClass);
|
|
2766
|
+
if (digits) return `quantity epcClass \uBD80\uC815: ${digits}`;
|
|
2767
|
+
if (EPC_CLASS_PREFIXES.some((p) => epcClass.startsWith(p))) return void 0;
|
|
2768
|
+
if (epcClass.startsWith(DL_CANONICAL)) return void 0;
|
|
2769
|
+
if (CBV_URL_CLASS.test(epcClass) || CBV_URN_CLASS.test(epcClass)) return void 0;
|
|
2770
|
+
return `quantity epcClass \uBD80\uC815: ${epcClass} \u2014 \uD074\uB798\uC2A4 \uC2DD\uBCC4\uC790\uB294 \uC18C\uC720 \uAD8C\uD55C\uC744 \uB9D0\uD560 \uC218 \uC788\uB294 \uBAA8\uC591\uC774\uC5B4\uC57C \uD55C\uB2E4 (EPC \uD074\uB798\uC2A4 urn:epc:idpat:/urn:epc:class: \xB7 CBV \xA78.3.4 http(s)://<\uB3C4\uBA54\uC778>/**/class/<id> \xB7 CBV \xA78.3.3 urn:<\uC774\uB984\uACF5\uAC04>:**:class:<id>)`;
|
|
2771
|
+
}
|
|
2772
|
+
function validateEpcisEvent(e) {
|
|
2773
|
+
const v = [];
|
|
2774
|
+
for (const [field, list] of [
|
|
2775
|
+
["epcList", e.epcList],
|
|
2776
|
+
["childEPCs", e.childEPCs],
|
|
2777
|
+
["inputEPCList", e.inputEPCList],
|
|
2778
|
+
["outputEPCList", e.outputEPCList],
|
|
2779
|
+
["parentID", e.parentID ? [e.parentID] : void 0]
|
|
2780
|
+
]) {
|
|
2781
|
+
for (const epc of list ?? []) {
|
|
2782
|
+
const bad2 = gs1KeyDigitViolation(epc);
|
|
2783
|
+
if (bad2) v.push(`${field}: ${bad2}`);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
|
|
2787
|
+
if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
|
|
2788
|
+
if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
|
|
2789
|
+
if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
|
|
2790
|
+
if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
|
|
2791
|
+
if (e.eventID !== void 0 && (typeof e.eventID !== "string" || !e.eventID)) v.push("eventID \uAC00 \uBE48 \uBB38\uC790\uC5F4");
|
|
2792
|
+
if (e.recordTime !== void 0 && (typeof e.recordTime !== "string" || !ISO_RE.test(e.recordTime))) {
|
|
2793
|
+
v.push("recordTime ISO8601 \uC544\uB2D8");
|
|
2794
|
+
}
|
|
2795
|
+
if (e.ilmd !== void 0) {
|
|
2796
|
+
const allowed = e.type === "ObjectEvent" && e.action === "ADD" || e.type === "TransformationEvent";
|
|
2797
|
+
if (!allowed) v.push("ilmd \uB294 ObjectEvent(action=ADD) \uB610\uB294 TransformationEvent \uC5D0\uB9CC \uC2E4\uC744 \uC218 \uC788\uB2E4");
|
|
2798
|
+
}
|
|
2799
|
+
for (const sd of e.sourceList ?? []) {
|
|
2800
|
+
if (!sd?.type || !sd?.source) v.push("sourceList \uD56D\uBAA9\uC5D0 type \uB610\uB294 source \uB204\uB77D");
|
|
2801
|
+
}
|
|
2802
|
+
for (const sd of e.destinationList ?? []) {
|
|
2803
|
+
if (!sd?.type || !sd?.destination) v.push("destinationList \uD56D\uBAA9\uC5D0 type \uB610\uB294 destination \uB204\uB77D");
|
|
2804
|
+
}
|
|
2805
|
+
if (e.persistentDisposition !== void 0) {
|
|
2806
|
+
const set = e.persistentDisposition.set ?? [];
|
|
2807
|
+
const unset = e.persistentDisposition.unset ?? [];
|
|
2808
|
+
if (!set.length && !unset.length) v.push("persistentDisposition \uC774 set\xB7unset \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
2809
|
+
const both = set.filter((x) => unset.includes(x));
|
|
2810
|
+
if (both.length) v.push(`persistentDisposition \uC774 \uAC19\uC740 \uAC12\uC744 set\xB7unset \uB3D9\uC2DC \uC9C0\uC815: ${both.join(", ")}`);
|
|
2811
|
+
}
|
|
2812
|
+
for (const se of e.sensorElementList ?? []) {
|
|
2813
|
+
if (!Array.isArray(se?.sensorReport) || se.sensorReport.length === 0) {
|
|
2814
|
+
v.push("sensorElement \uC5D0 sensorReport \uAC00 \uD558\uB098\uB3C4 \uC5C6\uC74C");
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
if (e.errorDeclaration !== void 0) {
|
|
2818
|
+
const d = e.errorDeclaration;
|
|
2819
|
+
if (typeof d?.declarationTime !== "string" || !ISO_RE.test(d.declarationTime)) {
|
|
2820
|
+
v.push("errorDeclaration.declarationTime ISO8601 \uC544\uB2D8/\uB204\uB77D");
|
|
2821
|
+
}
|
|
2822
|
+
if (d?.correctiveEventIDs !== void 0) {
|
|
2823
|
+
if (!Array.isArray(d.correctiveEventIDs)) v.push("errorDeclaration.correctiveEventIDs \uBC30\uC5F4 \uC544\uB2D8");
|
|
2824
|
+
else if (d.correctiveEventIDs.some((x) => typeof x !== "string" || !x)) {
|
|
2825
|
+
v.push("errorDeclaration.correctiveEventIDs \uC5D0 \uBE48 \uAC12");
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
if (e.type === "ObjectEvent") {
|
|
2830
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
2831
|
+
if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
|
|
2832
|
+
else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
2833
|
+
} else if (e.type === "AggregationEvent") {
|
|
2834
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
2835
|
+
if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
|
|
2836
|
+
if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
2837
|
+
} else if (e.type === "TransactionEvent") {
|
|
2838
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
2839
|
+
if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
|
|
2840
|
+
} else if (e.type === "TransformationEvent") {
|
|
2841
|
+
if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
|
|
2842
|
+
if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
|
|
2843
|
+
}
|
|
2844
|
+
const qtyLists = [
|
|
2845
|
+
"quantityList" in e ? e.quantityList : void 0,
|
|
2846
|
+
"childQuantityList" in e ? e.childQuantityList : void 0,
|
|
2847
|
+
"inputQuantityList" in e ? e.inputQuantityList : void 0,
|
|
2848
|
+
"outputQuantityList" in e ? e.outputQuantityList : void 0
|
|
2849
|
+
];
|
|
2850
|
+
for (const list of qtyLists) for (const q of list ?? []) {
|
|
2851
|
+
const bad2 = classIdentifierViolation(q.epcClass);
|
|
2852
|
+
if (bad2) v.push(bad2);
|
|
2853
|
+
const hasQty = q.quantity !== void 0 && q.quantity !== null;
|
|
2854
|
+
if (!hasQty) {
|
|
2855
|
+
if (q.uom !== void 0) v.push("quantity \uC5C6\uC73C\uBA74 uom \uB3C4 \uC5C6\uC5B4\uC57C \uD55C\uB2E4(\uC218\uB7C9 \uBBF8\uC9C0\uC815)");
|
|
2856
|
+
continue;
|
|
2857
|
+
}
|
|
2858
|
+
if (typeof q.quantity !== "number" || !Number.isFinite(q.quantity) || q.quantity <= 0) {
|
|
2859
|
+
v.push("quantity \uB294 \uC591\uC218\uC5EC\uC57C \uD55C\uB2E4(\uBAA8\uB974\uBA74 \uC0DD\uB7B5)");
|
|
2860
|
+
continue;
|
|
2861
|
+
}
|
|
2862
|
+
if (q.uom === void 0 && !Number.isInteger(q.quantity)) v.push("uom \uC5C6\uB294 quantity \uB294 \uC815\uC218(\uAC1C\uC218)\uC5EC\uC57C \uD55C\uB2E4");
|
|
2863
|
+
if (q.uom !== void 0 && !/^[A-Z0-9]{2,3}$/.test(q.uom)) v.push(`uom \uD615\uC2DD \uBD80\uC815(UN/CEFACT \uAD8C\uACE0 20 \uC758 2~3\uC790 \uCF54\uB4DC): ${q.uom}`);
|
|
2864
|
+
}
|
|
2865
|
+
return v;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// src/face2-adapter.ts
|
|
2869
|
+
function isAggregationRecord(record) {
|
|
2870
|
+
if (!record || typeof record !== "object") return false;
|
|
2871
|
+
const r = record;
|
|
2872
|
+
return typeof r.parentID === "string" && r.parentID.trim().length > 0 && r.epc === void 0;
|
|
2873
|
+
}
|
|
2874
|
+
function isTransformationRecord(record) {
|
|
2875
|
+
if (!record || typeof record !== "object") return false;
|
|
2876
|
+
const r = record;
|
|
2877
|
+
if (r.epc !== void 0 || r.parentID !== void 0) return false;
|
|
2878
|
+
const has = (k) => Array.isArray(r[k]) && r[k].length > 0;
|
|
2879
|
+
return has("inputEPCList") || has("inputQuantityList") || has("outputEPCList") || has("outputQuantityList");
|
|
2880
|
+
}
|
|
2881
|
+
function get(obj, path) {
|
|
2882
|
+
return path.split(".").reduce((o, k) => o == null ? o : o[k], obj);
|
|
2883
|
+
}
|
|
2884
|
+
function resolve(v, record, field, errors) {
|
|
2885
|
+
if (v === void 0) return void 0;
|
|
2886
|
+
if (!v.startsWith("$.")) return v;
|
|
2887
|
+
const r = get(record, v.slice(2));
|
|
2888
|
+
if (r === void 0 || r === null) return void 0;
|
|
2889
|
+
if (typeof r === "object") {
|
|
2890
|
+
errors.push(`${field} \uAC00 \uAC12\uC774 \uC544\uB2C8\uB77C ${Array.isArray(r) ? "\uBC30\uC5F4" : "\uAC1D\uCCB4"}\uB85C \uC654\uB2E4(${v}) \u2014 \uCEE4\uB125\uD130\uAC00 \uD45C\uC900 \uAD6C\uC870\uB97C \uADF8\uB300\uB85C \uC2E4\uC5C8\uB2E4: ${JSON.stringify(r).slice(0, 80)}`);
|
|
2891
|
+
return void 0;
|
|
2892
|
+
}
|
|
2893
|
+
return String(r);
|
|
2894
|
+
}
|
|
2895
|
+
function resolveList(v, record, field, errors) {
|
|
2896
|
+
if (v === void 0) return [];
|
|
2897
|
+
const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
|
|
2898
|
+
if (r === void 0 || r === null) return [];
|
|
2899
|
+
if (!Array.isArray(r) || r.some((x) => typeof x !== "string")) {
|
|
2900
|
+
errors.push(`${field} \uAC00 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
|
|
2901
|
+
return [];
|
|
2902
|
+
}
|
|
2903
|
+
return r.slice();
|
|
2904
|
+
}
|
|
2905
|
+
function resolveQuantityList(v, record, field, errors) {
|
|
2906
|
+
if (v === void 0) return [];
|
|
2907
|
+
const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
|
|
2908
|
+
if (r === void 0 || r === null) return [];
|
|
2909
|
+
if (!Array.isArray(r) || r.some((x) => !x || typeof x !== "object" || Array.isArray(x))) {
|
|
2910
|
+
errors.push(`${field} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
|
|
2911
|
+
return [];
|
|
2912
|
+
}
|
|
2913
|
+
return r.map((x) => ({ ...x }));
|
|
2914
|
+
}
|
|
2915
|
+
function resolveObject(v, record, field, errors) {
|
|
2916
|
+
if (v === void 0) return void 0;
|
|
2917
|
+
const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
|
|
2918
|
+
if (r === void 0 || r === null) return void 0;
|
|
2919
|
+
if (typeof r !== "object" || Array.isArray(r)) {
|
|
2920
|
+
errors.push(`${field} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
|
|
2921
|
+
return void 0;
|
|
2922
|
+
}
|
|
2923
|
+
const o = { ...r };
|
|
2924
|
+
return Object.keys(o).length ? o : void 0;
|
|
2925
|
+
}
|
|
2926
|
+
function resolveErrorDeclaration(spec, record, errors) {
|
|
2927
|
+
if (!spec) return void 0;
|
|
2928
|
+
const declarationTime = resolve(spec.declarationTime, record, "errorDeclaration.declarationTime", errors);
|
|
2929
|
+
const reason = resolve(spec.reason, record, "errorDeclaration.reason", errors);
|
|
2930
|
+
const correctiveEventIDs = resolveList(spec.correctiveEventIDs, record, "errorDeclaration.correctiveEventIDs", errors);
|
|
2931
|
+
if (!declarationTime) {
|
|
2932
|
+
if (reason || correctiveEventIDs.length) {
|
|
2933
|
+
errors.push("errorDeclaration \uC774 \uBC18\uCBE4 \uC654\uB2E4 \u2014 \uC774\uC720\xB7\uC815\uC815 \uB300\uC0C1\uC740 \uC788\uB294\uB370 declarationTime \uC774 \uC5C6\uB2E4(\uC815\uC815 \uC2DC\uAC01\uC740 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4)");
|
|
2934
|
+
}
|
|
2935
|
+
return void 0;
|
|
2936
|
+
}
|
|
2937
|
+
return {
|
|
2938
|
+
declarationTime,
|
|
2939
|
+
...reason ? { reason } : {},
|
|
2940
|
+
...correctiveEventIDs.length ? { correctiveEventIDs } : {}
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
function mapRecordChecked(record, mapping, eventTime) {
|
|
2944
|
+
const errors = [];
|
|
2945
|
+
const bizStep = resolve(mapping.bizStep, record, "bizStep", errors) ?? "";
|
|
2946
|
+
const disposition = resolve(mapping.disposition, record, "disposition", errors);
|
|
2947
|
+
const readPoint = resolve(mapping.readPoint, record, "readPoint", errors);
|
|
2948
|
+
const bizLocation = resolve(mapping.bizLocation, record, "bizLocation", errors);
|
|
2949
|
+
const errorDeclaration = resolveErrorDeclaration(mapping.errorDeclaration, record, errors);
|
|
2950
|
+
if (mapping.type === "TransformationEvent") {
|
|
2951
|
+
const inputEPCList = resolveList(mapping.inputEPCList, record, "inputEPCList", errors);
|
|
2952
|
+
const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, "inputQuantityList", errors);
|
|
2953
|
+
const outputEPCList = resolveList(mapping.outputEPCList, record, "outputEPCList", errors);
|
|
2954
|
+
const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, "outputQuantityList", errors);
|
|
2955
|
+
const transformationID = resolve(mapping.transformationID, record, "transformationID", errors);
|
|
2956
|
+
if (!inputEPCList.length && !inputQuantityList.length) {
|
|
2957
|
+
errors.push("\uC785\uB825\uC774 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uBB34\uC5C7\uC774 \uB4E4\uC5B4\uAC14\uB294\uC9C0 \uB9D0\uD574\uC57C \uD55C\uB2E4(EPCIS 2.0 \xA77.4.5)");
|
|
2958
|
+
}
|
|
2959
|
+
if (!outputEPCList.length && !outputQuantityList.length) {
|
|
2960
|
+
errors.push("\uCD9C\uB825\uC774 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uBB34\uC5C7\uC774 \uB098\uC654\uB294\uC9C0 \uB9D0\uD574\uC57C \uD55C\uB2E4(EPCIS 2.0 \xA77.4.5)");
|
|
2961
|
+
}
|
|
2962
|
+
return {
|
|
2963
|
+
event: transformationEvent({
|
|
2964
|
+
eventTime,
|
|
2965
|
+
bizStep,
|
|
2966
|
+
disposition,
|
|
2967
|
+
/* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
|
|
2968
|
+
개체로 말한 쪽과 수량으로 말한 쪽 중 하나만 쓰는 것이 정상이다. */
|
|
2969
|
+
...inputEPCList.length ? { inputEPCList } : {},
|
|
2970
|
+
...inputQuantityList.length ? { inputQuantityList } : {},
|
|
2971
|
+
...outputEPCList.length ? { outputEPCList } : {},
|
|
2972
|
+
...outputQuantityList.length ? { outputQuantityList } : {},
|
|
2973
|
+
...transformationID ? { transformationID } : {},
|
|
2974
|
+
...errorDeclaration ? { errorDeclaration } : {},
|
|
2975
|
+
readPoint,
|
|
2976
|
+
bizLocation
|
|
2977
|
+
}),
|
|
2978
|
+
errors
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
if (mapping.type === "AggregationEvent") {
|
|
2982
|
+
const action2 = resolve(mapping.action, record, "action", errors) ?? "";
|
|
2983
|
+
const parentID = resolve(mapping.parentID, record, "parentID", errors) ?? "";
|
|
2984
|
+
const childEPCs = resolveList(mapping.childEPCs, record, "childEPCs", errors);
|
|
2985
|
+
const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, "childQuantityList", errors);
|
|
2986
|
+
return {
|
|
2987
|
+
event: aggregationEvent({
|
|
2988
|
+
eventTime,
|
|
2989
|
+
action: action2,
|
|
2990
|
+
bizStep,
|
|
2991
|
+
disposition,
|
|
2992
|
+
parentID,
|
|
2993
|
+
/* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「자식이 없다」고 말하는 것이 된다. */
|
|
2994
|
+
...childEPCs.length ? { childEPCs } : {},
|
|
2995
|
+
...childQuantityList.length ? { childQuantityList } : {},
|
|
2996
|
+
...errorDeclaration ? { errorDeclaration } : {},
|
|
2997
|
+
readPoint,
|
|
2998
|
+
bizLocation
|
|
2999
|
+
}),
|
|
3000
|
+
errors
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
const action = resolve(mapping.action, record, "action", errors) ?? "";
|
|
3004
|
+
const epc = resolve(mapping.epc, record, "epc", errors);
|
|
3005
|
+
const quantityList = resolveQuantityList(mapping.quantityList, record, "quantityList", errors);
|
|
3006
|
+
if (!epc && !quantityList.length) {
|
|
3007
|
+
errors.push("epc \uC640 quantityList \uAC00 \uB458 \uB2E4 \uBE44\uC5C8\uB2E4 \u2014 \uAC1C\uCCB4 \uC2DD\uBCC4\uC790\uB098 \uD074\uB798\uC2A4+\uC218\uB7C9 \uC911 \uD558\uB098\uB294 \uC788\uC5B4\uC57C \uD55C\uB2E4");
|
|
3008
|
+
}
|
|
3009
|
+
const ilmd = resolveObject(mapping.ilmd, record, "ilmd", errors);
|
|
3010
|
+
return {
|
|
3011
|
+
event: objectEvent({
|
|
3012
|
+
eventTime,
|
|
3013
|
+
action,
|
|
3014
|
+
bizStep,
|
|
3015
|
+
disposition,
|
|
3016
|
+
epcList: epc ? [epc] : [],
|
|
3017
|
+
...quantityList.length ? { quantityList } : {},
|
|
3018
|
+
...ilmd ? { ilmd } : {},
|
|
3019
|
+
...errorDeclaration ? { errorDeclaration } : {},
|
|
3020
|
+
readPoint,
|
|
3021
|
+
bizLocation
|
|
3022
|
+
}),
|
|
3023
|
+
errors
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
function mapRecord(record, mapping, eventTime) {
|
|
3027
|
+
return mapRecordChecked(record, mapping, eventTime).event;
|
|
3028
|
+
}
|
|
3029
|
+
function ingest(records, rules, opts) {
|
|
3030
|
+
const ruleByType = new Map(rules.map((r) => [r.sourceType, r]));
|
|
3031
|
+
const accepted = [];
|
|
3032
|
+
const rejected = [];
|
|
3033
|
+
let seq = 0;
|
|
3034
|
+
for (const record of records) {
|
|
3035
|
+
const rule = ruleByType.get(String(record["sourceType"]));
|
|
3036
|
+
if (!rule) {
|
|
3037
|
+
rejected.push({ record, errors: [`\uB9E4\uCE6D \uB8F0 \uC5C6\uC74C: sourceType=${record["sourceType"]}`] });
|
|
3038
|
+
continue;
|
|
3039
|
+
}
|
|
3040
|
+
const timeErrors = [];
|
|
3041
|
+
const eventTime = (opts.eventTimePath ? resolve("$." + opts.eventTimePath, record, "eventTime", timeErrors) : void 0) ?? opts.defaultEventTime;
|
|
3042
|
+
const { event: ev, errors: mapErrors } = mapRecordChecked(record, rule.mapping, eventTime);
|
|
3043
|
+
const errors = [...timeErrors, ...mapErrors, ...validateEpcisEvent(ev)];
|
|
3044
|
+
if (errors.length) {
|
|
3045
|
+
rejected.push({ record, errors });
|
|
3046
|
+
continue;
|
|
3047
|
+
}
|
|
3048
|
+
const note = typeof record["description"] === "string" ? String(record["description"]).trim() : "";
|
|
3049
|
+
accepted.push({
|
|
3050
|
+
eventId: `${opts.tenantId}-ingest-${++seq}`,
|
|
3051
|
+
eventType: `epcis.${ev.type}`,
|
|
3052
|
+
eventTime: ev.eventTime,
|
|
3053
|
+
tenantId: opts.tenantId,
|
|
3054
|
+
...note ? { description: note } : {},
|
|
3055
|
+
data: ev
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
return { accepted, rejected };
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/iso-duration.ts
|
|
3062
|
+
var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
3063
|
+
function parseIsoDuration(text) {
|
|
3064
|
+
if (typeof text !== "string") return void 0;
|
|
3065
|
+
const s = text.trim();
|
|
3066
|
+
if (!s || s === "P" || s === "PT") return void 0;
|
|
3067
|
+
if (/\d+Y/.test(s)) return void 0;
|
|
3068
|
+
const tIdx = s.indexOf("T");
|
|
3069
|
+
const datePart = tIdx === -1 ? s : s.slice(0, tIdx);
|
|
3070
|
+
if (/\d+M/.test(datePart)) return void 0;
|
|
3071
|
+
const m = RE.exec(s);
|
|
3072
|
+
if (!m) return void 0;
|
|
3073
|
+
const [, sign, w, d, h, min, sec] = m;
|
|
3074
|
+
if (!w && !d && !h && !min && !sec) return void 0;
|
|
3075
|
+
const ms = (Number(w ?? 0) * 7 + Number(d ?? 0)) * 864e5 + Number(h ?? 0) * 36e5 + Number(min ?? 0) * 6e4 + Number(sec ?? 0) * 1e3;
|
|
3076
|
+
if (!Number.isFinite(ms)) return void 0;
|
|
3077
|
+
return sign ? -ms : ms;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// src/master-data.ts
|
|
3081
|
+
var VOCABULARY_TYPE = {
|
|
3082
|
+
/** 품목·로트 클래스의 속성 — 유통기한·로트번호가 여기 온다. */
|
|
3083
|
+
epcClass: "urn:epcglobal:epcis:vtype:EPCClass",
|
|
3084
|
+
/** 관측 지점의 속성. */
|
|
3085
|
+
readPoint: "urn:epcglobal:epcis:vtype:ReadPoint",
|
|
3086
|
+
/** 업무 자리의 속성. */
|
|
3087
|
+
bizLocation: "urn:epcglobal:epcis:vtype:BizLocation"
|
|
3088
|
+
};
|
|
3089
|
+
function isMasterDataRecord(record) {
|
|
3090
|
+
if (!record || typeof record !== "object") return false;
|
|
3091
|
+
const r = record;
|
|
3092
|
+
return typeof r.vocabularyType === "string" && !!r.vocabularyType && typeof r.id === "string" && !!r.id && isPlainObject(r.attributes);
|
|
3093
|
+
}
|
|
3094
|
+
function isPlainObject(v) {
|
|
3095
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3096
|
+
}
|
|
3097
|
+
function ingestMasterData(records) {
|
|
3098
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
3099
|
+
const accepted = [];
|
|
3100
|
+
const rejected = [];
|
|
3101
|
+
for (const r of arr) {
|
|
3102
|
+
const errors = [];
|
|
3103
|
+
if (!isMasterDataRecord(r)) errors.push("vocabularyType \xB7 id \xB7 attributes \uAC00 \uC788\uC5B4\uC57C \uD55C\uB2E4");
|
|
3104
|
+
else if (!Object.keys(r.attributes).length) errors.push("attributes \uAC00 \uBE44\uC5C8\uB2E4 \u2014 \uC544\uBB34 \uB73B\uC774 \uC5C6\uB294 \uC9C4\uC220\uC774\uB2E4");
|
|
3105
|
+
if (errors.length) rejected.push({ record: r, errors });
|
|
3106
|
+
else accepted.push({ vocabularyType: r.vocabularyType, id: r.id, attributes: { ...r.attributes } });
|
|
3107
|
+
}
|
|
3108
|
+
return { accepted, rejected };
|
|
3109
|
+
}
|
|
3110
|
+
function expiryFromAttributes(attrs) {
|
|
3111
|
+
return readEpochMs(attrs?.[ILMD_ATTR.expiry]);
|
|
3112
|
+
}
|
|
3113
|
+
function lotFromAttributes(attrs) {
|
|
3114
|
+
const v = attrs?.[ILMD_ATTR.lot];
|
|
3115
|
+
return typeof v === "string" && v.trim() ? v.trim() : void 0;
|
|
3116
|
+
}
|
|
3117
|
+
function readEpochMs(raw) {
|
|
3118
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
|
|
3119
|
+
if (typeof raw !== "string" || !raw.trim()) return void 0;
|
|
3120
|
+
const at = Date.parse(raw);
|
|
3121
|
+
return Number.isNaN(at) ? void 0 : at;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// src/operational-ingest.ts
|
|
3125
|
+
var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
|
|
3126
|
+
var EQUIPMENT_STATUS = ["idle", "busy", "down"];
|
|
3127
|
+
var PERSON_STATUS = ["idle", "busy"];
|
|
3128
|
+
var ASSET_STATUS = ["idle", "in-use"];
|
|
3129
|
+
var SPECS = {
|
|
3130
|
+
task: {
|
|
3131
|
+
eventType: OP_EVENT.task,
|
|
3132
|
+
identity: "taskId",
|
|
3133
|
+
/* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
|
|
3134
|
+
required: ["taskId", "kind", "status"],
|
|
3135
|
+
fields: {
|
|
3136
|
+
taskId: "string",
|
|
3137
|
+
kind: "string",
|
|
3138
|
+
status: "string",
|
|
3139
|
+
fromNode: "string",
|
|
3140
|
+
toNode: "string",
|
|
3141
|
+
itemRefs: "string[]",
|
|
3142
|
+
resourceRef: "string",
|
|
3143
|
+
resources: "string[]",
|
|
3144
|
+
personnel: "string[]",
|
|
3145
|
+
assets: "string[]",
|
|
3146
|
+
orderId: "string",
|
|
3147
|
+
intent: "string",
|
|
3148
|
+
progress: "number",
|
|
3149
|
+
remainingMs: "number",
|
|
3150
|
+
durationMs: "number",
|
|
3151
|
+
startedAtSimMs: "number",
|
|
3152
|
+
outcome: "string",
|
|
3153
|
+
priority: "number",
|
|
3154
|
+
startTime: "string",
|
|
3155
|
+
endTime: "string",
|
|
3156
|
+
materialActual: "object[]",
|
|
3157
|
+
recordTime: "string"
|
|
3158
|
+
},
|
|
3159
|
+
enums: {
|
|
3160
|
+
status: TASK_STATUS,
|
|
3161
|
+
intent: ["transport", "process", "dwell"],
|
|
3162
|
+
/* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
|
|
3163
|
+
outcome: ["good", "scrap"]
|
|
3164
|
+
}
|
|
3165
|
+
},
|
|
3166
|
+
equipment: {
|
|
3167
|
+
eventType: OP_EVENT.equipment,
|
|
3168
|
+
identity: "moverId",
|
|
3169
|
+
// vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
|
|
3170
|
+
required: ["moverId", "kind", "status"],
|
|
3171
|
+
// vocabulary-guard: allow 위와 같은 이유
|
|
3172
|
+
fields: {
|
|
3173
|
+
moverId: "string",
|
|
3174
|
+
kind: "string",
|
|
3175
|
+
status: "string",
|
|
3176
|
+
location: "string",
|
|
3177
|
+
homeLocation: "string",
|
|
3178
|
+
// vocabulary-guard: allow
|
|
3179
|
+
taskId: "string",
|
|
3180
|
+
held: "boolean",
|
|
3181
|
+
effectiveStart: "string",
|
|
3182
|
+
effectiveEnd: "string",
|
|
3183
|
+
recordTime: "string",
|
|
3184
|
+
/* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
|
|
3185
|
+
재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
|
|
3186
|
+
motion: "object"
|
|
3187
|
+
},
|
|
3188
|
+
enums: { status: EQUIPMENT_STATUS }
|
|
3189
|
+
},
|
|
3190
|
+
person: {
|
|
3191
|
+
eventType: OP_EVENT.person,
|
|
3192
|
+
identity: "personId",
|
|
3193
|
+
required: ["personId", "status"],
|
|
3194
|
+
fields: {
|
|
3195
|
+
personId: "string",
|
|
3196
|
+
status: "string",
|
|
3197
|
+
personnelClassIds: "string[]",
|
|
3198
|
+
taskId: "string",
|
|
3199
|
+
location: "string",
|
|
3200
|
+
offShift: "boolean",
|
|
3201
|
+
effectiveStart: "string",
|
|
3202
|
+
effectiveEnd: "string",
|
|
3203
|
+
recordTime: "string"
|
|
3204
|
+
},
|
|
3205
|
+
enums: { status: PERSON_STATUS }
|
|
3206
|
+
},
|
|
3207
|
+
asset: {
|
|
3208
|
+
eventType: OP_EVENT.asset,
|
|
3209
|
+
identity: "assetId",
|
|
3210
|
+
required: ["assetId", "status"],
|
|
3211
|
+
fields: {
|
|
3212
|
+
assetId: "string",
|
|
3213
|
+
status: "string",
|
|
3214
|
+
assetClassIds: "string[]",
|
|
3215
|
+
location: "string",
|
|
3216
|
+
taskId: "string",
|
|
3217
|
+
carrying: "string",
|
|
3218
|
+
effectiveStart: "string",
|
|
3219
|
+
effectiveEnd: "string",
|
|
3220
|
+
recordTime: "string"
|
|
3221
|
+
},
|
|
3222
|
+
enums: { status: ASSET_STATUS }
|
|
3223
|
+
},
|
|
3224
|
+
order: {
|
|
3225
|
+
eventType: OP_EVENT.order,
|
|
3226
|
+
identity: "orderId",
|
|
3227
|
+
/*
|
|
3228
|
+
* 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
|
|
3229
|
+
* 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
|
|
3230
|
+
*/
|
|
3231
|
+
required: ["orderId", "kind", "status", "requested", "fulfilled"],
|
|
3232
|
+
fields: {
|
|
3233
|
+
orderId: "string",
|
|
3234
|
+
kind: "string",
|
|
3235
|
+
status: "string",
|
|
3236
|
+
requested: "number",
|
|
3237
|
+
fulfilled: "number",
|
|
3238
|
+
gtin: "string",
|
|
3239
|
+
held: "boolean",
|
|
3240
|
+
lines: "object[]",
|
|
3241
|
+
priority: "number",
|
|
3242
|
+
startTime: "string",
|
|
3243
|
+
endTime: "string",
|
|
3244
|
+
allocated: "string[]",
|
|
3245
|
+
bizTransaction: "string",
|
|
3246
|
+
operationsRequestId: "string",
|
|
3247
|
+
dockDoor: "string",
|
|
3248
|
+
windowStartMs: "number",
|
|
3249
|
+
recordTime: "string"
|
|
3250
|
+
}
|
|
3251
|
+
/* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
|
|
3252
|
+
},
|
|
3253
|
+
quality: {
|
|
3254
|
+
eventType: OP_EVENT.quality,
|
|
3255
|
+
identity: "moverId",
|
|
3256
|
+
// vocabulary-guard: allow 저널 와이어 필드
|
|
3257
|
+
/* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
|
|
3258
|
+
required: ["moverId", "good", "goodCount", "scrapCount"],
|
|
3259
|
+
// vocabulary-guard: allow
|
|
3260
|
+
fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", recordTime: "string" }
|
|
3261
|
+
// vocabulary-guard: allow
|
|
3262
|
+
},
|
|
3263
|
+
/*
|
|
3264
|
+
* **시험 결과** — 대상을 가리켜 들어온다(표준 `TestResult.TestableObjectID`).
|
|
3265
|
+
*
|
|
3266
|
+
* `result` 를 **요구하지 않는다**: 재기만 하고 판정하지 않는 원천이 정상이고, 요구하면 그 원천의
|
|
3267
|
+
* 사실을 아예 담을 수 없다(§`TestResult.result`). 비어 있으면 커널이 선언된 기준으로 판정하고
|
|
3268
|
+
* `derived` 를 세운다 — 판정하지 못하면 비워 둔다.
|
|
3269
|
+
*
|
|
3270
|
+
* `propertyMeasurements` 안쪽은 재검사하지 않는다 — 그 모양은 `PropertyMeasurement` 계약이고,
|
|
3271
|
+
* 여기서 두 번 지키면 두 벌이 된다(설비 `motion` 과 같은 규율).
|
|
3272
|
+
*/
|
|
3273
|
+
test: {
|
|
3274
|
+
eventType: OP_EVENT.test,
|
|
3275
|
+
identity: "testableObjectId",
|
|
3276
|
+
/* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
|
|
3277
|
+
required: ["testableObjectId", "specId"],
|
|
3278
|
+
fields: {
|
|
3279
|
+
testableObjectId: "string",
|
|
3280
|
+
specId: "string",
|
|
3281
|
+
result: "string",
|
|
3282
|
+
at: "string",
|
|
3283
|
+
expiresAt: "string",
|
|
3284
|
+
derived: "boolean",
|
|
3285
|
+
propertyMeasurements: "object[]",
|
|
3286
|
+
recordTime: "string"
|
|
3287
|
+
},
|
|
3288
|
+
enums: { result: ["pass", "fail"] }
|
|
3289
|
+
},
|
|
3290
|
+
/*
|
|
3291
|
+
* **자리의 물리 관측** — 어느 자리의 어느 속성을 언제 얼마로 쟀나.
|
|
3292
|
+
*
|
|
3293
|
+
* 값에 **단위를 함께** 받는다(`ValueType`). 단위 없는 물리량은 판정의 재료가 못 된다 — 3 이 섭씨인지
|
|
3294
|
+
* 화씨인지 모르면 어떤 기준으로도 판정할 수 없다. 다만 **요구하지는 않는다**: 단위를 비우는 실 원본이
|
|
3295
|
+
* 흔하고, 요구하면 그 원본의 관측을 아예 담지 못한다(그때 판정은 커널이 거부한다 — `outsideLimit`).
|
|
3296
|
+
*
|
|
3297
|
+
* `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
|
|
3298
|
+
* 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
|
|
3299
|
+
*/
|
|
3300
|
+
/*
|
|
3301
|
+
* **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
|
|
3302
|
+
*
|
|
3303
|
+
* 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
|
|
3304
|
+
* 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
|
|
3305
|
+
*/
|
|
3306
|
+
complete: {
|
|
3307
|
+
eventType: OP_EVENT.complete,
|
|
3308
|
+
identity: "completeAxis",
|
|
3309
|
+
required: ["completeAxis", "since"],
|
|
3310
|
+
fields: { completeAxis: "string", since: "string", recordTime: "string" }
|
|
3311
|
+
},
|
|
3312
|
+
observation: {
|
|
3313
|
+
eventType: OP_EVENT.observation,
|
|
3314
|
+
identity: "locationId",
|
|
3315
|
+
/* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
|
|
3316
|
+
required: ["locationId", "propertyId"],
|
|
3317
|
+
fields: {
|
|
3318
|
+
locationId: "string",
|
|
3319
|
+
propertyId: "string",
|
|
3320
|
+
value: "string",
|
|
3321
|
+
dataType: "string",
|
|
3322
|
+
uom: "string",
|
|
3323
|
+
effectiveTime: "string",
|
|
3324
|
+
effectiveEndTime: "string",
|
|
3325
|
+
recordTime: "string",
|
|
3326
|
+
source: "string",
|
|
3327
|
+
derived: "boolean"
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
};
|
|
3331
|
+
function operationalKindOf(record) {
|
|
3332
|
+
if (!record || typeof record !== "object") return void 0;
|
|
3333
|
+
const r = record;
|
|
3334
|
+
if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
|
|
3335
|
+
const has = (k) => typeof r[k] === "string" && r[k].trim().length > 0;
|
|
3336
|
+
if (has("moverId")) return r.good !== void 0 ? "quality" : "equipment";
|
|
3337
|
+
if (has("personId")) return "person";
|
|
3338
|
+
if (has("assetId")) return "asset";
|
|
3339
|
+
if (has("taskId")) return "task";
|
|
3340
|
+
if (has("orderId")) return "order";
|
|
3341
|
+
if (has("testableObjectId")) return "test";
|
|
3342
|
+
if (has("locationId") && has("propertyId")) return "observation";
|
|
3343
|
+
if (has("completeAxis")) return "complete";
|
|
3344
|
+
return void 0;
|
|
3345
|
+
}
|
|
3346
|
+
function isOperationalRecord(record) {
|
|
3347
|
+
return operationalKindOf(record) !== void 0;
|
|
3348
|
+
}
|
|
3349
|
+
function ingestOperationalRecords(records, opts) {
|
|
3350
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
3351
|
+
const accepted = [];
|
|
3352
|
+
const rejected = [];
|
|
3353
|
+
let seq = 0;
|
|
3354
|
+
for (const record of arr) {
|
|
3355
|
+
const kind = operationalKindOf(record);
|
|
3356
|
+
if (!kind) {
|
|
3357
|
+
rejected.push({
|
|
3358
|
+
record,
|
|
3359
|
+
errors: ["\uC5B4\uB290 \uC6B4\uC601 \uC0AC\uC2E4\uC778\uC9C0 \uBAA8\uB978\uB2E4 \u2014 \uC815\uCCB4 \uD544\uB4DC\uAC00 \uD544\uC694\uD558\uB2E4(taskId \xB7 moverId(+good=\uD488\uC9C8) \xB7 personId \xB7 assetId \xB7 orderId)"]
|
|
3360
|
+
// vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
|
|
3361
|
+
});
|
|
3362
|
+
continue;
|
|
3363
|
+
}
|
|
3364
|
+
const spec = SPECS[kind];
|
|
3365
|
+
const r = record;
|
|
3366
|
+
const errors = [];
|
|
3367
|
+
const ENVELOPE_FIELDS = ["at", "description"];
|
|
3368
|
+
const unknown = Object.keys(r).filter((k) => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === void 0);
|
|
3369
|
+
if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
|
|
3370
|
+
for (const name of spec.required) {
|
|
3371
|
+
const v = r[name];
|
|
3372
|
+
if (v === void 0 || v === null || v === "") errors.push(`${kind}: ${name} \uC5C6\uC74C \u2014 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB294 \uAC12\uC774\uB2E4`);
|
|
3373
|
+
}
|
|
3374
|
+
const data = {};
|
|
3375
|
+
for (const [name, type] of Object.entries(spec.fields)) {
|
|
3376
|
+
const v = r[name];
|
|
3377
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
3378
|
+
switch (type) {
|
|
3379
|
+
case "string": {
|
|
3380
|
+
if (typeof v !== "string") {
|
|
3381
|
+
errors.push(`${kind}.${name} \uC774 \uBB38\uC790\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
3382
|
+
break;
|
|
3383
|
+
}
|
|
3384
|
+
const allowed = spec.enums?.[name];
|
|
3385
|
+
if (allowed && !allowed.includes(v)) {
|
|
3386
|
+
errors.push(`${kind}.${name} \uC774 \uCEE4\uB110\uC774 \uB2E4\uB8E8\uB294 \uB0B1\uB9D0\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)} \u2014 \uBC1B\uB294 \uAC12\uC740 ${allowed.join(" \xB7 ")}`);
|
|
3387
|
+
break;
|
|
3388
|
+
}
|
|
3389
|
+
data[name] = v;
|
|
3390
|
+
break;
|
|
3391
|
+
}
|
|
3392
|
+
case "number": {
|
|
3393
|
+
const n = Number(v);
|
|
3394
|
+
if (typeof v === "boolean" || !Number.isFinite(n)) {
|
|
3395
|
+
errors.push(`${kind}.${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
3396
|
+
break;
|
|
3397
|
+
}
|
|
3398
|
+
if (name === "progress" && (n < 0 || n > 1)) {
|
|
3399
|
+
errors.push(`${kind}.progress \uB294 0~1 \uBE44\uC728\uC774\uB2E4: ${n}`);
|
|
3400
|
+
break;
|
|
3401
|
+
}
|
|
3402
|
+
if ((name === "requested" || name === "fulfilled" || name === "goodCount" || name === "scrapCount") && n < 0) {
|
|
3403
|
+
errors.push(`${kind}.${name} \uAC00 \uC74C\uC218\uB2E4: ${n}`);
|
|
3404
|
+
break;
|
|
3405
|
+
}
|
|
3406
|
+
data[name] = n;
|
|
3407
|
+
break;
|
|
3408
|
+
}
|
|
3409
|
+
case "boolean": {
|
|
3410
|
+
if (typeof v !== "boolean") {
|
|
3411
|
+
errors.push(`${kind}.${name} \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
3412
|
+
break;
|
|
3413
|
+
}
|
|
3414
|
+
data[name] = v;
|
|
3415
|
+
break;
|
|
3416
|
+
}
|
|
3417
|
+
case "string[]": {
|
|
3418
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
|
|
3419
|
+
errors.push(`${kind}.${name} \uAC00 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
3420
|
+
break;
|
|
3421
|
+
}
|
|
3422
|
+
data[name] = v.slice();
|
|
3423
|
+
break;
|
|
3424
|
+
}
|
|
3425
|
+
case "object": {
|
|
3426
|
+
if (Array.isArray(v) || typeof v !== "object") {
|
|
3427
|
+
errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
3428
|
+
break;
|
|
3429
|
+
}
|
|
3430
|
+
data[name] = { ...v };
|
|
3431
|
+
break;
|
|
3432
|
+
}
|
|
3433
|
+
case "object[]": {
|
|
3434
|
+
if (!Array.isArray(v) || v.some((x) => !x || typeof x !== "object")) {
|
|
3435
|
+
errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
3436
|
+
break;
|
|
3437
|
+
}
|
|
3438
|
+
data[name] = v.map((x) => ({ ...x }));
|
|
3439
|
+
break;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
const at = String(r.at ?? "").trim() || opts.defaultEventTime;
|
|
3444
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
3445
|
+
if (!Number.isFinite(atMs)) {
|
|
3446
|
+
errors.push(`${kind}: at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC2DC\uAC01 \uC5C6\uC774\uB294 \uB2A6\uAC8C \uC628 \uC61B \uC0AC\uC2E4\uC744 \uAC78\uB7EC\uB0BC \uC218 \uC5C6\uB2E4`);
|
|
3447
|
+
}
|
|
3448
|
+
if (errors.length) {
|
|
3449
|
+
rejected.push({ record, errors });
|
|
3450
|
+
continue;
|
|
3451
|
+
}
|
|
3452
|
+
const note = typeof r.description === "string" ? String(r.description).trim() : "";
|
|
3453
|
+
accepted.push({
|
|
3454
|
+
eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
|
|
3455
|
+
eventType: spec.eventType,
|
|
3456
|
+
eventTime: new Date(atMs).toISOString(),
|
|
3457
|
+
tenantId: opts.tenantId,
|
|
3458
|
+
...note ? { description: note } : {},
|
|
3459
|
+
data
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
return { accepted, rejected };
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// src/operations-capability.ts
|
|
3466
|
+
function workingHoursBetween(calendar, fromMs, toMs, utcOffsetMinutes) {
|
|
3467
|
+
if (!(toMs > fromMs)) return 0;
|
|
3468
|
+
if (!calendar?.length) return (toMs - fromMs) / 36e5;
|
|
3469
|
+
let minutes = 0;
|
|
3470
|
+
for (let ms = fromMs; ms < toMs; ms += 6e4) if (inWorkCalendarAt(calendar, ms, utcOffsetMinutes)) minutes++;
|
|
3471
|
+
return minutes / 60;
|
|
3472
|
+
}
|
|
3473
|
+
function operationsCapabilityOf(input) {
|
|
3474
|
+
const fromMs = Date.parse(input.window.fromTime);
|
|
3475
|
+
const toMs = Date.parse(input.window.toTime);
|
|
3476
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
|
|
3477
|
+
throw new Error(`\uB2A5\uB825\uC758 \uAD6C\uAC04\uC744 \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${input.window.fromTime} ~ ${input.window.toTime}`);
|
|
3478
|
+
}
|
|
3479
|
+
if (toMs <= fromMs) throw new Error(`\uB2A5\uB825\uC758 \uAD6C\uAC04\uC774 \uB4A4\uC9D1\uD614\uB2E4: ${input.window.fromTime} ~ ${input.window.toTime}`);
|
|
3480
|
+
const workingHours = workingHoursBetween(input.calendar ?? input.capacity.calendar, fromMs, toMs, input.utcOffsetMinutes);
|
|
3481
|
+
const full = analyzeCapacity(input.capacity);
|
|
3482
|
+
const usable = input.usableCapacity ? analyzeCapacity(input.usableCapacity) : void 0;
|
|
3483
|
+
const amountOf = (perHour) => Number.isFinite(perHour) ? perHour * workingHours : null;
|
|
3484
|
+
const committedOf = (operation) => {
|
|
3485
|
+
let sum = 0;
|
|
3486
|
+
for (const d of input.demands ?? []) {
|
|
3487
|
+
if (d.operation !== void 0 && d.operation !== operation) continue;
|
|
3488
|
+
if (!d.dueTime) continue;
|
|
3489
|
+
const due = Date.parse(d.dueTime);
|
|
3490
|
+
if (!Number.isFinite(due) || due < fromMs || due >= toMs) continue;
|
|
3491
|
+
sum += Math.max(0, d.remaining);
|
|
3492
|
+
}
|
|
3493
|
+
return sum;
|
|
3494
|
+
};
|
|
3495
|
+
const perOperation = full.operations.map((op) => {
|
|
3496
|
+
const total = amountOf(op.perHour);
|
|
3497
|
+
const committed = committedOf(op.operation);
|
|
3498
|
+
let unattainable = null;
|
|
3499
|
+
let unattainableBy;
|
|
3500
|
+
if (usable) {
|
|
3501
|
+
const left = usable.operations.find((o) => o.operation === op.operation);
|
|
3502
|
+
const usableAmount = amountOf(left?.perHour ?? NaN);
|
|
3503
|
+
unattainable = total != null && usableAmount != null ? Math.max(0, total - usableAmount) : null;
|
|
3504
|
+
if (unattainable != null && unattainable > 0 && left?.constraint) unattainableBy = left.constraint;
|
|
3505
|
+
}
|
|
3506
|
+
const available = total == null || unattainable == null ? null : total - committed - unattainable;
|
|
3507
|
+
return {
|
|
3508
|
+
operation: op.operation,
|
|
3509
|
+
total,
|
|
3510
|
+
committed,
|
|
3511
|
+
unattainable,
|
|
3512
|
+
available,
|
|
3513
|
+
...op.constraint ? { constraint: op.constraint } : {},
|
|
3514
|
+
...unattainableBy ? { unattainableBy } : {}
|
|
3515
|
+
};
|
|
3516
|
+
});
|
|
3517
|
+
return {
|
|
3518
|
+
operationsType: input.operationsType ?? "Production",
|
|
3519
|
+
window: { fromTime: input.window.fromTime, toTime: input.window.toTime, workingHours },
|
|
3520
|
+
perOperation,
|
|
3521
|
+
...full.bottleneck ? { bottleneck: full.bottleneck } : {},
|
|
3522
|
+
...input.publishedDate ? { publishedDate: input.publishedDate } : {}
|
|
3523
|
+
};
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
// src/scenario-validate.ts
|
|
3527
|
+
var DISTRIBUTIONS = /* @__PURE__ */ new Set(["poisson", "uniform", "constant", "profile"]);
|
|
3528
|
+
var isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
3529
|
+
var bad = (errorCode, errorParams) => ({ ok: false, errorCode, errorParams });
|
|
3530
|
+
function validateGenerator(g, at) {
|
|
3531
|
+
if (!g || typeof g !== "object") return bad("scenario-generator-invalid", { at });
|
|
3532
|
+
if (!g.kind || typeof g.kind !== "string") return bad("scenario-generator-kind-required", { at });
|
|
3533
|
+
const rate = g.rate;
|
|
3534
|
+
if (!rate || typeof rate !== "object") return bad("scenario-rate-required", { at, kind: g.kind });
|
|
3535
|
+
if (!isNum(rate.meanPerHour) || rate.meanPerHour < 0) return bad("scenario-rate-mean-invalid", { at, kind: g.kind });
|
|
3536
|
+
if (!DISTRIBUTIONS.has(rate.distribution)) return bad("scenario-rate-distribution-invalid", { at, kind: g.kind, distribution: String(rate.distribution ?? "") });
|
|
3537
|
+
if (rate.distribution === "profile" && !Array.isArray(rate.profile)) return bad("scenario-rate-profile-required", { at, kind: g.kind });
|
|
3538
|
+
const content = g.content;
|
|
3539
|
+
if (!content || typeof content !== "object") return bad("scenario-content-required", { at, kind: g.kind });
|
|
3540
|
+
if (!Array.isArray(content.skuMix) || content.skuMix.length === 0) return bad("scenario-sku-mix-required", { at, kind: g.kind });
|
|
3541
|
+
for (const s of content.skuMix) {
|
|
3542
|
+
if (!s?.gtin || typeof s.gtin !== "string") return bad("scenario-sku-gtin-required", { at, kind: g.kind });
|
|
3543
|
+
if (!isNum(s.weight) || s.weight <= 0) return bad("scenario-sku-weight-invalid", { at, kind: g.kind, gtin: String(s.gtin) });
|
|
3544
|
+
}
|
|
3545
|
+
const q = content.qtyPerLine;
|
|
3546
|
+
if (!q || !isNum(q.min) || !isNum(q.max)) return bad("scenario-qty-required", { at, kind: g.kind });
|
|
3547
|
+
if (q.min < 0 || q.max < q.min) return bad("scenario-qty-range-invalid", { at, kind: g.kind, min: q.min, max: q.max });
|
|
3548
|
+
const l = content.linesPerOrder;
|
|
3549
|
+
if (l && (!isNum(l.min) || !isNum(l.max) || l.min < 1 || l.max < l.min)) return bad("scenario-lines-range-invalid", { at, kind: g.kind });
|
|
3550
|
+
if (g.stimulus !== void 0 && g.stimulus !== "arrival" && g.stimulus !== "order") {
|
|
3551
|
+
return bad("scenario-stimulus-invalid", { at, kind: g.kind, stimulus: String(g.stimulus) });
|
|
3552
|
+
}
|
|
3553
|
+
return { ok: true };
|
|
3554
|
+
}
|
|
3555
|
+
function validateScenario(def) {
|
|
3556
|
+
if (!def || typeof def !== "object") return bad("scenario-invalid");
|
|
3557
|
+
if (def.seed !== void 0 && !isNum(def.seed)) return bad("scenario-seed-invalid");
|
|
3558
|
+
if (def.speed !== void 0 && (!isNum(def.speed) || def.speed <= 0)) return bad("scenario-speed-invalid");
|
|
3559
|
+
if (def.horizon !== void 0 && (!isNum(def.horizon) || def.horizon < 0)) return bad("scenario-horizon-invalid");
|
|
3560
|
+
const gens = def.generators;
|
|
3561
|
+
if (gens === void 0) return { ok: true };
|
|
3562
|
+
if (!Array.isArray(gens)) return bad("scenario-generators-invalid");
|
|
3563
|
+
for (let i = 0; i < gens.length; i++) {
|
|
3564
|
+
const r = validateGenerator(gens[i], i);
|
|
3565
|
+
if (!r.ok) return r;
|
|
3566
|
+
}
|
|
3567
|
+
return { ok: true };
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
// src/vocabulary.ts
|
|
3571
|
+
var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE", "BoardDef", "loadBoard"];
|
|
3572
|
+
var VOCABULARY_EXCEPTIONS = [
|
|
3573
|
+
/* ── 저널 와이어 필드 — append-only 역사이므로 이름을 바꾸지 않는다 ───────
|
|
3574
|
+
* 같은 사실이 시점에 따라 다른 키로 들어가면 낡은 이름보다 나쁘다. 개명은 이벤트 스키마
|
|
3575
|
+
* 버저닝과 함께 다룬다. (개명 시점 실측: moverId 8,965행 · fromNode/toNode 9,679행) */
|
|
3576
|
+
{ token: "moverId", why: "journal wire field \u2014 renaming would mix two keys for one fact across history" },
|
|
3577
|
+
{ token: "fromNode", why: "journal wire field (task.status payload)" },
|
|
3578
|
+
{ token: "toNode", why: "journal wire field (task.status payload)" },
|
|
3579
|
+
/* ── 트윈 모델의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
|
|
3580
|
+
* 설비 배열의 이름은 세 세대를 거쳤다(movers → equipmentList → equipment). 저장된 보드에는
|
|
3581
|
+
* 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 트윈 모델은 **설비가 0인 공장**
|
|
3582
|
+
* 으로 조용히 읽힌다 — 화면의 설비 수가 0이 되고 용량 판정에서 자원이 사라진다. 쓰는 곳은
|
|
3583
|
+
* `readBoardEquipment` 한 곳뿐이고, 거기서 새 이름으로 정규화해 내보낸다. */
|
|
3584
|
+
{ token: "movers", why: "legacy board key (movers \u2192 equipmentList \u2192 equipment); read-only normalization in readBoardEquipment, 13 stored boards still use it" },
|
|
3585
|
+
/* ── 씬 컴포넌트 타입 — 트윈 모델에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
|
|
3586
|
+
* 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
|
|
3587
|
+
* 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
|
|
3588
|
+
{ token: "twin-mover", why: "scene component type persisted in boards; names a motion representation, not a resource" },
|
|
3589
|
+
{ token: "TwinMover", why: "class backing the persisted scene component type above" },
|
|
3590
|
+
/* ── 플랫폼·언어 어휘 — 우리 도메인 낱말이 아니다 ─────────────────────── */
|
|
3591
|
+
{ token: "node_modules", why: "npm path" },
|
|
3592
|
+
{ token: "node:", why: "Node.js builtin module specifier" },
|
|
3593
|
+
{ token: "nodemon", why: "tool name" },
|
|
3594
|
+
{ token: "Node.js", why: "platform name" },
|
|
3595
|
+
{ token: "NodeJS", why: "TypeScript global namespace" },
|
|
3596
|
+
{ token: "nodeName", why: "DOM API" },
|
|
3597
|
+
{ token: "childNodes", why: "DOM API" },
|
|
3598
|
+
{ token: "appendChild", why: "DOM API (no match, kept for clarity)" }
|
|
3599
|
+
];
|
|
3600
|
+
var GUARD_PRAGMA = "vocabulary-guard: allow";
|
|
3601
|
+
function retiredVocabularyIn(line) {
|
|
3602
|
+
if (line.includes(GUARD_PRAGMA)) return [];
|
|
3603
|
+
let rest = line;
|
|
3604
|
+
for (const { token } of VOCABULARY_EXCEPTIONS) rest = rest.split(token).join(" ");
|
|
3605
|
+
const hits = [];
|
|
3606
|
+
for (const word of RETIRED_VOCABULARY) {
|
|
3607
|
+
const re = new RegExp(`[A-Za-z_]*${word}[A-Za-z_]*`, "g");
|
|
3608
|
+
for (const m of rest.matchAll(re)) hits.push(m[0]);
|
|
3609
|
+
}
|
|
3610
|
+
return hits;
|
|
3611
|
+
}
|
|
3612
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3613
|
+
0 && (module.exports = {
|
|
3614
|
+
BIZSTEP,
|
|
3615
|
+
BTT,
|
|
3616
|
+
BTT_DELIVERY,
|
|
3617
|
+
BTT_PRODORDER,
|
|
3618
|
+
CAPABILITIES,
|
|
3619
|
+
CAPABILITY_KEYS,
|
|
3620
|
+
CBV_BIZSTEP,
|
|
3621
|
+
CMD,
|
|
3622
|
+
DISP,
|
|
3623
|
+
DISPOSITION_DECISION,
|
|
3624
|
+
DOMAIN_CATALOG,
|
|
3625
|
+
DOMAIN_SYSTEMS,
|
|
3626
|
+
EMS_EQUIPMENT_TYPES,
|
|
3627
|
+
EMS_LOCATION_TYPES,
|
|
3628
|
+
EMS_PROPERTY,
|
|
3629
|
+
EMS_PROPERTY_SPEC,
|
|
3630
|
+
EMS_TYPES,
|
|
3631
|
+
ENERGY_EVENT,
|
|
3632
|
+
EPCIS_CONTEXT,
|
|
3633
|
+
EPCIS_EVENT,
|
|
3634
|
+
EQUIPMENT_LEVEL,
|
|
3635
|
+
GUARD_PRAGMA,
|
|
3636
|
+
ILMD_ATTR,
|
|
3637
|
+
LOCATION_SATURATION_NEAR,
|
|
3638
|
+
MATERIAL_PROPERTY,
|
|
3639
|
+
MES_BIZSTEP,
|
|
3640
|
+
MES_LOCATION_TYPES,
|
|
3641
|
+
MES_TYPES,
|
|
3642
|
+
METER_DIRECTION,
|
|
3643
|
+
OBSERVATION_BASIS,
|
|
3644
|
+
OP_EVENT,
|
|
3645
|
+
OP_PARAM,
|
|
3646
|
+
ORDER_TERMINAL_STATUS,
|
|
3647
|
+
PRIORITY_UNSET,
|
|
3648
|
+
RETIRED_VOCABULARY,
|
|
3649
|
+
TWIN_AXES,
|
|
3650
|
+
TWIN_PROPERTIES,
|
|
3651
|
+
TWIN_RELATIONS,
|
|
3652
|
+
UTC_OFFSET,
|
|
3653
|
+
VOCABULARY_EXCEPTIONS,
|
|
3654
|
+
VOCABULARY_TYPE,
|
|
3655
|
+
WMS_LOCATION_TYPES,
|
|
3656
|
+
WMS_TYPES,
|
|
3657
|
+
YARD_BIZSTEP,
|
|
3658
|
+
YMS_LOCATION_TYPES,
|
|
3659
|
+
YMS_TYPES,
|
|
3660
|
+
activeShiftAt,
|
|
3661
|
+
activeShiftOf,
|
|
3662
|
+
aggregationEvent,
|
|
3663
|
+
analyzeCapacity,
|
|
3664
|
+
axesOfSystem,
|
|
3665
|
+
axisAppliesTo,
|
|
3666
|
+
axisInfo,
|
|
3667
|
+
axisSource,
|
|
3668
|
+
bizTransactionUri,
|
|
3669
|
+
capabilitiesForType,
|
|
3670
|
+
capabilityOf,
|
|
3671
|
+
classClosure,
|
|
3672
|
+
classIdentifierViolation,
|
|
3673
|
+
commandsOf,
|
|
3674
|
+
conversionFactorOf,
|
|
3675
|
+
criterionSaysNothing,
|
|
3676
|
+
documentPath,
|
|
3677
|
+
dueStatusOf,
|
|
3678
|
+
effectivityAt,
|
|
3679
|
+
electricalUpstreamOf,
|
|
3680
|
+
expiryFromAttributes,
|
|
3681
|
+
gdtiUri,
|
|
3682
|
+
generationFractionAt,
|
|
3683
|
+
graiUri,
|
|
3684
|
+
gs1KeyDigitViolation,
|
|
3685
|
+
hierarchyOf,
|
|
3686
|
+
identityGroundingOf,
|
|
3687
|
+
inWorkCalendar,
|
|
3688
|
+
inWorkCalendarAt,
|
|
3689
|
+
ingest,
|
|
3690
|
+
ingestEnergyBillRecords,
|
|
3691
|
+
ingestEnergyEquipmentRecords,
|
|
3692
|
+
ingestEnergyGenerationPeriodRecords,
|
|
3693
|
+
ingestEnergyGenerationPriceRecords,
|
|
3694
|
+
ingestEnergyGenerationRecords,
|
|
3695
|
+
ingestEnergyRecords,
|
|
3696
|
+
ingestEnergyTariffBasisRecords,
|
|
3697
|
+
ingestEnergyUsagePeriodRecords,
|
|
3698
|
+
ingestMasterData,
|
|
3699
|
+
ingestOperationalRecords,
|
|
3700
|
+
isAggregationRecord,
|
|
3701
|
+
isElectricalLocationType,
|
|
3702
|
+
isEnergyBillRecord,
|
|
3703
|
+
isEnergyEquipmentRecord,
|
|
3704
|
+
isEnergyGenerationPeriodRecord,
|
|
3705
|
+
isEnergyGenerationPriceRecord,
|
|
3706
|
+
isEnergyGenerationRecord,
|
|
3707
|
+
isEnergyRecord,
|
|
3708
|
+
isEnergyTariffBasisRecord,
|
|
3709
|
+
isEnergyUsagePeriodRecord,
|
|
3710
|
+
isEpcisEventType,
|
|
3711
|
+
isEquipmentLevel,
|
|
3712
|
+
isMasterDataRecord,
|
|
3713
|
+
isOperationalRecord,
|
|
3714
|
+
isOrderTerminal,
|
|
3715
|
+
isTransformationRecord,
|
|
3716
|
+
isoDurationHours,
|
|
3717
|
+
itemKeyOf,
|
|
3718
|
+
judgeAgainstSpec,
|
|
3719
|
+
levelOfLocationType,
|
|
3720
|
+
lgtinClass,
|
|
3721
|
+
localDayIndexAt,
|
|
3722
|
+
localDayStartMs,
|
|
3723
|
+
locationStatusOf,
|
|
3724
|
+
lotFromAttributes,
|
|
3725
|
+
mapRecord,
|
|
3726
|
+
mapRecordChecked,
|
|
3727
|
+
meetsTests,
|
|
3728
|
+
minuteOfDayAt,
|
|
3729
|
+
objectEvent,
|
|
3730
|
+
objectUri,
|
|
3731
|
+
observationAt,
|
|
3732
|
+
offCalendarAt,
|
|
3733
|
+
offCalendarReasonAt,
|
|
3734
|
+
operationalKindOf,
|
|
3735
|
+
operationsCapabilityOf,
|
|
3736
|
+
outsideLimit,
|
|
3737
|
+
parseEpc,
|
|
3738
|
+
parseIsoDuration,
|
|
3739
|
+
periodFactId,
|
|
3740
|
+
priorityRank,
|
|
3741
|
+
procedureViolations,
|
|
3742
|
+
propertiesOf,
|
|
3743
|
+
quantityIn,
|
|
3744
|
+
readBoardAssets,
|
|
3745
|
+
readBoardEquipment,
|
|
3746
|
+
readBoardLocations,
|
|
3747
|
+
relationsFrom,
|
|
3748
|
+
relationsTo,
|
|
3749
|
+
requiredTestsFor,
|
|
3750
|
+
resolveSubject,
|
|
3751
|
+
retiredVocabularyIn,
|
|
3752
|
+
sgtinClass,
|
|
3753
|
+
sgtinUri,
|
|
3754
|
+
ssccUri,
|
|
3755
|
+
stateFieldsOf,
|
|
3756
|
+
subLotIdOf,
|
|
3757
|
+
testEvidenceGaps,
|
|
3758
|
+
testPassedAt,
|
|
3759
|
+
transactionEvent,
|
|
3760
|
+
transformationEvent,
|
|
3761
|
+
validateDomainDefinition,
|
|
3762
|
+
validateEpcisEvent,
|
|
3763
|
+
validateScenario,
|
|
3764
|
+
weekdayAt,
|
|
3765
|
+
workingHoursBetween,
|
|
3766
|
+
workingTimeOfWeek
|
|
3767
|
+
});
|