@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,100 @@
|
|
|
1
|
+
import { ILMD_ATTR } from "./epcis.js";
|
|
2
|
+
/*
|
|
3
|
+
* **마스터데이터 — 변하지 않는 속성이 들어오는 문** (2026-08-28).
|
|
4
|
+
*
|
|
5
|
+
* ── 무엇이 없어서 무엇이 망가졌나 ───────────────────────────────────────────
|
|
6
|
+
* 커널에 마스터데이터라는 개념이 없었다. 그래서 유통기한 같은 값이 갈 곳이 **사건밖에** 없었고,
|
|
7
|
+
* 표준은 그 값을 `ObjectEvent(action=ADD)` 와 변환 사건에만 실을 수 있게 한다(EPCIS 2.0 §7.3.8).
|
|
8
|
+
*
|
|
9
|
+
* 그 제약이 유입 쪽에서 이렇게 나타났다. 전량을 읽는 원본(식품 제조 MES)이 재고를 다시 말할 때마다,
|
|
10
|
+
* 유통기한을 실으려고 **들어오지 않은 것을 `ADD` 라고** 말했다. 그리고 `ilmd` 는 사건 단위라서 자리별로
|
|
11
|
+
* 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에 2,665건이다.
|
|
12
|
+
*
|
|
13
|
+
* 나르는 방법의 제약이 사실의 뜻을 정한 것이고, 순서가 거꾸로였다.
|
|
14
|
+
*
|
|
15
|
+
* ── 표준이 둔 자리 ──────────────────────────────────────────────────────────
|
|
16
|
+
* 표준은 이런 값을 **어휘 요소의 속성**으로 담는 자리를 사건과 별개로 갖고 있다.
|
|
17
|
+
*
|
|
18
|
+
* 어휘 타입 urn:epcglobal:epcis:vtype:EPCClass · ReadPoint · BizLocation
|
|
19
|
+
* 어휘 요소 { id, attributes }
|
|
20
|
+
*
|
|
21
|
+
* 속성 **이름**은 EPCIS 본문이 정하지 않고 상위 문서(CBV 마스터데이터)의 몫이다(§7.3.8 이 미룬다).
|
|
22
|
+
* 그래서 이 모듈은 이름을 해석하지 않고 **그대로 보관한다** — 아는 이름만 상태 축으로 옮기고, 모르는
|
|
23
|
+
* 것은 잃지 않는다. 우리가 생산할 때 쓰는 이름은 `ILMD_ATTR` 한 곳에 모여 있고 사건 경로와 같은
|
|
24
|
+
* 상수다(이름이 바뀌면 한 곳만 고친다).
|
|
25
|
+
*
|
|
26
|
+
* ── 무엇을 이 문으로 보내지 않나 ────────────────────────────────────────────
|
|
27
|
+
* **변하는 값은 사건이다.** 수량·위치·상태·진행은 이 문으로 오지 않는다. 여기 오는 것은 그 물건의
|
|
28
|
+
* 생애 동안 같은 값뿐이고, 그래서 「그때는 얼마였나」라는 물음이 성립하지 않는다 — 시각축이 없는 값이다.
|
|
29
|
+
*
|
|
30
|
+
* 그 성질이 「지난 기록에 적지 않는다」의 근거다. 사건에서 파생되는 상태가 아니라 **선언된 것**이므로
|
|
31
|
+
* 모델·자재 선언과 같은 자리에 산다. 다만 중간 저장본에는 담는다(되세울 때 원본을 다시 읽지 않게).
|
|
32
|
+
*
|
|
33
|
+
* 설계: `operato-twin/design/plans/master-data.md`
|
|
34
|
+
*/
|
|
35
|
+
/** 표준 어휘 타입 — 이 세 가지가 우리 상태 축에 닿는다. 그 밖은 보관하고 알린다. */
|
|
36
|
+
export const VOCABULARY_TYPE = {
|
|
37
|
+
/** 품목·로트 클래스의 속성 — 유통기한·로트번호가 여기 온다. */
|
|
38
|
+
epcClass: 'urn:epcglobal:epcis:vtype:EPCClass',
|
|
39
|
+
/** 관측 지점의 속성. */
|
|
40
|
+
readPoint: 'urn:epcglobal:epcis:vtype:ReadPoint',
|
|
41
|
+
/** 업무 자리의 속성. */
|
|
42
|
+
bizLocation: 'urn:epcglobal:epcis:vtype:BizLocation'
|
|
43
|
+
};
|
|
44
|
+
/** 이 레코드가 마스터데이터인가 — 갈래 판정은 **커널이 한 곳에서** 한다(어휘 넷을 가르는 방식과 같다). */
|
|
45
|
+
export function isMasterDataRecord(record) {
|
|
46
|
+
if (!record || typeof record !== 'object')
|
|
47
|
+
return false;
|
|
48
|
+
const r = record;
|
|
49
|
+
return typeof r.vocabularyType === 'string' && !!r.vocabularyType && typeof r.id === 'string' && !!r.id && isPlainObject(r.attributes);
|
|
50
|
+
}
|
|
51
|
+
function isPlainObject(v) {
|
|
52
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 마스터데이터를 받아들인다 — **거부한 것은 이유와 함께 남는다**(다른 어휘와 같은 규율).
|
|
56
|
+
*
|
|
57
|
+
* 빈 속성은 거부한다: 「이 로트에 대해 아무것도 모른다」를 보내는 것은 아무 뜻이 없고, 받아 두면
|
|
58
|
+
* 기존 값을 지우려는 것인지 아무 말도 아닌지 구별할 수 없다.
|
|
59
|
+
*/
|
|
60
|
+
export function ingestMasterData(records) {
|
|
61
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
62
|
+
const accepted = [];
|
|
63
|
+
const rejected = [];
|
|
64
|
+
for (const r of arr) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
if (!isMasterDataRecord(r))
|
|
67
|
+
errors.push('vocabularyType · id · attributes 가 있어야 한다');
|
|
68
|
+
else if (!Object.keys(r.attributes).length)
|
|
69
|
+
errors.push('attributes 가 비었다 — 아무 뜻이 없는 진술이다');
|
|
70
|
+
/* 값이 `null` 인 속성은 「지운다」는 뜻이므로 빈 진술이 아니다(위 검사가 이름 수를 세므로 통과한다). */
|
|
71
|
+
if (errors.length)
|
|
72
|
+
rejected.push({ record: r, errors });
|
|
73
|
+
else
|
|
74
|
+
accepted.push({ vocabularyType: r.vocabularyType, id: r.id, attributes: { ...r.attributes } });
|
|
75
|
+
}
|
|
76
|
+
return { accepted, rejected };
|
|
77
|
+
}
|
|
78
|
+
/** 마스터 속성에서 유통기한을 읽는다 — 사건의 `ilmd` 와 **같은 이름**을 본다. */
|
|
79
|
+
export function expiryFromAttributes(attrs) {
|
|
80
|
+
return readEpochMs(attrs?.[ILMD_ATTR.expiry]);
|
|
81
|
+
}
|
|
82
|
+
/** 마스터 속성에서 로트 번호를 읽는다 — 사건의 `ilmd` 와 같은 이름. */
|
|
83
|
+
export function lotFromAttributes(attrs) {
|
|
84
|
+
const v = attrs?.[ILMD_ATTR.lot];
|
|
85
|
+
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 시각 값을 밀리초로 — 숫자와 문자열을 모두 읽는다(원본이 둘 다 쓴다).
|
|
89
|
+
*
|
|
90
|
+
* 읽을 수 없으면 `undefined` 다. 「지금」으로 메우면 기한이 지난 재고가 멀쩡해 보이고, 0 으로 메우면
|
|
91
|
+
* 멀쩡한 재고가 기한 지난 것으로 보인다 — 둘 다 사실이 아니다.
|
|
92
|
+
*/
|
|
93
|
+
function readEpochMs(raw) {
|
|
94
|
+
if (typeof raw === 'number')
|
|
95
|
+
return Number.isFinite(raw) ? raw : undefined;
|
|
96
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
97
|
+
return undefined;
|
|
98
|
+
const at = Date.parse(raw);
|
|
99
|
+
return Number.isNaN(at) ? undefined : at;
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TwinTypeInfo } from './domain-catalog.ts';
|
|
2
|
+
export declare const MES_BIZSTEP: {
|
|
3
|
+
readonly receiving: "urn:epcglobal:cbv:bizstep:receiving";
|
|
4
|
+
readonly producing: "urn:epcglobal:cbv:bizstep:commissioning";
|
|
5
|
+
readonly storing: "urn:epcglobal:cbv:bizstep:storing";
|
|
6
|
+
};
|
|
7
|
+
/** 작업지시(Work Order) = 생산 오더 거래 유형. */
|
|
8
|
+
export declare const BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
|
|
9
|
+
/** 직렬 SGTIN URI (원자재 단위·완제품). */
|
|
10
|
+
export declare function sgtinUri(companyPrefix: string, itemRef: string, serial: number): string;
|
|
11
|
+
/** MES 자리 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(locationByType/.type). 도메인 SSOT.
|
|
12
|
+
* 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
|
|
13
|
+
export declare const MES_LOCATION_TYPES: readonly ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
|
|
14
|
+
export declare const MES_TYPES: TwinTypeInfo[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// MES bizStep — CBV 재사용. producing = commissioning(신규 제품 커미셔닝).
|
|
2
|
+
export const MES_BIZSTEP = {
|
|
3
|
+
receiving: 'urn:epcglobal:cbv:bizstep:receiving', // 원자재 수령
|
|
4
|
+
producing: 'urn:epcglobal:cbv:bizstep:commissioning', // 생산(제품 최초 생성)
|
|
5
|
+
storing: 'urn:epcglobal:cbv:bizstep:storing' // 완제품 저장
|
|
6
|
+
};
|
|
7
|
+
/** 작업지시(Work Order) = 생산 오더 거래 유형. */
|
|
8
|
+
export const BTT_PRODORDER = 'urn:epcglobal:cbv:btt:prodorder';
|
|
9
|
+
/** 직렬 SGTIN URI (원자재 단위·완제품). */
|
|
10
|
+
export function sgtinUri(companyPrefix, itemRef, serial) {
|
|
11
|
+
return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
|
|
12
|
+
}
|
|
13
|
+
/** MES 자리 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(locationByType/.type). 도메인 SSOT.
|
|
14
|
+
* 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
|
|
15
|
+
export const MES_LOCATION_TYPES = ['raw-store', 'cut-station', 'weld-station', 'paint-booth', 'assembly-line', 'fg-store'];
|
|
16
|
+
/*
|
|
17
|
+
* 트윈 타입 서술(ADR-0018 확장) — 자리 키는 MES_LOCATION_TYPES 단일 출처에서 파생 + 설비(자원) 타입 추가.
|
|
18
|
+
* MES=ISA-95 앵커: 스테이션=WorkCenter, 저장소=bizLocation. 설비(가공설비)=Equipment/자산(GIAI). key 는 flow resourceType 과 일치.
|
|
19
|
+
*/
|
|
20
|
+
// label 은 언어 중립 i18n 키(twin.type.<key>) — cls(표준 클래스)만 자리별 메타로 유지. 사람 언어는 표현계층(L2).
|
|
21
|
+
const MES_LOCATION_CLS = {
|
|
22
|
+
'raw-store': { epcis: 'bizLocation' },
|
|
23
|
+
'cut-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
|
|
24
|
+
'weld-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
|
|
25
|
+
'paint-booth': { isa95: 'WorkCenter', epcis: 'bizLocation' },
|
|
26
|
+
'assembly-line': { isa95: 'WorkCenter', epcis: 'bizLocation' },
|
|
27
|
+
'fg-store': { epcis: 'bizLocation' }
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* 자리의 **ISA-95 계층 단** — 여기가 단이 가장 크게 갈리는 곳이다.
|
|
31
|
+
*
|
|
32
|
+
* · `assembly-line` = 라인 → `ProductionLine`(`WorkCenter` 의 한 종류)
|
|
33
|
+
* · `cut-station`·`weld-station`·`paint-booth` = 라인 안의 작업 자리 → `WorkCell`(`WorkUnit` 의 한 종류)
|
|
34
|
+
* · `raw-store`·`fg-store` = 보관 구역 → `StorageZone`
|
|
35
|
+
*
|
|
36
|
+
* ── 남은 어긋남 (2026-08-14, 지금 고치지 않는다) ─────────────────────────────
|
|
37
|
+
* 스테이션들의 `standardClass.isa95` 는 예전부터 `WorkCenter` 로 적혀 있다. 그런데 `WorkCenter` 는
|
|
38
|
+
* ProcessCell·ProductionLine·ProductionUnit·StorageZone 의 **총칭**이므로, 라인 안의 작업 자리는
|
|
39
|
+
* 엄밀히는 `WorkUnit` 계열이다. 즉 클래스 투영이 한 단 위를 가리키고 있다.
|
|
40
|
+
*
|
|
41
|
+
* 여기서 그것을 바꾸지 않는다: `standardClass` 는 적합성 표가 세는 값이고(`isa95-coverage`),
|
|
42
|
+
* 바꾸면 그 표의 숫자가 함께 움직인다. **단(`level`)과 클래스(`standardClass`)는 다른 축**이므로
|
|
43
|
+
* 단을 옳게 적는 것으로 계층 질의는 살아난다. 클래스 재검토는 적합성 표와 함께 다룰 일이다.
|
|
44
|
+
*/
|
|
45
|
+
const MES_LOCATION_LEVEL = {
|
|
46
|
+
'raw-store': 'StorageZone',
|
|
47
|
+
'cut-station': 'WorkCell',
|
|
48
|
+
'weld-station': 'WorkCell',
|
|
49
|
+
'paint-booth': 'WorkCell',
|
|
50
|
+
'assembly-line': 'ProductionLine',
|
|
51
|
+
'fg-store': 'StorageZone'
|
|
52
|
+
};
|
|
53
|
+
export const MES_TYPES = [
|
|
54
|
+
...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'] })),
|
|
55
|
+
{ key: 'cutter', role: 'equipment', label: 'twin.type.cutter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
|
|
56
|
+
{ key: 'welder', role: 'equipment', label: 'twin.type.welder', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
|
|
57
|
+
{ key: 'painter', role: 'equipment', label: 'twin.type.painter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
|
|
58
|
+
{ key: 'assembler', role: 'equipment', label: 'twin.type.assembler', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
|
|
59
|
+
];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IngestResult } from './face2-adapter.ts';
|
|
2
|
+
/** 이 문이 받는 여섯 가지 — 리듀서가 다루는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
|
|
3
|
+
export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation' | 'complete';
|
|
4
|
+
/**
|
|
5
|
+
* 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
|
|
6
|
+
*
|
|
7
|
+
* `at` 은 봉투의 `eventTime` 이 된다(리듀서가 늦게 온 옛 사실을 걸러내는 기준). 페이로드에는 싣지
|
|
8
|
+
* 않는다 — 델타에 없는 필드이고, 같은 사실이 두 시각을 갖지 않게.
|
|
9
|
+
*/
|
|
10
|
+
export interface OperationalRecordEnvelopeFields {
|
|
11
|
+
/** 발생 시각(ISO) — 없으면 `defaultEventTime`, 그것도 없으면 거부한다. */
|
|
12
|
+
at?: string;
|
|
13
|
+
/** 기록 시각(ISO) — 같은 발생 시각이 겹칠 때의 보조 순서. 리듀서가 페이로드에서 읽는다. */
|
|
14
|
+
recordTime?: string;
|
|
15
|
+
}
|
|
16
|
+
export type OperationalRecord = Record<string, unknown> & OperationalRecordEnvelopeFields;
|
|
17
|
+
export interface OperationalIngestOptions {
|
|
18
|
+
tenantId: string;
|
|
19
|
+
/** 레코드에 시각이 없을 때 쓸 값 — 주지 않으면 그 레코드를 거부한다. */
|
|
20
|
+
defaultEventTime?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
|
|
24
|
+
*
|
|
25
|
+
* EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
|
|
26
|
+
* **에너지** 상태의 이름이다(운영 설비는 `moverId`). 품질은 설비와 정체 필드를 공유하므로 `good` 으로
|
|
27
|
+
* 가른다 — 둘 다 아니면 어느 쪽인지 모르는 것이고, 모르면 받지 않는다.
|
|
28
|
+
*
|
|
29
|
+
* ── 순서가 뜻을 갖는다: **주체와 참조는 다르다** ────────────────────────────
|
|
30
|
+
* 정체 필드는 하나만 오지 않는다. 설비·사람·자산 델타는 「지금 붙어 있는 작업」(`taskId`)을 함께 싣고,
|
|
31
|
+
* 작업 델타는 「소속 오더」(`orderId`)를 함께 싣는다. 그래서 아무 정체 필드나 먼저 보면 **참조를 주체로
|
|
32
|
+
* 읽는다** — 실제로 그랬다: `moverId` + `taskId` 인 설비 사실을 작업으로 읽어 「계약에 없는 필드」로
|
|
33
|
+
* 거부했다. 자원(설비·사람·자산)을 먼저 보고, 작업을 오더보다 먼저 본다.
|
|
34
|
+
*/
|
|
35
|
+
export declare function operationalKindOf(record: unknown): OperationalKind | undefined;
|
|
36
|
+
/** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
|
|
37
|
+
export declare function isOperationalRecord(record: unknown): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* 운영 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 **이유와 함께** 남는다.
|
|
40
|
+
*
|
|
41
|
+
* 봉투는 다른 어휘와 같은 것을 쓴다(`CanonicalEnvelope`) — 그래서 저널·리플레이·시간여행·성과 폴드를
|
|
42
|
+
* 그대로 얻는다. 어휘만 자기 것이다.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ingestOperationalRecords(records: OperationalRecord | OperationalRecord[] | undefined | null, opts: OperationalIngestOptions): IngestResult;
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 운영 사실 인제스트 — **작업·설비·사람·자산·오더·품질이 들어오는 문.** (ADR-0029 어휘 넓히기)
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 없었나 (2026-08-19) ──────────────────────────────────────────────
|
|
5
|
+
* 관측 리듀서는 이 여섯을 **이미 다룬다**(`observed-reducer.ts` 의 `OP_EVENT.*` 분기). 그런데 라이브
|
|
6
|
+
* 인제스트 문은 어휘를 셋만 알았다: EPCIS 품목 사실, 에너지 계량, 설비 에너지 상태. 그래서 원본이
|
|
7
|
+
* 「이 작업이 끝났다」·「이 설비가 고장이다」를 말할 **길이 없었다** — 넣으면 `epc` 가 없어 EPCIS
|
|
8
|
+
* 검증에서 거부됐다.
|
|
9
|
+
*
|
|
10
|
+
* 그 결과가 이 프로젝트가 가장 싫어하는 모양이었다: **시뮬만 아는 상태.** 시뮬 커널은 작업과 설비를
|
|
11
|
+
* 알고 미러는 영원히 몰랐다. 그러면 같은 화면이 두 구동에서 다른 것을 말하고, 미러 위에 세운 예측은
|
|
12
|
+
* 「진행 중인 일이 하나도 없는 현장」에서 출발한다.
|
|
13
|
+
*
|
|
14
|
+
* ── 어휘는 델타의 이름이다 ──────────────────────────────────────────────────
|
|
15
|
+
* 필드 이름을 새로 짓지 않는다. `TaskStatusDelta`·`EquipmentStatusDelta`… 가 이미 계약이고, 리듀서가
|
|
16
|
+
* 그 이름으로 읽는다. 여기서 다른 이름을 받아 옮기면 **같은 사실에 두 어휘**가 생긴다(에너지가 그
|
|
17
|
+
* 규율을 먼저 세웠다: "필드 이름이 계약이다").
|
|
18
|
+
*
|
|
19
|
+
* ── 무엇을 거부하나 ─────────────────────────────────────────────────────────
|
|
20
|
+
* 지어낼 수 없는 것이 빠지면 거부한다 — 정체(누구의 상태인가)와 상태다. 그리고 **계산할 수 없는 낱말**도
|
|
21
|
+
* 거부한다: 설비 상태를 `'RUNNING'` 으로 받으면 아무 오류 없이 가동률이 0% 가 되고(누적기는 `busy`·
|
|
22
|
+
* `down` 만 센다), 사람이 `'available'` 이면 배정에서 조용히 사라진다. 그 실패는 화면에서 「일이 없는
|
|
23
|
+
* 공장」으로 보이고 원인을 되짚을 수 없다. 그래서 커널이 다룰 수 있는 낱말만 받고, **받는 낱말을 이유에
|
|
24
|
+
* 적어** 커넥터가 매핑을 고칠 수 있게 한다(매핑=밖, 검증=커널).
|
|
25
|
+
*
|
|
26
|
+
* 오더의 상태·종류는 **열려 있다** — 도메인이 소유한다(`picking`·`packed`·`shipped`…). 커널이 그 낱말로
|
|
27
|
+
* 무엇을 계산하지 않으므로 닫을 근거가 없다.
|
|
28
|
+
*
|
|
29
|
+
* ── 파생은 받아도 커널이 다시 계산한다 ──────────────────────────────────────
|
|
30
|
+
* 작업의 진척(`progress`)은 계약에 있어 받지만, 상태에 앉는 값은 커널이 **소요·남은 시간에서 다시
|
|
31
|
+
* 계산한 것**이다(`progressOf`). 그러니 원본이 진척을 보이게 하려면 `durationMs`·`remainingMs` 를 보내야
|
|
32
|
+
* 한다 — 파생을 사실로 삼지 않는 규율이고, 이 문을 붙이는 사람이 알아야 하는 사실이라 여기 적는다.
|
|
33
|
+
*
|
|
34
|
+
* 모르는 필드는 **조용히 버리지 않고 거부한다.** `taskID` 처럼 한 글자 틀린 이름은 통과시키면 영원히
|
|
35
|
+
* 보이지 않는 손실이 된다(이 문에는 아직 옛 발신자가 없어 호환 부담도 없다).
|
|
36
|
+
*/
|
|
37
|
+
import { OP_EVENT } from "./contract.js";
|
|
38
|
+
/**
|
|
39
|
+
* 닫아 둔 낱말과 그 이유.
|
|
40
|
+
* · 작업 상태 — 성과 폴드가 `completed`·`in-progress` 로 갈린다(`kpi-fold`).
|
|
41
|
+
* · 설비 상태 — OEE 누적기가 `busy`·`down` 만 센다. 그 밖의 낱말은 가동률 0% 로 조용히 앉는다.
|
|
42
|
+
* · 사람·자산 상태 — 배정이 `idle` 을 찾는다. 다른 낱말이면 있는 자원이 없는 것이 된다.
|
|
43
|
+
*/
|
|
44
|
+
const TASK_STATUS = ['created', 'assigned', 'in-progress', 'completed'];
|
|
45
|
+
const EQUIPMENT_STATUS = ['idle', 'busy', 'down'];
|
|
46
|
+
const PERSON_STATUS = ['idle', 'busy'];
|
|
47
|
+
const ASSET_STATUS = ['idle', 'in-use'];
|
|
48
|
+
const SPECS = {
|
|
49
|
+
task: {
|
|
50
|
+
eventType: OP_EVENT.task,
|
|
51
|
+
identity: 'taskId',
|
|
52
|
+
/* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
|
|
53
|
+
required: ['taskId', 'kind', 'status'],
|
|
54
|
+
fields: {
|
|
55
|
+
taskId: 'string', kind: 'string', status: 'string', fromNode: 'string', toNode: 'string',
|
|
56
|
+
itemRefs: 'string[]', resourceRef: 'string', resources: 'string[]', personnel: 'string[]', assets: 'string[]',
|
|
57
|
+
orderId: 'string', intent: 'string', progress: 'number', remainingMs: 'number', durationMs: 'number',
|
|
58
|
+
startedAtSimMs: 'number', outcome: 'string', priority: 'number', startTime: 'string', endTime: 'string',
|
|
59
|
+
materialActual: 'object[]', recordTime: 'string'
|
|
60
|
+
},
|
|
61
|
+
enums: {
|
|
62
|
+
status: TASK_STATUS,
|
|
63
|
+
intent: ['transport', 'process', 'dwell'],
|
|
64
|
+
/* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
|
|
65
|
+
outcome: ['good', 'scrap']
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
equipment: {
|
|
69
|
+
eventType: OP_EVENT.equipment,
|
|
70
|
+
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
|
|
71
|
+
required: ['moverId', 'kind', 'status'], // vocabulary-guard: allow 위와 같은 이유
|
|
72
|
+
fields: {
|
|
73
|
+
moverId: 'string', kind: 'string', status: 'string', location: 'string', homeLocation: 'string', // vocabulary-guard: allow
|
|
74
|
+
taskId: 'string', held: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string',
|
|
75
|
+
/* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
|
|
76
|
+
재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
|
|
77
|
+
motion: 'object'
|
|
78
|
+
},
|
|
79
|
+
enums: { status: EQUIPMENT_STATUS }
|
|
80
|
+
},
|
|
81
|
+
person: {
|
|
82
|
+
eventType: OP_EVENT.person,
|
|
83
|
+
identity: 'personId',
|
|
84
|
+
required: ['personId', 'status'],
|
|
85
|
+
fields: {
|
|
86
|
+
personId: 'string', status: 'string', personnelClassIds: 'string[]', taskId: 'string', location: 'string',
|
|
87
|
+
offShift: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string'
|
|
88
|
+
},
|
|
89
|
+
enums: { status: PERSON_STATUS }
|
|
90
|
+
},
|
|
91
|
+
asset: {
|
|
92
|
+
eventType: OP_EVENT.asset,
|
|
93
|
+
identity: 'assetId',
|
|
94
|
+
required: ['assetId', 'status'],
|
|
95
|
+
fields: {
|
|
96
|
+
assetId: 'string', status: 'string', assetClassIds: 'string[]', location: 'string', taskId: 'string',
|
|
97
|
+
carrying: 'string', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string'
|
|
98
|
+
},
|
|
99
|
+
enums: { status: ASSET_STATUS }
|
|
100
|
+
},
|
|
101
|
+
order: {
|
|
102
|
+
eventType: OP_EVENT.order,
|
|
103
|
+
identity: 'orderId',
|
|
104
|
+
/*
|
|
105
|
+
* 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
|
|
106
|
+
* 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
|
|
107
|
+
*/
|
|
108
|
+
required: ['orderId', 'kind', 'status', 'requested', 'fulfilled'],
|
|
109
|
+
fields: {
|
|
110
|
+
orderId: 'string', kind: 'string', status: 'string', requested: 'number', fulfilled: 'number',
|
|
111
|
+
gtin: 'string', held: 'boolean', lines: 'object[]', priority: 'number', startTime: 'string', endTime: 'string',
|
|
112
|
+
allocated: 'string[]', bizTransaction: 'string', operationsRequestId: 'string', dockDoor: 'string', windowStartMs: 'number', recordTime: 'string'
|
|
113
|
+
}
|
|
114
|
+
/* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
|
|
115
|
+
},
|
|
116
|
+
quality: {
|
|
117
|
+
eventType: OP_EVENT.quality,
|
|
118
|
+
identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드
|
|
119
|
+
/* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
|
|
120
|
+
required: ['moverId', 'good', 'goodCount', 'scrapCount'], // vocabulary-guard: allow
|
|
121
|
+
fields: { moverId: 'string', good: 'boolean', goodCount: 'number', scrapCount: 'number', recordTime: 'string' } // vocabulary-guard: allow
|
|
122
|
+
},
|
|
123
|
+
/*
|
|
124
|
+
* **시험 결과** — 대상을 가리켜 들어온다(표준 `TestResult.TestableObjectID`).
|
|
125
|
+
*
|
|
126
|
+
* `result` 를 **요구하지 않는다**: 재기만 하고 판정하지 않는 원천이 정상이고, 요구하면 그 원천의
|
|
127
|
+
* 사실을 아예 담을 수 없다(§`TestResult.result`). 비어 있으면 커널이 선언된 기준으로 판정하고
|
|
128
|
+
* `derived` 를 세운다 — 판정하지 못하면 비워 둔다.
|
|
129
|
+
*
|
|
130
|
+
* `propertyMeasurements` 안쪽은 재검사하지 않는다 — 그 모양은 `PropertyMeasurement` 계약이고,
|
|
131
|
+
* 여기서 두 번 지키면 두 벌이 된다(설비 `motion` 과 같은 규율).
|
|
132
|
+
*/
|
|
133
|
+
test: {
|
|
134
|
+
eventType: OP_EVENT.test,
|
|
135
|
+
identity: 'testableObjectId',
|
|
136
|
+
/* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
|
|
137
|
+
required: ['testableObjectId', 'specId'],
|
|
138
|
+
fields: {
|
|
139
|
+
testableObjectId: 'string', specId: 'string', result: 'string', at: 'string', expiresAt: 'string',
|
|
140
|
+
derived: 'boolean', propertyMeasurements: 'object[]', recordTime: 'string'
|
|
141
|
+
},
|
|
142
|
+
enums: { result: ['pass', 'fail'] }
|
|
143
|
+
},
|
|
144
|
+
/*
|
|
145
|
+
* **자리의 물리 관측** — 어느 자리의 어느 속성을 언제 얼마로 쟀나.
|
|
146
|
+
*
|
|
147
|
+
* 값에 **단위를 함께** 받는다(`ValueType`). 단위 없는 물리량은 판정의 재료가 못 된다 — 3 이 섭씨인지
|
|
148
|
+
* 화씨인지 모르면 어떤 기준으로도 판정할 수 없다. 다만 **요구하지는 않는다**: 단위를 비우는 실 원본이
|
|
149
|
+
* 흔하고, 요구하면 그 원본의 관측을 아예 담지 못한다(그때 판정은 커널이 거부한다 — `outsideLimit`).
|
|
150
|
+
*
|
|
151
|
+
* `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
|
|
152
|
+
* 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
|
|
153
|
+
*/
|
|
154
|
+
/*
|
|
155
|
+
* **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
|
|
156
|
+
*
|
|
157
|
+
* 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
|
|
158
|
+
* 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
|
|
159
|
+
*/
|
|
160
|
+
complete: {
|
|
161
|
+
eventType: OP_EVENT.complete,
|
|
162
|
+
identity: 'completeAxis',
|
|
163
|
+
required: ['completeAxis', 'since'],
|
|
164
|
+
fields: { completeAxis: 'string', since: 'string', recordTime: 'string' }
|
|
165
|
+
},
|
|
166
|
+
observation: {
|
|
167
|
+
eventType: OP_EVENT.observation,
|
|
168
|
+
identity: 'locationId',
|
|
169
|
+
/* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
|
|
170
|
+
required: ['locationId', 'propertyId'],
|
|
171
|
+
fields: {
|
|
172
|
+
locationId: 'string', propertyId: 'string',
|
|
173
|
+
value: 'string', dataType: 'string', uom: 'string',
|
|
174
|
+
effectiveTime: 'string', effectiveEndTime: 'string', recordTime: 'string',
|
|
175
|
+
source: 'string', derived: 'boolean'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
|
|
181
|
+
*
|
|
182
|
+
* EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
|
|
183
|
+
* **에너지** 상태의 이름이다(운영 설비는 `moverId`). 품질은 설비와 정체 필드를 공유하므로 `good` 으로
|
|
184
|
+
* 가른다 — 둘 다 아니면 어느 쪽인지 모르는 것이고, 모르면 받지 않는다.
|
|
185
|
+
*
|
|
186
|
+
* ── 순서가 뜻을 갖는다: **주체와 참조는 다르다** ────────────────────────────
|
|
187
|
+
* 정체 필드는 하나만 오지 않는다. 설비·사람·자산 델타는 「지금 붙어 있는 작업」(`taskId`)을 함께 싣고,
|
|
188
|
+
* 작업 델타는 「소속 오더」(`orderId`)를 함께 싣는다. 그래서 아무 정체 필드나 먼저 보면 **참조를 주체로
|
|
189
|
+
* 읽는다** — 실제로 그랬다: `moverId` + `taskId` 인 설비 사실을 작업으로 읽어 「계약에 없는 필드」로
|
|
190
|
+
* 거부했다. 자원(설비·사람·자산)을 먼저 보고, 작업을 오더보다 먼저 본다.
|
|
191
|
+
*/
|
|
192
|
+
export function operationalKindOf(record) {
|
|
193
|
+
if (!record || typeof record !== 'object')
|
|
194
|
+
return undefined;
|
|
195
|
+
const r = record;
|
|
196
|
+
if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
|
|
197
|
+
return undefined;
|
|
198
|
+
const has = (k) => typeof r[k] === 'string' && r[k].trim().length > 0;
|
|
199
|
+
/* vocabulary-guard: allow 저널 와이어 필드로 가른다 */
|
|
200
|
+
if (has('moverId'))
|
|
201
|
+
return r.good !== undefined ? 'quality' : 'equipment';
|
|
202
|
+
if (has('personId'))
|
|
203
|
+
return 'person';
|
|
204
|
+
if (has('assetId'))
|
|
205
|
+
return 'asset';
|
|
206
|
+
if (has('taskId'))
|
|
207
|
+
return 'task'; // 작업이 든 `orderId` 는 소속(참조)이다
|
|
208
|
+
if (has('orderId'))
|
|
209
|
+
return 'order';
|
|
210
|
+
/*
|
|
211
|
+
* ── ★ **채널을 열고 들어오는 길을 내지 않았다** (2026-08-24) ─────────────────
|
|
212
|
+
* `OP_EVENT.test` 를 계약에 냈는데 이 라우팅이 `testableObjectId` 를 보지 않았다. 그래서 커넥터의
|
|
213
|
+
* 시험 결과가 **어느 통도 아니어서 조용히 버려졌다** — 거부 목록에도 남지 않았다(운영 경로를 아예
|
|
214
|
+
* 지나지 않으므로).
|
|
215
|
+
*
|
|
216
|
+
* 같은 부류가 하루에 세 번 났다: `ilmd`(매핑에 자리 없음) · 사건 시각(이름 어긋남) · 그리고 이것.
|
|
217
|
+
* **계약에 자리를 만드는 것과 그 자리로 가는 길을 내는 것은 다른 일이다.** 앞의 것만 하면 보내는
|
|
218
|
+
* 쪽에는 「실었다」로 보이고 화면에는 「없다」로 보인다.
|
|
219
|
+
*
|
|
220
|
+
* 순서상 뒤에 둔다 — 시험 결과가 작업·오더를 함께 가리킬 수 있고, 그때 그것은 **그 작업의 사실**이다.
|
|
221
|
+
*/
|
|
222
|
+
if (has('testableObjectId'))
|
|
223
|
+
return 'test';
|
|
224
|
+
/*
|
|
225
|
+
* ── ★ **채널을 열고 또 길을 내지 않았다** (2026-08-24) ──────────────────────
|
|
226
|
+
* `OP_EVENT.observation`(`location.measured`)을 내고 상태(`LocationState.observations`)와 조회
|
|
227
|
+
* (`observationAt`)까지 붙였는데 **이 라우팅이 `locationId` 를 보지 않았다.** 커넥터가 방의 온습도를
|
|
228
|
+
* 실어 보내면 「어느 운영 사실인지 모른다」로 거부됐다.
|
|
229
|
+
*
|
|
230
|
+
* 같은 부류를 하루에 일곱 번 만났다. 다만 이번엔 **거부되고 이유가 남았다** — 시험 결과 때는 어느
|
|
231
|
+
* 통도 아니어서 조용히 사라졌다. 그 차이가 이것을 5분 만에 찾게 했다(§`isOperationalRecord`).
|
|
232
|
+
*
|
|
233
|
+
* **둘을 함께 요구한다.** `locationId` 만으로는 자리를 말하는 다른 사실과 섞인다. 관측은 「어느
|
|
234
|
+
* 자리의 **무엇**을 쟀나」이므로 속성 없이는 담을 곳이 없다 — 그때는 받지 않는 것이 옳다.
|
|
235
|
+
*/
|
|
236
|
+
if (has('locationId') && has('propertyId'))
|
|
237
|
+
return 'observation';
|
|
238
|
+
/* 「이 목록이 전부다」 — 어느 목록인지를 스스로 말하므로 다른 사실과 섞이지 않는다. */
|
|
239
|
+
if (has('completeAxis'))
|
|
240
|
+
return 'complete';
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
/** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
|
|
244
|
+
export function isOperationalRecord(record) {
|
|
245
|
+
return operationalKindOf(record) !== undefined;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 운영 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 **이유와 함께** 남는다.
|
|
249
|
+
*
|
|
250
|
+
* 봉투는 다른 어휘와 같은 것을 쓴다(`CanonicalEnvelope`) — 그래서 저널·리플레이·시간여행·성과 폴드를
|
|
251
|
+
* 그대로 얻는다. 어휘만 자기 것이다.
|
|
252
|
+
*/
|
|
253
|
+
export function ingestOperationalRecords(records, opts) {
|
|
254
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
255
|
+
const accepted = [];
|
|
256
|
+
const rejected = [];
|
|
257
|
+
let seq = 0;
|
|
258
|
+
for (const record of arr) {
|
|
259
|
+
const kind = operationalKindOf(record);
|
|
260
|
+
if (!kind) {
|
|
261
|
+
rejected.push({
|
|
262
|
+
record,
|
|
263
|
+
errors: ['어느 운영 사실인지 모른다 — 정체 필드가 필요하다(taskId · moverId(+good=품질) · personId · assetId · orderId)'] // vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const spec = SPECS[kind];
|
|
268
|
+
const r = record;
|
|
269
|
+
const errors = [];
|
|
270
|
+
/*
|
|
271
|
+
* 모르는 이름은 거부한다 — 한 글자 틀린 필드가 오류 없이 사라지는 것을 막는다.
|
|
272
|
+
*
|
|
273
|
+
* 둘은 뺀다. **사실의 칸이 아니라 봉투의 칸**이다: `at` 은 그 사실이 일어난 시각이고,
|
|
274
|
+
* `description` 은 사람이 그때 적어 둔 말이다(§`CanonicalEnvelope.description`). 사실 칸으로
|
|
275
|
+
* 검사하면 명세마다 같은 두 줄을 적어야 하고, 한 곳을 빠뜨리면 그 통로만 거부한다.
|
|
276
|
+
*/
|
|
277
|
+
const ENVELOPE_FIELDS = ['at', 'description'];
|
|
278
|
+
const unknown = Object.keys(r).filter(k => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === undefined);
|
|
279
|
+
if (unknown.length)
|
|
280
|
+
errors.push(`${kind}: 계약에 없는 필드 — ${unknown.join(', ')}`);
|
|
281
|
+
for (const name of spec.required) {
|
|
282
|
+
const v = r[name];
|
|
283
|
+
if (v === undefined || v === null || v === '')
|
|
284
|
+
errors.push(`${kind}: ${name} 없음 — 지어낼 수 없는 값이다`);
|
|
285
|
+
}
|
|
286
|
+
const data = {};
|
|
287
|
+
for (const [name, type] of Object.entries(spec.fields)) {
|
|
288
|
+
const v = r[name];
|
|
289
|
+
if (v === undefined || v === null || v === '')
|
|
290
|
+
continue;
|
|
291
|
+
switch (type) {
|
|
292
|
+
case 'string': {
|
|
293
|
+
if (typeof v !== 'string') {
|
|
294
|
+
errors.push(`${kind}.${name} 이 문자열이 아니다: ${JSON.stringify(v)}`);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
const allowed = spec.enums?.[name];
|
|
298
|
+
if (allowed && !allowed.includes(v)) {
|
|
299
|
+
errors.push(`${kind}.${name} 이 커널이 다루는 낱말이 아니다: ${JSON.stringify(v)} — 받는 값은 ${allowed.join(' · ')}`);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
data[name] = v;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case 'number': {
|
|
306
|
+
const n = Number(v);
|
|
307
|
+
if (typeof v === 'boolean' || !Number.isFinite(n)) {
|
|
308
|
+
errors.push(`${kind}.${name} 가 수가 아니다: ${JSON.stringify(v)}`);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
/* 진척은 비율이다 — 백분율(95)을 그대로 받으면 화면이 9,500% 를 말한다. */
|
|
312
|
+
if (name === 'progress' && (n < 0 || n > 1)) {
|
|
313
|
+
errors.push(`${kind}.progress 는 0~1 비율이다: ${n}`);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
if ((name === 'requested' || name === 'fulfilled' || name === 'goodCount' || name === 'scrapCount') && n < 0) {
|
|
317
|
+
errors.push(`${kind}.${name} 가 음수다: ${n}`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
data[name] = n;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case 'boolean': {
|
|
324
|
+
if (typeof v !== 'boolean') {
|
|
325
|
+
errors.push(`${kind}.${name} 가 참/거짓이 아니다: ${JSON.stringify(v)}`);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
data[name] = v;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
case 'string[]': {
|
|
332
|
+
if (!Array.isArray(v) || v.some(x => typeof x !== 'string')) {
|
|
333
|
+
errors.push(`${kind}.${name} 가 문자열 배열이 아니다: ${JSON.stringify(v)}`);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
data[name] = v.slice();
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case 'object': {
|
|
340
|
+
if (Array.isArray(v) || typeof v !== 'object') {
|
|
341
|
+
errors.push(`${kind}.${name} 가 객체가 아니다: ${JSON.stringify(v)}`);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
data[name] = { ...v };
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case 'object[]': {
|
|
348
|
+
if (!Array.isArray(v) || v.some(x => !x || typeof x !== 'object')) {
|
|
349
|
+
errors.push(`${kind}.${name} 가 객체 배열이 아니다: ${JSON.stringify(v)}`);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
data[name] = v.map(x => ({ ...x }));
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const at = String(r.at ?? '').trim() || opts.defaultEventTime;
|
|
358
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
359
|
+
if (!Number.isFinite(atMs)) {
|
|
360
|
+
/* 시각이 없으면 순서를 판정할 수 없다 — 늦게 온 옛 사실이 최신 상태를 덮어써 위치가 과거로 튄다. */
|
|
361
|
+
errors.push(`${kind}: at 없음/형식 오류 — 시각 없이는 늦게 온 옛 사실을 걸러낼 수 없다`);
|
|
362
|
+
}
|
|
363
|
+
if (errors.length) {
|
|
364
|
+
rejected.push({ record, errors });
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
/* 사람이 적어 둔 말 — 봉투에 싣는다(§`CanonicalEnvelope.description`). 물류 쪽과 같은 자리다. */
|
|
368
|
+
const note = typeof r.description === 'string' ? String(r.description).trim() : '';
|
|
369
|
+
accepted.push({
|
|
370
|
+
eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
|
|
371
|
+
eventType: spec.eventType,
|
|
372
|
+
eventTime: new Date(atMs).toISOString(),
|
|
373
|
+
tenantId: opts.tenantId,
|
|
374
|
+
...(note ? { description: note } : {}),
|
|
375
|
+
data
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return { accepted, rejected };
|
|
379
|
+
}
|