@operato/ops-contract 0.7.2 → 0.8.1

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.
@@ -0,0 +1,157 @@
1
+ import type { ISOTime } from './contract.ts';
2
+ /**
3
+ * 커맨드의 상태.
4
+ *
5
+ * proposed 트윈이 냈다. 아직 아무 일도 일어나지 않았다
6
+ * approved 사람이 승인했다 — 누가 · 언제가 함께 남는다
7
+ * rejected 사람이 거절했다. 여기서 끝난다
8
+ * dispatched 어댑터에 넘겼다. 저쪽이 받았는지는 아직 모른다
9
+ * acked 저쪽이 답했다
10
+ * failed 넘기다 실패했다. 다시 넘길 수 있다
11
+ */
12
+ export declare const COMMAND_STATE: readonly ["proposed", "approved", "rejected", "dispatched", "acked", "failed"];
13
+ export type CommandState = (typeof COMMAND_STATE)[number];
14
+ /** 이 커맨드를 낸 것이 누구인가 — 사람이 승인할 때 그 판단의 재료가 된다. */
15
+ export declare const COMMAND_ORIGIN: readonly ["operator", "rule", "ai", "schedule"];
16
+ export type CommandOrigin = (typeof COMMAND_ORIGIN)[number];
17
+ /** 사람이 승인하거나 거절한 기록. **재기동을 넘어 살아야 한다.** */
18
+ export interface CommandApproval {
19
+ by: string;
20
+ at: ISOTime;
21
+ note?: string;
22
+ }
23
+ /**
24
+ * 현장에 내리는 조치 하나.
25
+ *
26
+ * `reversible` 의 기본은 **종류가 정한다**(§`CommandTypeSpec.reversible`). 커맨드가 그것을 들고 다니는
27
+ * 이유는 저장되고 되세워지기 때문이다 — 명세를 매번 다시 찾지 않는다.
28
+ *
29
+ * **커맨드는 더 엄하게만 갈 수 있다.** 종류가 「되돌릴 수 있다」고 해도 그 한 건이 되돌릴 수 없다면
30
+ * 그렇게 적을 수 있다(절반 만든 작업의 취소). 반대는 안 된다 — 종류가 되돌릴 수 없다고 한 것을 커맨드가
31
+ * 뒤집으면 게이트가 그 한 건에서만 열리고, 그것이 이 자리에서 가장 나쁜 결과다.
32
+ */
33
+ export interface TwinCommand {
34
+ id: string;
35
+ /** 어느 트윈의 조치인가. */
36
+ instanceId: string;
37
+ /** 무엇을 하라는 것인가 — 트윈 어휘. 레거시 호출로 옮기는 것은 어댑터의 일이다. */
38
+ type: string;
39
+ payload?: Record<string, unknown>;
40
+ origin: CommandOrigin;
41
+ proposedAt: ISOTime;
42
+ /**
43
+ * 되돌릴 수 있는가. **모르면 적지 않는다** — 없는 것은 「되돌릴 수 없다」로 읽는다.
44
+ * 모름을 「되돌릴 수 있다」로 읽으면 그 커맨드가 승인 없이 나간다.
45
+ */
46
+ reversible?: boolean;
47
+ state: CommandState;
48
+ approval?: CommandApproval;
49
+ /** 어댑터가 돌려준 것 — 저쪽에서 무엇이 되었는지 되짚을 수 있어야 한다. */
50
+ dispatchRef?: string;
51
+ error?: string;
52
+ }
53
+ /**
54
+ * 사람이 먼저 봐야 하는가.
55
+ *
56
+ * **모르면 봐야 한다.** `reversible` 이 참이라고 명시된 것만 게이트를 지나지 않는다 — 판단하지 못한
57
+ * 커맨드가 자동으로 나가는 쪽이 반대보다 훨씬 나쁘다.
58
+ */
59
+ export declare function requiresApproval(command: Pick<TwinCommand, 'reversible'>): boolean;
60
+ /** 상태를 옮기는 사건. */
61
+ export declare const COMMAND_EVENT: readonly ["approve", "reject", "dispatch", "ack", "fail"];
62
+ export type CommandEvent = (typeof COMMAND_EVENT)[number];
63
+ /** 그 전이가 허용되나 — 허용되면 다음 상태, 아니면 `undefined`. */
64
+ export declare function nextCommandState(from: CommandState, event: CommandEvent): CommandState | undefined;
65
+ /** 더 갈 곳이 없는 상태인가 — 목록에서 지워도 되는지 화면이 이것으로 판단한다. */
66
+ export declare function isCommandTerminal(state: CommandState): boolean;
67
+ declare const approvedBrand: unique symbol;
68
+ /**
69
+ * **승인을 지난 커맨드** — 조치를 부르는 함수는 이것만 받는다.
70
+ *
71
+ * 이 표식은 `approveCommand` 로만 붙는다. 그래서 승인 없는 커맨드를 조치에 넘기는 코드는 **컴파일이
72
+ * 되지 않는다.** 부르는 자리마다 참·거짓을 확인하는 방식과 다른 점이 이것이다 — 확인을 빠뜨릴 자리가
73
+ * 없다.
74
+ *
75
+ * 우회하려면 `as` 로 억지로 만들어야 하고, 그것은 검토에서 보인다.
76
+ */
77
+ export type ApprovedCommand = TwinCommand & {
78
+ readonly [approvedBrand]: true;
79
+ };
80
+ /**
81
+ * 승인한다 — **승인할 수 없는 것은 던진다.**
82
+ *
83
+ * 조용히 실패하지 않는 이유는, 실패를 답으로 돌려주면 부르는 쪽이 그것을 무시할 수 있기 때문이다.
84
+ * 승인이 안 된 것을 승인된 줄 알고 넘기는 것이 이 자리에서 가장 나쁜 결과다.
85
+ */
86
+ export declare function approveCommand(command: TwinCommand, approval: CommandApproval): ApprovedCommand;
87
+ /**
88
+ * 되돌릴 수 있는 커맨드를 사람 없이 통과시킨다 — **게이트를 여는 유일한 다른 문**이다.
89
+ *
90
+ * 시뮬레이션과 what-if 가 이 길로 간다. 되돌릴 수 없는 것에 이 함수를 쓰면 던진다 — 그것이 이 문의
91
+ * 뜻이고, 뜻을 어기면 게이트가 없는 것과 같아진다.
92
+ */
93
+ export declare function passThroughReversible(command: TwinCommand): ApprovedCommand;
94
+ /**
95
+ * 승인 표식이 붙었는지 실행 중에도 본다 — **타입만으로는 부족한 자리가 있다.**
96
+ *
97
+ * 저장소에서 되세운 커맨드는 타입이 지워진 채로 온다(JSON 에는 표식이 없다). 그때 이 함수로 확인하고
98
+ * 다시 표식을 붙인다. 확인 없이 `as` 로 붙이면 재기동이 게이트를 여는 길이 된다.
99
+ */
100
+ export declare function asApproved(command: TwinCommand): ApprovedCommand;
101
+ /**
102
+ * 조치 한 종류의 명세.
103
+ *
104
+ * ── 왜 필요한가 (2026-08-31, 인티그레이션 레인 지적) ──────────────────────
105
+ * `TwinCommand.type` 이 열린 문자열이었다. 그러면 **어댑터를 만드는 사람이 이름을 지어내고**, 그
106
+ * 이름이 계약에 없으므로 다음 어댑터가 다른 이름을 쓴다. 같은 조치가 두 이름으로 갈린다.
107
+ *
108
+ * ── 왜 전역 목록이 아닌가 ────────────────────────────────────────────────
109
+ * 조치는 도메인마다 다르다 — 제조의 「생산 지시」와 창고의 「오더 보류」는 같은 목록에 있을 것이 아니다.
110
+ * 전역 목록으로 두면 도메인이 늘 때마다 계약을 고치게 된다.
111
+ *
112
+ * 그래서 **도메인 프로파일이 자기 종류를 선언한다**(`MES_COMMANDS` 처럼). bizStep 과 자리 타입을
113
+ * 프로파일이 선언하는 것과 같은 결이다.
114
+ */
115
+ export interface CommandTypeSpec {
116
+ /** 트윈 어휘의 종류 이름. 점으로 나눈다(`production.order`). */
117
+ type: string;
118
+ label: string;
119
+ /**
120
+ * 이 종류가 되돌릴 수 있는가 — **기본을 정하는 자리**.
121
+ *
122
+ * 커맨드는 이것보다 **더 엄하게만** 갈 수 있다(§`TwinCommand.reversible`). 느슨하게 가는 길을 열면
123
+ * 그것을 내는 코드의 실수 하나가 게이트를 그 한 건에서만 연다.
124
+ */
125
+ reversible: boolean;
126
+ /**
127
+ * 이 종류가 요구하는 `payload` 칸.
128
+ *
129
+ * 없으면 보내는 쪽이 무엇을 담을지 짐작하고, 받는 어댑터는 없는 칸을 조용히 지나친다.
130
+ */
131
+ required: readonly string[];
132
+ }
133
+ /** 그 종류의 명세를 찾는다 — 모르는 종류면 `undefined`. */
134
+ export declare function commandTypeOf(specs: readonly CommandTypeSpec[], type: string): CommandTypeSpec | undefined;
135
+ /**
136
+ * 그 종류의 커맨드를 만든다 — **되돌릴 수 있는지를 명세에서 가져온다.**
137
+ *
138
+ * 부르는 쪽이 `reversible` 을 적지 않는다. 적게 두면 그 값이 두 곳에 살고, 내는 코드의 실수 하나가
139
+ * 게이트를 그 한 건에서만 연다.
140
+ */
141
+ export declare function commandFromSpec(spec: CommandTypeSpec, seed: {
142
+ id: string;
143
+ instanceId: string;
144
+ origin: CommandOrigin;
145
+ proposedAt: ISOTime;
146
+ payload?: Record<string, unknown>;
147
+ }): TwinCommand;
148
+ /** 요구하는 칸 중 없는 것 — 목록으로 낸다(하나씩 고치게 하지 않는다). */
149
+ export declare function missingCommandPayload(spec: CommandTypeSpec, payload: Record<string, unknown> | undefined): string[];
150
+ /**
151
+ * 커맨드가 자기 종류의 명세와 어긋나지 않는가 — **어긋남을 조용히 지나치지 않는다.**
152
+ *
153
+ * 저장본을 손으로 고치거나 옛 코드가 만든 커맨드가 명세와 다른 `reversible` 을 들고 있을 수 있다.
154
+ * 그 한 건에서만 게이트가 열리는 것을 막는다.
155
+ */
156
+ export declare function commandSpecGaps(specs: readonly CommandTypeSpec[], command: TwinCommand): string[];
157
+ export {};
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 커맨드의 상태.
3
+ *
4
+ * proposed 트윈이 냈다. 아직 아무 일도 일어나지 않았다
5
+ * approved 사람이 승인했다 — 누가 · 언제가 함께 남는다
6
+ * rejected 사람이 거절했다. 여기서 끝난다
7
+ * dispatched 어댑터에 넘겼다. 저쪽이 받았는지는 아직 모른다
8
+ * acked 저쪽이 답했다
9
+ * failed 넘기다 실패했다. 다시 넘길 수 있다
10
+ */
11
+ export const COMMAND_STATE = ['proposed', 'approved', 'rejected', 'dispatched', 'acked', 'failed'];
12
+ /** 이 커맨드를 낸 것이 누구인가 — 사람이 승인할 때 그 판단의 재료가 된다. */
13
+ export const COMMAND_ORIGIN = ['operator', 'rule', 'ai', 'schedule'];
14
+ /**
15
+ * 사람이 먼저 봐야 하는가.
16
+ *
17
+ * **모르면 봐야 한다.** `reversible` 이 참이라고 명시된 것만 게이트를 지나지 않는다 — 판단하지 못한
18
+ * 커맨드가 자동으로 나가는 쪽이 반대보다 훨씬 나쁘다.
19
+ */
20
+ export function requiresApproval(command) {
21
+ return command?.reversible !== true;
22
+ }
23
+ /* ── 상태 전이 ─────────────────────────────────────────────────────────────── */
24
+ /** 상태를 옮기는 사건. */
25
+ export const COMMAND_EVENT = ['approve', 'reject', 'dispatch', 'ack', 'fail'];
26
+ /**
27
+ * 어느 상태에서 어느 사건이 허용되나 — **표 하나가 전부다.**
28
+ *
29
+ * `if` 사슬로 두면 새 상태를 넣을 때 고칠 자리가 흩어지고, 한 곳을 빠뜨린 것이 「그 전이만 조용히
30
+ * 허용됨」으로 나타난다.
31
+ */
32
+ const ALLOWED = {
33
+ proposed: { approve: 'approved', reject: 'rejected' },
34
+ approved: { dispatch: 'dispatched', reject: 'rejected' },
35
+ /* 넘기다 실패한 것은 다시 넘길 수 있다 — 승인은 그대로 살아 있다. */
36
+ failed: { dispatch: 'dispatched', reject: 'rejected' },
37
+ dispatched: { ack: 'acked', fail: 'failed' },
38
+ acked: {},
39
+ rejected: {}
40
+ };
41
+ /** 그 전이가 허용되나 — 허용되면 다음 상태, 아니면 `undefined`. */
42
+ export function nextCommandState(from, event) {
43
+ return ALLOWED[from]?.[event];
44
+ }
45
+ /** 더 갈 곳이 없는 상태인가 — 목록에서 지워도 되는지 화면이 이것으로 판단한다. */
46
+ export function isCommandTerminal(state) {
47
+ return Object.keys(ALLOWED[state] ?? {}).length === 0;
48
+ }
49
+ /**
50
+ * 승인한다 — **승인할 수 없는 것은 던진다.**
51
+ *
52
+ * 조용히 실패하지 않는 이유는, 실패를 답으로 돌려주면 부르는 쪽이 그것을 무시할 수 있기 때문이다.
53
+ * 승인이 안 된 것을 승인된 줄 알고 넘기는 것이 이 자리에서 가장 나쁜 결과다.
54
+ */
55
+ export function approveCommand(command, approval) {
56
+ if (!approval?.by?.trim())
57
+ throw new Error('승인에는 누가 승인했는지가 있어야 한다');
58
+ if (!approval?.at?.trim())
59
+ throw new Error('승인에는 언제 승인했는지가 있어야 한다');
60
+ const to = nextCommandState(command.state, 'approve');
61
+ if (!to)
62
+ throw new Error(`${command.state} 상태의 커맨드는 승인할 수 없다 (${command.id})`);
63
+ return { ...command, state: to, approval };
64
+ }
65
+ /**
66
+ * 되돌릴 수 있는 커맨드를 사람 없이 통과시킨다 — **게이트를 여는 유일한 다른 문**이다.
67
+ *
68
+ * 시뮬레이션과 what-if 가 이 길로 간다. 되돌릴 수 없는 것에 이 함수를 쓰면 던진다 — 그것이 이 문의
69
+ * 뜻이고, 뜻을 어기면 게이트가 없는 것과 같아진다.
70
+ */
71
+ export function passThroughReversible(command) {
72
+ if (requiresApproval(command)) {
73
+ throw new Error(`되돌릴 수 없는 커맨드는 사람 승인을 지나야 한다 (${command.id} · ${command.type})`);
74
+ }
75
+ const to = nextCommandState(command.state, 'approve');
76
+ if (!to)
77
+ throw new Error(`${command.state} 상태의 커맨드는 넘길 수 없다 (${command.id})`);
78
+ return { ...command, state: to };
79
+ }
80
+ /**
81
+ * 승인 표식이 붙었는지 실행 중에도 본다 — **타입만으로는 부족한 자리가 있다.**
82
+ *
83
+ * 저장소에서 되세운 커맨드는 타입이 지워진 채로 온다(JSON 에는 표식이 없다). 그때 이 함수로 확인하고
84
+ * 다시 표식을 붙인다. 확인 없이 `as` 로 붙이면 재기동이 게이트를 여는 길이 된다.
85
+ */
86
+ export function asApproved(command) {
87
+ if (command.state !== 'approved' && command.state !== 'failed') {
88
+ throw new Error(`승인되지 않은 커맨드를 조치로 넘길 수 없다 (${command.id} · ${command.state})`);
89
+ }
90
+ if (requiresApproval(command) && !command.approval?.by) {
91
+ throw new Error(`승인 기록이 없는 커맨드를 조치로 넘길 수 없다 (${command.id})`);
92
+ }
93
+ return command;
94
+ }
95
+ /** 그 종류의 명세를 찾는다 — 모르는 종류면 `undefined`. */
96
+ export function commandTypeOf(specs, type) {
97
+ return specs.find(s => s.type === type);
98
+ }
99
+ /**
100
+ * 그 종류의 커맨드를 만든다 — **되돌릴 수 있는지를 명세에서 가져온다.**
101
+ *
102
+ * 부르는 쪽이 `reversible` 을 적지 않는다. 적게 두면 그 값이 두 곳에 살고, 내는 코드의 실수 하나가
103
+ * 게이트를 그 한 건에서만 연다.
104
+ */
105
+ export function commandFromSpec(spec, seed) {
106
+ const missing = missingCommandPayload(spec, seed.payload);
107
+ if (missing.length) {
108
+ throw new Error(`${spec.type}: 요구하는 칸이 없다 — ${missing.join(', ')}`);
109
+ }
110
+ return {
111
+ id: seed.id,
112
+ instanceId: seed.instanceId,
113
+ type: spec.type,
114
+ ...(seed.payload ? { payload: seed.payload } : {}),
115
+ origin: seed.origin,
116
+ proposedAt: seed.proposedAt,
117
+ reversible: spec.reversible,
118
+ state: 'proposed'
119
+ };
120
+ }
121
+ /** 요구하는 칸 중 없는 것 — 목록으로 낸다(하나씩 고치게 하지 않는다). */
122
+ export function missingCommandPayload(spec, payload) {
123
+ const has = (k) => {
124
+ const v = payload?.[k];
125
+ if (v === undefined || v === null)
126
+ return false;
127
+ return typeof v === 'string' ? v.trim().length > 0 : true;
128
+ };
129
+ return spec.required.filter(k => !has(k));
130
+ }
131
+ /**
132
+ * 커맨드가 자기 종류의 명세와 어긋나지 않는가 — **어긋남을 조용히 지나치지 않는다.**
133
+ *
134
+ * 저장본을 손으로 고치거나 옛 코드가 만든 커맨드가 명세와 다른 `reversible` 을 들고 있을 수 있다.
135
+ * 그 한 건에서만 게이트가 열리는 것을 막는다.
136
+ */
137
+ export function commandSpecGaps(specs, command) {
138
+ const spec = commandTypeOf(specs, command.type);
139
+ if (!spec)
140
+ return [`모르는 조치 종류다 — ${command.type}`];
141
+ const gaps = missingCommandPayload(spec, command.payload).map(k => `요구하는 칸이 없다 — ${k}`);
142
+ /*
143
+ * **느슨해진 것만 잡는다.** 종류가 되돌릴 수 없다고 했는데 커맨드가 되돌릴 수 있다고 하면, 그 한 건이
144
+ * 사람 승인을 건너뛴다. 반대(더 엄해진 것)는 정상이다 — 절반 만든 작업의 취소가 그렇다.
145
+ */
146
+ if (command.reversible === true && spec.reversible === false) {
147
+ gaps.push(`${command.type} 은 되돌릴 수 없는 종류인데 이 커맨드가 되돌릴 수 있다고 한다 — 승인을 건너뛴다`);
148
+ }
149
+ return gaps;
150
+ }
@@ -239,7 +239,13 @@ export interface OpMaterialSpecification {
239
239
  uom?: string;
240
240
  }
241
241
  /** 라우트(오퍼레이션 시퀀스) — ISA-95 ProcessSegment 연결. */
242
- export interface RouteDef {
242
+ /**
243
+ * 공정을 지나는 순서.
244
+ *
245
+ * 유효 기간은 레시피와 같은 이유로 있다 — 라우트도 개정되고, 지난 실적이 어느 경로로 만든 것인지
246
+ * 잃으면 추적이 성립하지 않는다(§`RecipeDef`).
247
+ */
248
+ export interface RouteDef extends EffectivePeriod {
243
249
  key: string;
244
250
  label: string;
245
251
  /** OperationDef.key 순서. */
@@ -323,8 +329,20 @@ export interface RecipePart {
323
329
  */
324
330
  operation?: string;
325
331
  }
326
- /** BOM/레시피 — ISA-95 Material Consumed/Produced · EPCIS TransformationEvent(input→output). */
327
- export interface RecipeDef {
332
+ /**
333
+ * BOM/레시피 — ISA-95 Material Consumed/Produced · EPCIS TransformationEvent(input→output).
334
+ *
335
+ * ── 유효 기간을 왜 여기 두나 (2026-08-31) ────────────────────────────────
336
+ * **레시피는 개정된다.** 그런데 이 타입에 그 자리가 없어서, 마스터가 `effectiveStart`·`effectiveEnd` 를
337
+ * 채워 보내도 커널이 오류 없이 지나쳤다 — 값을 넣어도 트윈에 닿지 않았다.
338
+ *
339
+ * 그것이 없으면 **지난 실적이 어느 배합으로 만든 것인지 잃는다.** 정산과 추적이 그 위에 선다.
340
+ * 마스터가 행을 지우지 않고 이 칸을 채우는 이유도 그것이다.
341
+ *
342
+ * 선언 적합성 하네스가 잡았다(`ops-master/test/declaration-reaches-contract.test.ts`) — 마스터가
343
+ * 내보내는 칸이 계약에 실재하는지 보는 시험이다.
344
+ */
345
+ export interface RecipeDef extends EffectivePeriod {
328
346
  key: string;
329
347
  label: string;
330
348
  inputs: RecipePart[];
package/dist/erp.d.ts ADDED
@@ -0,0 +1,300 @@
1
+ import type { ISOTime } from './contract.ts';
2
+ /**
3
+ * **무엇을 얼마나 만들라** — 이 사실을 나르는 자리가 둘이고, 어휘는 하나다.
4
+ *
5
+ * ── 왜 갈라 두나 (2026-08-31) ────────────────────────────────────────────
6
+ * 같은 사실이 두 방향으로 흐른다.
7
+ *
8
+ * ERP → MES `ProductionScheduleEntry` 저쪽 오더번호와 상태를 함께 든다
9
+ * 트윈 → MES `production.order` 조치 번호가 없다. MES 가 받아서 자기 번호를 붙인다
10
+ *
11
+ * 처음에는 둘을 따로 적었다. ERP 쪽은 `materialId`, 조치 쪽은 `materialDefinitionId` 였다 —
12
+ * **같은 사실에 이름이 두 벌**이었고, 그것은 이 저장소가 반복해서 거절하는 모양이다. 옮기는 코드가
13
+ * 생기고, 그 코드가 언젠가 어긋난다.
14
+ *
15
+ * 이름은 `materialDefinitionId` 로 모았다 — 마스터의 축과 엔티티가 그 이름이다(`MaterialDefinition`).
16
+ * `startTime`·`endTime` 은 ISA-95 `JobOrder` 의 낱말이고 계약이 이미 그것을 쓴다.
17
+ */
18
+ export interface ProductionDemand {
19
+ /** 무엇을 만드나 — 마스터의 `MaterialDefinition` 식별자. */
20
+ materialDefinitionId: string;
21
+ /** 얼마나 — **받은 단위 그대로 든다.** 우리가 변환하지 않는다(변환계수는 원천의 사실이다). */
22
+ quantity: number;
23
+ uom: string;
24
+ /** 언제까지 — 예정 착수와 예정 완료(표준 `JobOrder.StartTime`·`EndTime`). */
25
+ startTime?: ISOTime;
26
+ endTime?: ISOTime;
27
+ /**
28
+ * 어느 레시피로 만드나 — 품목만으로는 정해지지 않는다(같은 품목에 대체 레시피가 있다).
29
+ * 아는 경우만 싣는다.
30
+ */
31
+ recipeKey?: string;
32
+ /** 작은 값이 급하다(우리 `JobOrder.Priority` 와 같은 규약). */
33
+ priority?: number;
34
+ }
35
+ /**
36
+ * ERP 가 내린 생산 지시 하나.
37
+ *
38
+ * **식별자는 ERP 것이다.** `orderId` 는 저쪽 오더번호이고 우리가 만들지 않는다. 우리 식별자를 여기
39
+ * 담으면 ERP 를 바꾸는 날 지난 기록이 가리킬 것을 잃는다.
40
+ */
41
+ export interface ProductionScheduleEntry extends ProductionDemand {
42
+ /** ERP 의 오더번호. 이 지시를 가리키는 이름이다. */
43
+ orderId: string;
44
+ /**
45
+ * **이 지시의 판** — 수량 · 납기 · 취소가 바뀌면 올라간다.
46
+ *
47
+ * 덮어쓰지 않고 판마다 남기는 이유는, 이미 올린 실적이 **어느 지시에 대한 것인지** 잃으면 정산이
48
+ * 성립하지 않기 때문이다. ERP 가 판을 말해 주지 않으면 받은 시각으로 대신하지 않는다 — 그것은
49
+ * 우리 사정이지 지시의 사실이 아니다.
50
+ */
51
+ revision?: string;
52
+ /** 어디서 — ERP 가 말하는 현장과 작업장. SAP 는 플랜트, Oracle 은 Organization 이라 부른다. */
53
+ site?: string;
54
+ workCenter?: string;
55
+ /** 지시 상태 — 아래 `SCHEDULE_STATUS`. 잠긴 것에는 실적을 올릴 수 없다. */
56
+ status: ScheduleStatus;
57
+ recordTime?: ISOTime;
58
+ }
59
+ /**
60
+ * 지시의 상태 — **어느 ERP 에나 있는 여섯**.
61
+ *
62
+ * ERP 마다 이름이 다르고 수가 다르다. SAP 는 `REL`·`TECO`·`CLSD` 로, Oracle 은
63
+ * `Released`·`Complete`·`Closed` 로, 더존은 다른 코드로 말한다. 그 코드를 그대로 들면 어댑터마다
64
+ * 다른 어휘가 화면까지 올라온다.
65
+ *
66
+ * **여섯으로 좁힌 기준은 「부르는 쪽의 판단이 갈리는가」다.** 이름이 달라도 판단이 같으면 한 칸이다.
67
+ *
68
+ * draft 아직 내려오지 않았다. 실적을 올릴 수 없다
69
+ * released 내려왔다. 올릴 수 있다
70
+ * started 일부 올라갔다. 계속 올릴 수 있다
71
+ * completed 생산이 끝났다고 ERP 가 정했다. 더 못 올린다
72
+ * closed 정산까지 닫혔다. 되돌리는 것도 사람이 ERP 에서 한다
73
+ * cancelled 취소됐다
74
+ *
75
+ * `completed` 와 `closed` 를 합치지 않는 이유는 되돌리기가 갈리기 때문이다 — 앞은 ERP 가 열어 주면
76
+ * 되돌릴 수 있고 뒤는 그것도 안 된다.
77
+ */
78
+ export declare const SCHEDULE_STATUS: readonly ["draft", "released", "started", "completed", "closed", "cancelled"];
79
+ export type ScheduleStatus = (typeof SCHEDULE_STATUS)[number];
80
+ /** 실적을 받을 수 있는 지시인가 — 잠긴 것에 올리면 거절되고, 그 거절은 기다려도 풀리지 않는다. */
81
+ export declare function acceptsPerformance(status: ScheduleStatus): boolean;
82
+ /**
83
+ * MES 가 올리는 생산 실적 하나.
84
+ *
85
+ * 수량 확정과 자재 소비는 ERP 에서 **별개 문서**다(§`erp-sap.md` 4-6). 한쪽만 올라간 상태가 실제로
86
+ * 생기므로 이 레코드는 둘을 함께 들되, 보내는 쪽이 **각각의 결과를 따로 적을 수 있어야** 한다.
87
+ */
88
+ export interface ProductionPerformanceEntry {
89
+ /** 어느 지시에 대한 것인가 — ERP 의 오더번호. */
90
+ orderId: string;
91
+ /** 그 지시의 어느 판에 대한 것인가. 지시가 판을 말했으면 그대로 옮긴다. */
92
+ revision?: string;
93
+ /**
94
+ * 어느 공정인가 — ERP 의 공정 식별자. **오더 단위로만 받는 ERP 가 있다.**
95
+ *
96
+ * 어댑터가 `performanceGranularity: 'order'` 를 말하면 이 값을 보내도 저쪽이 버린다. 그때 이 칸을
97
+ * 채워 보내는 쪽은 공정별 실적이 올라간 줄 알게 되므로, 어댑터가 **버렸다고 말해야 한다.**
98
+ */
99
+ operationId?: string;
100
+ /** 만든 것 — 양품과 불량을 나눠 든다. 합쳐 보내면 수율이 사라진다. */
101
+ goodQuantity: number;
102
+ scrapQuantity: number;
103
+ uom: string;
104
+ /** 언제 — 이 실적이 덮는 구간. 마감된 구간이라 미래일 수 없다. */
105
+ from: ISOTime;
106
+ to: ISOTime;
107
+ /** 누가 했나 — ERP 의 인사번호. 「누가 적었나」는 여기 오지 않는다(MES 의 감사 추적이다). */
108
+ personId?: string;
109
+ /** 쓴 자재 — 소비 문서가 된다. 없으면 자재 소비를 올리지 않는다(0 으로 채우지 않는다). */
110
+ materialActual?: MaterialActual[];
111
+ /**
112
+ * **이것이 이 지시의 마지막 실적인가** — ERP 가 이 값으로 오더를 닫는다.
113
+ *
114
+ * 커널이 판정하지 않는다. 「계획 수량에 도달했으니 마지막」은 틀린 규칙이다 — 초과 생산과 미달 종료가
115
+ * 둘 다 정상이다. 사람이 정한 것만 참이다.
116
+ */
117
+ final?: boolean;
118
+ recordTime?: ISOTime;
119
+ }
120
+ /** 실제로 들어가고 나온 자재 한 줄. */
121
+ export interface MaterialActual {
122
+ /** 마스터의 `MaterialDefinition` 식별자 — 지시와 같은 이름을 쓴다. */
123
+ materialDefinitionId: string;
124
+ quantity: number;
125
+ uom: string;
126
+ /** `consumed` 소비 · `produced` 산출. 부호로 구별하지 않는다 — 음수는 되돌림과 섞인다. */
127
+ direction: 'consumed' | 'produced';
128
+ lot?: string;
129
+ }
130
+ /**
131
+ * 실적의 **멱등 키** — 같은 실적이 두 번 올라가지 않게.
132
+ *
133
+ * ── 왜 계약에 있나 ───────────────────────────────────────────────────────
134
+ * ERP 확정은 삭제가 없다. 잘못 올린 것은 역분개로만 되돌리고 그 역분개도 장부에 남는다. 그래서 이 키를
135
+ * 만드는 쪽과 확인하는 쪽이 **한 글자까지 같은 규칙**을 써야 한다. 규칙이 두 벌이면 한쪽만 바뀌는 날
136
+ * 같은 실적이 두 번 앉는다.
137
+ *
138
+ * ── 무엇으로 만드나 ──────────────────────────────────────────────────────
139
+ * 오더 · 공정 · 구간. 같은 오더의 같은 공정에서 **같은 구간**의 실적은 하나뿐이다. 수량을 넣지 않는
140
+ * 이유는, 수량을 고쳐 다시 올리는 것이 「다른 실적」이 아니라 「같은 실적의 정정」이기 때문이다 —
141
+ * 정정은 역분개로 처리할 일이지 새 키를 받을 일이 아니다.
142
+ */
143
+ export declare function performanceKey(e: Pick<ProductionPerformanceEntry, 'orderId' | 'operationId' | 'from' | 'to'>): string;
144
+ /**
145
+ * 자재 소비의 멱등 키 — **확정과 따로 둔다.**
146
+ *
147
+ * 수량 확정과 자재 소비는 ERP 에서 별개 문서이고 한쪽만 성공하는 순간이 실제로 있다. 키를 하나로
148
+ * 쓰면 「확정은 올라갔고 소비는 안 올라간 상태」를 표시할 방법이 없어진다.
149
+ */
150
+ export declare function materialMovementKey(e: Pick<ProductionPerformanceEntry, 'orderId' | 'operationId' | 'from' | 'to'>): string;
151
+ /**
152
+ * 보내기 전에 스스로 본다 — **틀린 모양이 ERP 에서 걸리면 그때는 이미 늦다.**
153
+ *
154
+ * 거절 이유를 목록으로 낸다. 첫 번째에서 멈추면 사람이 한 번에 하나씩 고치게 된다.
155
+ */
156
+ export declare function validatePerformance(e: ProductionPerformanceEntry, nowMs: number): string[];
157
+ /**
158
+ * 잘못 올린 실적을 되돌리는 요청 — **삭제가 아니라 역분개다.**
159
+ *
160
+ * ERP 는 확정을 지우지 않는다. 되돌림도 장부에 남고, 그래서 되돌림에도 자기 멱등 키가 필요하다 —
161
+ * 되돌림이 두 번 올라가면 원래 수량이 다시 살아난다.
162
+ */
163
+ export interface PerformanceReversal {
164
+ /** 되돌릴 실적의 멱등 키(`performanceKey`). */
165
+ performanceKey: string;
166
+ /** ERP 가 돌려준 확정 문서 — 이것이 있어야 무엇을 되돌릴지 저쪽이 안다. */
167
+ confirmationRef: string;
168
+ reason: string;
169
+ decidedBy: string;
170
+ decidedAt: ISOTime;
171
+ }
172
+ /** 되돌림의 멱등 키 — 원래 실적의 키에 표식 하나. */
173
+ export declare function reversalKey(performanceKey: string): string;
174
+ /**
175
+ * **ERP 어댑터가 구현하는 자리** — 부르는 쪽은 이것만 안다.
176
+ *
177
+ * ── 왜 먼저 정하나 (2026-08-31, 사용자 지적) ─────────────────────────────
178
+ * SAP 구현을 먼저 만들면 **SAP 의 모양이 곧 표준이 된다.** 두 번째 ERP(ECC · Oracle · Dynamics)는
179
+ * 자기에게 안 맞는 모양을 물려받고, 그때 고치려면 첫 소비처가 이미 그 위에 서 있다.
180
+ *
181
+ * 그래서 이 자리에는 **어느 ERP에나 있는 것만** 둔다. OData 인지 IDoc 인지, 어느 엔티티에서 꺼내는지는
182
+ * 어댑터의 사정이다.
183
+ *
184
+ * ── 트윈 커넥터와 같은 규율 두 벌 ────────────────────────────────────────
185
+ * `capabilities` 로 무엇을 할 수 있는지 선언하고, **선언만 있고 구현이 없으면 없는 것으로 읽는다** —
186
+ * 선언만 있는 능력은 사용자에게 거짓이 된다(§`ReferenceAdapter.capabilities`).
187
+ * `grounding` 은 「그 선언이 무엇으로 확인됐나」다 — 목에 대고 만든 것과 실 시스템에 붙어 본 것은
188
+ * 다르고, 화면이 그 구분을 보여야 한다.
189
+ */
190
+ export interface ErpAdapter {
191
+ /** 어느 ERP 인가 — `sap-s4hana` · `sap-ecc` 처럼. */
192
+ readonly type: string;
193
+ readonly label: string;
194
+ readonly capabilities: readonly ErpCapability[];
195
+ /** `mock` 목에 대고 확인 · `vendor-doc` 공개 문서 기준 · `verified` 실 시스템에 붙여 확인. */
196
+ readonly grounding: 'mock' | 'vendor-doc' | 'verified';
197
+ /**
198
+ * **ERP 마다 갈리는 성질** — 능력이 있고 없고와 다른 축이다.
199
+ *
200
+ * 「할 수 있나」가 아니라 「어떻게 하나」이고, 부르는 쪽의 코드가 이 값에 따라 달라진다. 선언하지
201
+ * 않으면 부르는 쪽이 짐작하게 되고, 짐작이 틀린 것은 화면에 드러나지 않는다.
202
+ */
203
+ readonly traits: ErpTraits;
204
+ /** 붙는지 본다. 값을 바꾸지 않는다. */
205
+ probe(cfg: ErpConfig): Promise<ErpOutcome<{
206
+ detail?: string;
207
+ }>>;
208
+ /** 지시를 읽는다. `since` 이후에 바뀐 것만 — 없으면 어댑터가 정한 창을 쓴다. */
209
+ readSchedules?(cfg: ErpConfig, since?: ISOTime): Promise<ErpOutcome<ProductionScheduleEntry[]>>;
210
+ /**
211
+ * 실적을 올린다. **`key` 로 두 번 올라가지 않게 한다**(§`performanceKey`).
212
+ *
213
+ * 어댑터가 그 키로 이미 올렸는지 먼저 확인해야 한다 — ERP 확정은 삭제가 없다.
214
+ */
215
+ postPerformance?(cfg: ErpConfig, entry: ProductionPerformanceEntry, key: string): Promise<ErpOutcome<ErpDocumentRef>>;
216
+ /**
217
+ * 자재 소비·산출을 올린다 — **실적과 별개 문서다.** 한쪽만 성공하는 순간이 실제로 있다.
218
+ */
219
+ postMaterialMovement?(cfg: ErpConfig, entry: ProductionPerformanceEntry, key: string): Promise<ErpOutcome<ErpDocumentRef>>;
220
+ /** 잘못 올린 실적을 되돌린다 — 삭제가 아니라 역분개다. */
221
+ reversePerformance?(cfg: ErpConfig, reversal: PerformanceReversal, key: string): Promise<ErpOutcome<ErpDocumentRef>>;
222
+ /** 마스터를 당긴다 — 구조는 밀지 않고 당겨 간다(사실과 다른 규율). */
223
+ readMaster?(cfg: ErpConfig, axis: ErpMasterAxis): Promise<ErpOutcome<unknown[]>>;
224
+ }
225
+ /**
226
+ * ERP 마다 갈리는 성질.
227
+ *
228
+ * 실제로 갈리는 것만 둔다 — 아래 셋은 SAP · Oracle · Dynamics · 국내 ERP 를 견주면 서로 다르고,
229
+ * 부르는 쪽이 그것을 모르면 조용히 틀린 결과를 낸다.
230
+ */
231
+ export interface ErpTraits {
232
+ /**
233
+ * 실적을 어느 단위로 받나.
234
+ *
235
+ * `order` 만 받는 ERP 에 공정별 실적을 올리면 저쪽이 공정을 버리고 합산한다. 부르는 쪽은 공정별로
236
+ * 올라간 줄 알지만 장부에는 오더 하나로 앉는다.
237
+ */
238
+ performanceGranularity: 'order' | 'operation';
239
+ /**
240
+ * 자재 소비가 실적과 **같은 문서**인가.
241
+ *
242
+ * SAP 는 별개 문서라 한쪽만 성공하는 순간이 있고, 함께 앉는 ERP 는 그 상태가 아예 없다. 별개인 곳에서
243
+ * 하나로 다루면 반쯤 올라간 것을 아무도 못 본다. 함께인 곳에서 둘로 다루면 소비가 두 번 앉는다.
244
+ */
245
+ materialWithPerformance: boolean;
246
+ /**
247
+ * 되돌리기를 API 로 할 수 있나.
248
+ *
249
+ * `none` 이면 잘못 올린 실적을 **사람이 ERP 화면에서** 지워야 한다. 그 사실을 화면이 말해야 현장이
250
+ * 기다리지 않는다.
251
+ */
252
+ reversal: 'api' | 'manual' | 'none';
253
+ }
254
+ /** 이 ERP 가 할 수 있다고 선언하는 것. 구현이 없으면 없는 것으로 읽는다. */
255
+ export declare const ERP_CAPABILITY: readonly ["schedule", "performance", "material-movement", "reversal", "master"];
256
+ export type ErpCapability = (typeof ERP_CAPABILITY)[number];
257
+ /** 당겨 올 수 있는 마스터의 축. */
258
+ export declare const ERP_MASTER_AXIS: readonly ["product", "bom", "routing", "work-center"];
259
+ export type ErpMasterAxis = (typeof ERP_MASTER_AXIS)[number];
260
+ /** 연결 설정 — 어댑터마다 다른 칸이 붙는다. 비밀값은 여기 두지 않는다. */
261
+ export interface ErpConfig {
262
+ baseUrl: string;
263
+ /** ERP 안에서 우리가 어느 현장인가. SAP 는 플랜트, Oracle 은 Organization 이다. */
264
+ site?: string;
265
+ [key: string]: unknown;
266
+ }
267
+ /** ERP 가 돌려준 문서 — 되돌리려면 이것이 있어야 저쪽이 무엇을 되돌릴지 안다. */
268
+ export interface ErpDocumentRef {
269
+ /** 그 ERP 의 문서 식별자 원문. 우리가 모양을 정하지 않는다. */
270
+ ref: string;
271
+ postedAt?: ISOTime;
272
+ }
273
+ /**
274
+ * ERP 호출의 결과 — **다음에 무엇을 할지가 여기서 나온다.**
275
+ *
276
+ * 웹훅 응답에서 세운 것과 같은 규율이다: 「다시 보내면 되는가 · 기다리면 되는가 · 사람을 불러야 하는가」가
277
+ * 하나로 정해져야 한다. 그 판정을 부르는 쪽이 본문을 읽어 짐작하게 두면 어댑터마다 달라진다.
278
+ */
279
+ export type ErpOutcome<T> = {
280
+ ok: true;
281
+ value: T;
282
+ } | {
283
+ ok: false;
284
+ action: ErpFailureAction;
285
+ error: string;
286
+ };
287
+ /**
288
+ * retry 다시 보내면 될 수 있다 — 저쪽 오류 · 시간 초과
289
+ * wait 지금은 안 되고 기다리면 된다 — 저쪽이 따라오지 못한다
290
+ * stop 다시 보내도 같다 — 잠긴 오더 · 없는 자재 · 권한 없음. 사람이 붙어야 한다
291
+ * duplicate 이미 올라가 있다 — 실패가 아니다. 우리 기록만 맞추면 된다
292
+ */
293
+ export type ErpFailureAction = 'retry' | 'wait' | 'stop' | 'duplicate';
294
+ /**
295
+ * 선언과 구현이 어긋나지 않는가 — **선언만 있는 능력은 사용자에게 거짓이 된다.**
296
+ *
297
+ * 어긋난 것을 목록으로 낸다. 등록할 때 이것을 지나게 하면 「할 수 있다고 적혀 있는데 안 되는」 어댑터가
298
+ * 화면에 서지 않는다.
299
+ */
300
+ export declare function erpCapabilityGaps(adapter: ErpAdapter): string[];
package/dist/erp.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 지시의 상태 — **어느 ERP 에나 있는 여섯**.
3
+ *
4
+ * ERP 마다 이름이 다르고 수가 다르다. SAP 는 `REL`·`TECO`·`CLSD` 로, Oracle 은
5
+ * `Released`·`Complete`·`Closed` 로, 더존은 다른 코드로 말한다. 그 코드를 그대로 들면 어댑터마다
6
+ * 다른 어휘가 화면까지 올라온다.
7
+ *
8
+ * **여섯으로 좁힌 기준은 「부르는 쪽의 판단이 갈리는가」다.** 이름이 달라도 판단이 같으면 한 칸이다.
9
+ *
10
+ * draft 아직 내려오지 않았다. 실적을 올릴 수 없다
11
+ * released 내려왔다. 올릴 수 있다
12
+ * started 일부 올라갔다. 계속 올릴 수 있다
13
+ * completed 생산이 끝났다고 ERP 가 정했다. 더 못 올린다
14
+ * closed 정산까지 닫혔다. 되돌리는 것도 사람이 ERP 에서 한다
15
+ * cancelled 취소됐다
16
+ *
17
+ * `completed` 와 `closed` 를 합치지 않는 이유는 되돌리기가 갈리기 때문이다 — 앞은 ERP 가 열어 주면
18
+ * 되돌릴 수 있고 뒤는 그것도 안 된다.
19
+ */
20
+ export const SCHEDULE_STATUS = ['draft', 'released', 'started', 'completed', 'closed', 'cancelled'];
21
+ /** 실적을 받을 수 있는 지시인가 — 잠긴 것에 올리면 거절되고, 그 거절은 기다려도 풀리지 않는다. */
22
+ export function acceptsPerformance(status) {
23
+ return status === 'released' || status === 'started';
24
+ }
25
+ /* ── 멱등 ──────────────────────────────────────────────────────────────────── */
26
+ /**
27
+ * 실적의 **멱등 키** — 같은 실적이 두 번 올라가지 않게.
28
+ *
29
+ * ── 왜 계약에 있나 ───────────────────────────────────────────────────────
30
+ * ERP 확정은 삭제가 없다. 잘못 올린 것은 역분개로만 되돌리고 그 역분개도 장부에 남는다. 그래서 이 키를
31
+ * 만드는 쪽과 확인하는 쪽이 **한 글자까지 같은 규칙**을 써야 한다. 규칙이 두 벌이면 한쪽만 바뀌는 날
32
+ * 같은 실적이 두 번 앉는다.
33
+ *
34
+ * ── 무엇으로 만드나 ──────────────────────────────────────────────────────
35
+ * 오더 · 공정 · 구간. 같은 오더의 같은 공정에서 **같은 구간**의 실적은 하나뿐이다. 수량을 넣지 않는
36
+ * 이유는, 수량을 고쳐 다시 올리는 것이 「다른 실적」이 아니라 「같은 실적의 정정」이기 때문이다 —
37
+ * 정정은 역분개로 처리할 일이지 새 키를 받을 일이 아니다.
38
+ */
39
+ export function performanceKey(e) {
40
+ const parts = [e.orderId, e.operationId ?? '-', e.from, e.to];
41
+ if (parts.some(p => !String(p ?? '').trim())) {
42
+ throw new Error(`performanceKey: 오더 · 구간이 없으면 멱등 키를 만들 수 없다 — ${JSON.stringify(e)}`);
43
+ }
44
+ return parts.join('|');
45
+ }
46
+ /**
47
+ * 자재 소비의 멱등 키 — **확정과 따로 둔다.**
48
+ *
49
+ * 수량 확정과 자재 소비는 ERP 에서 별개 문서이고 한쪽만 성공하는 순간이 실제로 있다. 키를 하나로
50
+ * 쓰면 「확정은 올라갔고 소비는 안 올라간 상태」를 표시할 방법이 없어진다.
51
+ */
52
+ export function materialMovementKey(e) {
53
+ return `${performanceKey(e)}|goods`;
54
+ }
55
+ /* ── 검증 ──────────────────────────────────────────────────────────────────── */
56
+ /**
57
+ * 보내기 전에 스스로 본다 — **틀린 모양이 ERP 에서 걸리면 그때는 이미 늦다.**
58
+ *
59
+ * 거절 이유를 목록으로 낸다. 첫 번째에서 멈추면 사람이 한 번에 하나씩 고치게 된다.
60
+ */
61
+ export function validatePerformance(e, nowMs) {
62
+ const errors = [];
63
+ if (!String(e?.orderId ?? '').trim())
64
+ errors.push('orderId 없음 — 어느 지시에 대한 실적인지 지어낼 수 없다');
65
+ if (!String(e?.uom ?? '').trim())
66
+ errors.push('uom 없음 — 단위 없는 수량은 정산에 쓸 수 없다');
67
+ for (const [name, v] of [
68
+ ['goodQuantity', e?.goodQuantity],
69
+ ['scrapQuantity', e?.scrapQuantity]
70
+ ]) {
71
+ if (typeof v !== 'number' || !Number.isFinite(v))
72
+ errors.push(`${name} 이 수가 아니다`);
73
+ else if (v < 0)
74
+ errors.push(`${name} 이 음수다 — 되돌림은 역분개로 하고 음수 실적으로 하지 않는다`);
75
+ }
76
+ if (typeof e?.goodQuantity === 'number' && typeof e?.scrapQuantity === 'number' && e.goodQuantity + e.scrapQuantity <= 0) {
77
+ errors.push('만든 것이 하나도 없다 — 올릴 실적이 아니다');
78
+ }
79
+ const from = Date.parse(String(e?.from ?? ''));
80
+ const to = Date.parse(String(e?.to ?? ''));
81
+ if (!Number.isFinite(from) || !Number.isFinite(to))
82
+ errors.push('구간이 없다 — from · to 가 있어야 한다');
83
+ else {
84
+ if (to < from)
85
+ errors.push('구간이 거꾸로다');
86
+ /* 마감된 구간의 사실이다. 아직 오지 않은 시각을 정산에 올리면 그 달의 장부가 틀린다. */
87
+ if (to > nowMs)
88
+ errors.push('아직 오지 않은 시각까지의 실적이다');
89
+ }
90
+ for (const m of e?.materialActual ?? []) {
91
+ if (!String(m?.materialDefinitionId ?? '').trim())
92
+ errors.push('자재 줄에 materialDefinitionId 가 없다');
93
+ if (!String(m?.uom ?? '').trim())
94
+ errors.push(`${m?.materialDefinitionId}: uom 없음 — 단위는 ERP 의 사실이므로 지어내지 않는다`);
95
+ if (typeof m?.quantity !== 'number' || !(m.quantity > 0))
96
+ errors.push(`${m?.materialDefinitionId}: 수량이 0 이하다`);
97
+ if (m?.direction !== 'consumed' && m?.direction !== 'produced') {
98
+ errors.push(`${m?.materialDefinitionId}: direction 이 consumed · produced 가 아니다`);
99
+ }
100
+ }
101
+ return errors;
102
+ }
103
+ /** 되돌림의 멱등 키 — 원래 실적의 키에 표식 하나. */
104
+ export function reversalKey(performanceKey) {
105
+ if (!String(performanceKey ?? '').trim())
106
+ throw new Error('reversalKey: 되돌릴 실적의 키가 없다');
107
+ return `${performanceKey}|reversal`;
108
+ }
109
+ /** 이 ERP 가 할 수 있다고 선언하는 것. 구현이 없으면 없는 것으로 읽는다. */
110
+ export const ERP_CAPABILITY = ['schedule', 'performance', 'material-movement', 'reversal', 'master'];
111
+ /** 당겨 올 수 있는 마스터의 축. */
112
+ export const ERP_MASTER_AXIS = ['product', 'bom', 'routing', 'work-center'];
113
+ /**
114
+ * 선언과 구현이 어긋나지 않는가 — **선언만 있는 능력은 사용자에게 거짓이 된다.**
115
+ *
116
+ * 어긋난 것을 목록으로 낸다. 등록할 때 이것을 지나게 하면 「할 수 있다고 적혀 있는데 안 되는」 어댑터가
117
+ * 화면에 서지 않는다.
118
+ */
119
+ export function erpCapabilityGaps(adapter) {
120
+ const needs = {
121
+ schedule: 'readSchedules',
122
+ performance: 'postPerformance',
123
+ 'material-movement': 'postMaterialMovement',
124
+ reversal: 'reversePerformance',
125
+ master: 'readMaster'
126
+ };
127
+ const gaps = [];
128
+ for (const cap of adapter.capabilities ?? []) {
129
+ const fn = needs[cap];
130
+ if (typeof adapter[fn] !== 'function')
131
+ gaps.push(`${cap} 을 선언했는데 ${String(fn)} 구현이 없다`);
132
+ }
133
+ /* 되돌림을 API 로 한다고 말했으면 그 구현이 있어야 한다. `manual`·`none` 은 없는 것이 정상이다. */
134
+ if (adapter.traits?.reversal === 'api' && typeof adapter.reversePerformance !== 'function') {
135
+ gaps.push('traits.reversal 이 api 인데 reversePerformance 구현이 없다');
136
+ }
137
+ if (adapter.traits?.reversal !== 'api' && adapter.capabilities?.includes('reversal')) {
138
+ gaps.push('reversal 능력을 선언했는데 traits.reversal 이 api 가 아니다 — 둘 중 하나가 틀렸다');
139
+ }
140
+ /* 자재가 실적과 같은 문서인 ERP 에 따로 올리는 문을 두면 소비가 두 번 앉는다. */
141
+ if (adapter.traits?.materialWithPerformance && typeof adapter.postMaterialMovement === 'function') {
142
+ gaps.push('traits.materialWithPerformance 가 참인데 postMaterialMovement 가 따로 있다 — 소비가 두 번 앉는다');
143
+ }
144
+ return gaps;
145
+ }
package/dist/index.d.ts CHANGED
@@ -19,3 +19,5 @@ export * from './yms-profile.ts';
19
19
  export * from './canonical-record.ts';
20
20
  export * from './webhook.ts';
21
21
  export * from './oee.ts';
22
+ export * from './erp.ts';
23
+ export * from './actuation.ts';
package/dist/index.js CHANGED
@@ -41,3 +41,5 @@ export * from "./canonical-record.js";
41
41
  /* `webhook-signature.ts` 는 여기 없다 — `node:crypto` 를 쓰므로 `@operato/ops-contract/webhook` 으로만 나간다. */
42
42
  export * from "./webhook.js";
43
43
  export * from "./oee.js";
44
+ export * from "./erp.js";
45
+ export * from "./actuation.js";
@@ -1,4 +1,5 @@
1
1
  import type { TwinTypeInfo } from './domain-catalog.ts';
2
+ import type { CommandTypeSpec } from './actuation.ts';
2
3
  export declare const MES_BIZSTEP: {
3
4
  readonly receiving: "urn:epcglobal:cbv:bizstep:receiving";
4
5
  readonly producing: "urn:epcglobal:cbv:bizstep:commissioning";
@@ -12,3 +13,13 @@ export declare function sgtinUri(companyPrefix: string, itemRef: string, serial:
12
13
  * 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
13
14
  export declare const MES_LOCATION_TYPES: readonly ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
14
15
  export declare const MES_TYPES: TwinTypeInfo[];
16
+ /**
17
+ * 제조 도메인이 내리는 조치 — **어댑터가 이름을 지어내지 않게 여기서 정한다.**
18
+ *
19
+ * 전역 목록으로 두지 않는 이유는 조치가 도메인마다 다르기 때문이다. bizStep 과 자리 타입을 이 파일이
20
+ * 선언하는 것과 같은 결이다.
21
+ *
22
+ * 지금 하나다. **소비처가 있는 것만 적는다** — 쓰지 않는 종류를 미리 늘리면 그 이름이 무엇을 뜻하는지
23
+ * 아무도 모르는 채로 계약에 남는다.
24
+ */
25
+ export declare const MES_COMMANDS: CommandTypeSpec[];
@@ -57,3 +57,28 @@ export const MES_TYPES = [
57
57
  { key: 'painter', role: 'equipment', label: 'twin.type.painter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
58
58
  { key: 'assembler', role: 'equipment', label: 'twin.type.assembler', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
59
59
  ];
60
+ /**
61
+ * 제조 도메인이 내리는 조치 — **어댑터가 이름을 지어내지 않게 여기서 정한다.**
62
+ *
63
+ * 전역 목록으로 두지 않는 이유는 조치가 도메인마다 다르기 때문이다. bizStep 과 자리 타입을 이 파일이
64
+ * 선언하는 것과 같은 결이다.
65
+ *
66
+ * 지금 하나다. **소비처가 있는 것만 적는다** — 쓰지 않는 종류를 미리 늘리면 그 이름이 무엇을 뜻하는지
67
+ * 아무도 모르는 채로 계약에 남는다.
68
+ */
69
+ export const MES_COMMANDS = [
70
+ {
71
+ type: 'production.order',
72
+ label: '생산 지시',
73
+ /*
74
+ * 되돌릴 수 없다. 지시가 나가면 사람과 설비가 그것으로 움직이고, 취소해도 이미 만든 것은 남는다.
75
+ * 그래서 사람 승인을 지난다(§`requiresApproval`).
76
+ */
77
+ reversible: false,
78
+ /*
79
+ * 단위를 요구하는 이유 — 단위 없는 수량은 지시가 되지 못한다. 우리가 기본 단위를 지어내면 그것이
80
+ * 현장의 단위와 다른 날 수량이 조용히 틀린다.
81
+ */
82
+ required: ['materialDefinitionId', 'quantity', 'uom']
83
+ }
84
+ ];
@@ -27,6 +27,9 @@ __export(index_exports, {
27
27
  CAPABILITY_KEYS: () => CAPABILITY_KEYS,
28
28
  CBV_BIZSTEP: () => CBV_BIZSTEP,
29
29
  CMD: () => CMD,
30
+ COMMAND_EVENT: () => COMMAND_EVENT,
31
+ COMMAND_ORIGIN: () => COMMAND_ORIGIN,
32
+ COMMAND_STATE: () => COMMAND_STATE,
30
33
  DISP: () => DISP,
31
34
  DISPOSITION_DECISION: () => DISPOSITION_DECISION,
32
35
  DOMAIN_CATALOG: () => DOMAIN_CATALOG,
@@ -40,11 +43,14 @@ __export(index_exports, {
40
43
  EPCIS_CONTEXT: () => EPCIS_CONTEXT,
41
44
  EPCIS_EVENT: () => EPCIS_EVENT,
42
45
  EQUIPMENT_LEVEL: () => EQUIPMENT_LEVEL,
46
+ ERP_CAPABILITY: () => ERP_CAPABILITY,
47
+ ERP_MASTER_AXIS: () => ERP_MASTER_AXIS,
43
48
  GUARD_PRAGMA: () => GUARD_PRAGMA,
44
49
  ILMD_ATTR: () => ILMD_ATTR,
45
50
  LOCATION_SATURATION_NEAR: () => LOCATION_SATURATION_NEAR,
46
51
  MATERIAL_PROPERTY: () => MATERIAL_PROPERTY,
47
52
  MES_BIZSTEP: () => MES_BIZSTEP,
53
+ MES_COMMANDS: () => MES_COMMANDS,
48
54
  MES_LOCATION_TYPES: () => MES_LOCATION_TYPES,
49
55
  MES_TYPES: () => MES_TYPES,
50
56
  METER_DIRECTION: () => METER_DIRECTION,
@@ -54,6 +60,7 @@ __export(index_exports, {
54
60
  ORDER_TERMINAL_STATUS: () => ORDER_TERMINAL_STATUS,
55
61
  PRIORITY_UNSET: () => PRIORITY_UNSET,
56
62
  RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
63
+ SCHEDULE_STATUS: () => SCHEDULE_STATUS,
57
64
  TWIN_AXES: () => TWIN_AXES,
58
65
  TWIN_PROPERTIES: () => TWIN_PROPERTIES,
59
66
  TWIN_RELATIONS: () => TWIN_RELATIONS,
@@ -67,10 +74,13 @@ __export(index_exports, {
67
74
  YARD_BIZSTEP: () => YARD_BIZSTEP,
68
75
  YMS_LOCATION_TYPES: () => YMS_LOCATION_TYPES,
69
76
  YMS_TYPES: () => YMS_TYPES,
77
+ acceptsPerformance: () => acceptsPerformance,
70
78
  activeShiftAt: () => activeShiftAt,
71
79
  activeShiftOf: () => activeShiftOf,
72
80
  aggregationEvent: () => aggregationEvent,
73
81
  analyzeCapacity: () => analyzeCapacity,
82
+ approveCommand: () => approveCommand,
83
+ asApproved: () => asApproved,
74
84
  axesOfSystem: () => axesOfSystem,
75
85
  axisAppliesTo: () => axisAppliesTo,
76
86
  axisInfo: () => axisInfo,
@@ -82,6 +92,9 @@ __export(index_exports, {
82
92
  checkSequenceRun: () => checkSequenceRun,
83
93
  classClosure: () => classClosure,
84
94
  classIdentifierViolation: () => classIdentifierViolation,
95
+ commandFromSpec: () => commandFromSpec,
96
+ commandSpecGaps: () => commandSpecGaps,
97
+ commandTypeOf: () => commandTypeOf,
85
98
  commandsOf: () => commandsOf,
86
99
  computeOee: () => computeOee,
87
100
  conversionFactorOf: () => conversionFactorOf,
@@ -90,6 +103,7 @@ __export(index_exports, {
90
103
  dueStatusOf: () => dueStatusOf,
91
104
  effectivityAt: () => effectivityAt,
92
105
  electricalUpstreamOf: () => electricalUpstreamOf,
106
+ erpCapabilityGaps: () => erpCapabilityGaps,
93
107
  expiryFromAttributes: () => expiryFromAttributes,
94
108
  gdtiUri: () => gdtiUri,
95
109
  generationFractionAt: () => generationFractionAt,
@@ -111,6 +125,7 @@ __export(index_exports, {
111
125
  ingestMasterData: () => ingestMasterData,
112
126
  ingestOperationalRecords: () => ingestOperationalRecords,
113
127
  isAggregationRecord: () => isAggregationRecord,
128
+ isCommandTerminal: () => isCommandTerminal,
114
129
  isElectricalLocationType: () => isElectricalLocationType,
115
130
  isEnergyBillRecord: () => isEnergyBillRecord,
116
131
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
@@ -137,8 +152,11 @@ __export(index_exports, {
137
152
  lotFromAttributes: () => lotFromAttributes,
138
153
  mapRecord: () => mapRecord,
139
154
  mapRecordChecked: () => mapRecordChecked,
155
+ materialMovementKey: () => materialMovementKey,
140
156
  meetsTests: () => meetsTests,
141
157
  minuteOfDayAt: () => minuteOfDayAt,
158
+ missingCommandPayload: () => missingCommandPayload,
159
+ nextCommandState: () => nextCommandState,
142
160
  objectEvent: () => objectEvent,
143
161
  objectUri: () => objectUri,
144
162
  observationAt: () => observationAt,
@@ -149,6 +167,8 @@ __export(index_exports, {
149
167
  outsideLimit: () => outsideLimit,
150
168
  parseEpc: () => parseEpc,
151
169
  parseIsoDuration: () => parseIsoDuration,
170
+ passThroughReversible: () => passThroughReversible,
171
+ performanceKey: () => performanceKey,
152
172
  periodFactId: () => periodFactId,
153
173
  priorityRank: () => priorityRank,
154
174
  procedureViolations: () => procedureViolations,
@@ -160,8 +180,10 @@ __export(index_exports, {
160
180
  relationsFrom: () => relationsFrom,
161
181
  relationsTo: () => relationsTo,
162
182
  requiredTestsFor: () => requiredTestsFor,
183
+ requiresApproval: () => requiresApproval,
163
184
  resolveSubject: () => resolveSubject,
164
185
  retiredVocabularyIn: () => retiredVocabularyIn,
186
+ reversalKey: () => reversalKey,
165
187
  sgtinClass: () => sgtinClass,
166
188
  sgtinUri: () => sgtinUri,
167
189
  ssccUri: () => ssccUri,
@@ -173,6 +195,7 @@ __export(index_exports, {
173
195
  transformationEvent: () => transformationEvent,
174
196
  validateDomainDefinition: () => validateDomainDefinition,
175
197
  validateEpcisEvent: () => validateEpcisEvent,
198
+ validatePerformance: () => validatePerformance,
176
199
  validateScenario: () => validateScenario,
177
200
  webhookSenderAction: () => webhookSenderAction,
178
201
  weekdayAt: () => weekdayAt,
@@ -1201,6 +1224,22 @@ var MES_TYPES = [
1201
1224
  { key: "painter", role: "equipment", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1202
1225
  { key: "assembler", role: "equipment", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
1203
1226
  ];
1227
+ var MES_COMMANDS = [
1228
+ {
1229
+ type: "production.order",
1230
+ label: "\uC0DD\uC0B0 \uC9C0\uC2DC",
1231
+ /*
1232
+ * 되돌릴 수 없다. 지시가 나가면 사람과 설비가 그것으로 움직이고, 취소해도 이미 만든 것은 남는다.
1233
+ * 그래서 사람 승인을 지난다(§`requiresApproval`).
1234
+ */
1235
+ reversible: false,
1236
+ /*
1237
+ * 단위를 요구하는 이유 — 단위 없는 수량은 지시가 되지 못한다. 우리가 기본 단위를 지어내면 그것이
1238
+ * 현장의 단위와 다른 날 수량이 조용히 틀린다.
1239
+ */
1240
+ required: ["materialDefinitionId", "quantity", "uom"]
1241
+ }
1242
+ ];
1204
1243
 
1205
1244
  // src/ems-profile.ts
1206
1245
  var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
@@ -3853,6 +3892,166 @@ function computeOee(c, nowMs) {
3853
3892
  scrapCount: c.scrapCount
3854
3893
  };
3855
3894
  }
3895
+
3896
+ // src/erp.ts
3897
+ var SCHEDULE_STATUS = ["draft", "released", "started", "completed", "closed", "cancelled"];
3898
+ function acceptsPerformance(status) {
3899
+ return status === "released" || status === "started";
3900
+ }
3901
+ function performanceKey(e) {
3902
+ const parts = [e.orderId, e.operationId ?? "-", e.from, e.to];
3903
+ if (parts.some((p) => !String(p ?? "").trim())) {
3904
+ throw new Error(`performanceKey: \uC624\uB354 \xB7 \uAD6C\uAC04\uC774 \uC5C6\uC73C\uBA74 \uBA71\uB4F1 \uD0A4\uB97C \uB9CC\uB4E4 \uC218 \uC5C6\uB2E4 \u2014 ${JSON.stringify(e)}`);
3905
+ }
3906
+ return parts.join("|");
3907
+ }
3908
+ function materialMovementKey(e) {
3909
+ return `${performanceKey(e)}|goods`;
3910
+ }
3911
+ function validatePerformance(e, nowMs) {
3912
+ const errors = [];
3913
+ if (!String(e?.orderId ?? "").trim()) errors.push("orderId \uC5C6\uC74C \u2014 \uC5B4\uB290 \uC9C0\uC2DC\uC5D0 \uB300\uD55C \uC2E4\uC801\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
3914
+ if (!String(e?.uom ?? "").trim()) errors.push("uom \uC5C6\uC74C \u2014 \uB2E8\uC704 \uC5C6\uB294 \uC218\uB7C9\uC740 \uC815\uC0B0\uC5D0 \uC4F8 \uC218 \uC5C6\uB2E4");
3915
+ for (const [name, v] of [
3916
+ ["goodQuantity", e?.goodQuantity],
3917
+ ["scrapQuantity", e?.scrapQuantity]
3918
+ ]) {
3919
+ if (typeof v !== "number" || !Number.isFinite(v)) errors.push(`${name} \uC774 \uC218\uAC00 \uC544\uB2C8\uB2E4`);
3920
+ else if (v < 0) errors.push(`${name} \uC774 \uC74C\uC218\uB2E4 \u2014 \uB418\uB3CC\uB9BC\uC740 \uC5ED\uBD84\uAC1C\uB85C \uD558\uACE0 \uC74C\uC218 \uC2E4\uC801\uC73C\uB85C \uD558\uC9C0 \uC54A\uB294\uB2E4`);
3921
+ }
3922
+ if (typeof e?.goodQuantity === "number" && typeof e?.scrapQuantity === "number" && e.goodQuantity + e.scrapQuantity <= 0) {
3923
+ errors.push("\uB9CC\uB4E0 \uAC83\uC774 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uC62C\uB9B4 \uC2E4\uC801\uC774 \uC544\uB2C8\uB2E4");
3924
+ }
3925
+ const from = Date.parse(String(e?.from ?? ""));
3926
+ const to = Date.parse(String(e?.to ?? ""));
3927
+ if (!Number.isFinite(from) || !Number.isFinite(to)) errors.push("\uAD6C\uAC04\uC774 \uC5C6\uB2E4 \u2014 from \xB7 to \uAC00 \uC788\uC5B4\uC57C \uD55C\uB2E4");
3928
+ else {
3929
+ if (to < from) errors.push("\uAD6C\uAC04\uC774 \uAC70\uAFB8\uB85C\uB2E4");
3930
+ if (to > nowMs) errors.push("\uC544\uC9C1 \uC624\uC9C0 \uC54A\uC740 \uC2DC\uAC01\uAE4C\uC9C0\uC758 \uC2E4\uC801\uC774\uB2E4");
3931
+ }
3932
+ for (const m of e?.materialActual ?? []) {
3933
+ if (!String(m?.materialDefinitionId ?? "").trim()) errors.push("\uC790\uC7AC \uC904\uC5D0 materialDefinitionId \uAC00 \uC5C6\uB2E4");
3934
+ if (!String(m?.uom ?? "").trim()) errors.push(`${m?.materialDefinitionId}: uom \uC5C6\uC74C \u2014 \uB2E8\uC704\uB294 ERP \uC758 \uC0AC\uC2E4\uC774\uBBC0\uB85C \uC9C0\uC5B4\uB0B4\uC9C0 \uC54A\uB294\uB2E4`);
3935
+ if (typeof m?.quantity !== "number" || !(m.quantity > 0)) errors.push(`${m?.materialDefinitionId}: \uC218\uB7C9\uC774 0 \uC774\uD558\uB2E4`);
3936
+ if (m?.direction !== "consumed" && m?.direction !== "produced") {
3937
+ errors.push(`${m?.materialDefinitionId}: direction \uC774 consumed \xB7 produced \uAC00 \uC544\uB2C8\uB2E4`);
3938
+ }
3939
+ }
3940
+ return errors;
3941
+ }
3942
+ function reversalKey(performanceKey2) {
3943
+ if (!String(performanceKey2 ?? "").trim()) throw new Error("reversalKey: \uB418\uB3CC\uB9B4 \uC2E4\uC801\uC758 \uD0A4\uAC00 \uC5C6\uB2E4");
3944
+ return `${performanceKey2}|reversal`;
3945
+ }
3946
+ var ERP_CAPABILITY = ["schedule", "performance", "material-movement", "reversal", "master"];
3947
+ var ERP_MASTER_AXIS = ["product", "bom", "routing", "work-center"];
3948
+ function erpCapabilityGaps(adapter) {
3949
+ const needs = {
3950
+ schedule: "readSchedules",
3951
+ performance: "postPerformance",
3952
+ "material-movement": "postMaterialMovement",
3953
+ reversal: "reversePerformance",
3954
+ master: "readMaster"
3955
+ };
3956
+ const gaps = [];
3957
+ for (const cap of adapter.capabilities ?? []) {
3958
+ const fn = needs[cap];
3959
+ if (typeof adapter[fn] !== "function") gaps.push(`${cap} \uC744 \uC120\uC5B8\uD588\uB294\uB370 ${String(fn)} \uAD6C\uD604\uC774 \uC5C6\uB2E4`);
3960
+ }
3961
+ if (adapter.traits?.reversal === "api" && typeof adapter.reversePerformance !== "function") {
3962
+ gaps.push("traits.reversal \uC774 api \uC778\uB370 reversePerformance \uAD6C\uD604\uC774 \uC5C6\uB2E4");
3963
+ }
3964
+ if (adapter.traits?.reversal !== "api" && adapter.capabilities?.includes("reversal")) {
3965
+ gaps.push("reversal \uB2A5\uB825\uC744 \uC120\uC5B8\uD588\uB294\uB370 traits.reversal \uC774 api \uAC00 \uC544\uB2C8\uB2E4 \u2014 \uB458 \uC911 \uD558\uB098\uAC00 \uD2C0\uB838\uB2E4");
3966
+ }
3967
+ if (adapter.traits?.materialWithPerformance && typeof adapter.postMaterialMovement === "function") {
3968
+ gaps.push("traits.materialWithPerformance \uAC00 \uCC38\uC778\uB370 postMaterialMovement \uAC00 \uB530\uB85C \uC788\uB2E4 \u2014 \uC18C\uBE44\uAC00 \uB450 \uBC88 \uC549\uB294\uB2E4");
3969
+ }
3970
+ return gaps;
3971
+ }
3972
+
3973
+ // src/actuation.ts
3974
+ var COMMAND_STATE = ["proposed", "approved", "rejected", "dispatched", "acked", "failed"];
3975
+ var COMMAND_ORIGIN = ["operator", "rule", "ai", "schedule"];
3976
+ function requiresApproval(command) {
3977
+ return command?.reversible !== true;
3978
+ }
3979
+ var COMMAND_EVENT = ["approve", "reject", "dispatch", "ack", "fail"];
3980
+ var ALLOWED = {
3981
+ proposed: { approve: "approved", reject: "rejected" },
3982
+ approved: { dispatch: "dispatched", reject: "rejected" },
3983
+ /* 넘기다 실패한 것은 다시 넘길 수 있다 — 승인은 그대로 살아 있다. */
3984
+ failed: { dispatch: "dispatched", reject: "rejected" },
3985
+ dispatched: { ack: "acked", fail: "failed" },
3986
+ acked: {},
3987
+ rejected: {}
3988
+ };
3989
+ function nextCommandState(from, event) {
3990
+ return ALLOWED[from]?.[event];
3991
+ }
3992
+ function isCommandTerminal(state) {
3993
+ return Object.keys(ALLOWED[state] ?? {}).length === 0;
3994
+ }
3995
+ function approveCommand(command, approval) {
3996
+ if (!approval?.by?.trim()) throw new Error("\uC2B9\uC778\uC5D0\uB294 \uB204\uAC00 \uC2B9\uC778\uD588\uB294\uC9C0\uAC00 \uC788\uC5B4\uC57C \uD55C\uB2E4");
3997
+ if (!approval?.at?.trim()) throw new Error("\uC2B9\uC778\uC5D0\uB294 \uC5B8\uC81C \uC2B9\uC778\uD588\uB294\uC9C0\uAC00 \uC788\uC5B4\uC57C \uD55C\uB2E4");
3998
+ const to = nextCommandState(command.state, "approve");
3999
+ if (!to) throw new Error(`${command.state} \uC0C1\uD0DC\uC758 \uCEE4\uB9E8\uB4DC\uB294 \uC2B9\uC778\uD560 \uC218 \uC5C6\uB2E4 (${command.id})`);
4000
+ return { ...command, state: to, approval };
4001
+ }
4002
+ function passThroughReversible(command) {
4003
+ if (requiresApproval(command)) {
4004
+ throw new Error(`\uB418\uB3CC\uB9B4 \uC218 \uC5C6\uB294 \uCEE4\uB9E8\uB4DC\uB294 \uC0AC\uB78C \uC2B9\uC778\uC744 \uC9C0\uB098\uC57C \uD55C\uB2E4 (${command.id} \xB7 ${command.type})`);
4005
+ }
4006
+ const to = nextCommandState(command.state, "approve");
4007
+ if (!to) throw new Error(`${command.state} \uC0C1\uD0DC\uC758 \uCEE4\uB9E8\uB4DC\uB294 \uB118\uAE38 \uC218 \uC5C6\uB2E4 (${command.id})`);
4008
+ return { ...command, state: to };
4009
+ }
4010
+ function asApproved(command) {
4011
+ if (command.state !== "approved" && command.state !== "failed") {
4012
+ throw new Error(`\uC2B9\uC778\uB418\uC9C0 \uC54A\uC740 \uCEE4\uB9E8\uB4DC\uB97C \uC870\uCE58\uB85C \uB118\uAE38 \uC218 \uC5C6\uB2E4 (${command.id} \xB7 ${command.state})`);
4013
+ }
4014
+ if (requiresApproval(command) && !command.approval?.by) {
4015
+ throw new Error(`\uC2B9\uC778 \uAE30\uB85D\uC774 \uC5C6\uB294 \uCEE4\uB9E8\uB4DC\uB97C \uC870\uCE58\uB85C \uB118\uAE38 \uC218 \uC5C6\uB2E4 (${command.id})`);
4016
+ }
4017
+ return command;
4018
+ }
4019
+ function commandTypeOf(specs, type) {
4020
+ return specs.find((s) => s.type === type);
4021
+ }
4022
+ function commandFromSpec(spec, seed) {
4023
+ const missing = missingCommandPayload(spec, seed.payload);
4024
+ if (missing.length) {
4025
+ throw new Error(`${spec.type}: \uC694\uAD6C\uD558\uB294 \uCE78\uC774 \uC5C6\uB2E4 \u2014 ${missing.join(", ")}`);
4026
+ }
4027
+ return {
4028
+ id: seed.id,
4029
+ instanceId: seed.instanceId,
4030
+ type: spec.type,
4031
+ ...seed.payload ? { payload: seed.payload } : {},
4032
+ origin: seed.origin,
4033
+ proposedAt: seed.proposedAt,
4034
+ reversible: spec.reversible,
4035
+ state: "proposed"
4036
+ };
4037
+ }
4038
+ function missingCommandPayload(spec, payload) {
4039
+ const has = (k) => {
4040
+ const v = payload?.[k];
4041
+ if (v === void 0 || v === null) return false;
4042
+ return typeof v === "string" ? v.trim().length > 0 : true;
4043
+ };
4044
+ return spec.required.filter((k) => !has(k));
4045
+ }
4046
+ function commandSpecGaps(specs, command) {
4047
+ const spec = commandTypeOf(specs, command.type);
4048
+ if (!spec) return [`\uBAA8\uB974\uB294 \uC870\uCE58 \uC885\uB958\uB2E4 \u2014 ${command.type}`];
4049
+ const gaps = missingCommandPayload(spec, command.payload).map((k) => `\uC694\uAD6C\uD558\uB294 \uCE78\uC774 \uC5C6\uB2E4 \u2014 ${k}`);
4050
+ if (command.reversible === true && spec.reversible === false) {
4051
+ gaps.push(`${command.type} \uC740 \uB418\uB3CC\uB9B4 \uC218 \uC5C6\uB294 \uC885\uB958\uC778\uB370 \uC774 \uCEE4\uB9E8\uB4DC\uAC00 \uB418\uB3CC\uB9B4 \uC218 \uC788\uB2E4\uACE0 \uD55C\uB2E4 \u2014 \uC2B9\uC778\uC744 \uAC74\uB108\uB6F4\uB2E4`);
4052
+ }
4053
+ return gaps;
4054
+ }
3856
4055
  // Annotate the CommonJS export names for ESM import in node:
3857
4056
  0 && (module.exports = {
3858
4057
  BIZSTEP,
@@ -3863,6 +4062,9 @@ function computeOee(c, nowMs) {
3863
4062
  CAPABILITY_KEYS,
3864
4063
  CBV_BIZSTEP,
3865
4064
  CMD,
4065
+ COMMAND_EVENT,
4066
+ COMMAND_ORIGIN,
4067
+ COMMAND_STATE,
3866
4068
  DISP,
3867
4069
  DISPOSITION_DECISION,
3868
4070
  DOMAIN_CATALOG,
@@ -3876,11 +4078,14 @@ function computeOee(c, nowMs) {
3876
4078
  EPCIS_CONTEXT,
3877
4079
  EPCIS_EVENT,
3878
4080
  EQUIPMENT_LEVEL,
4081
+ ERP_CAPABILITY,
4082
+ ERP_MASTER_AXIS,
3879
4083
  GUARD_PRAGMA,
3880
4084
  ILMD_ATTR,
3881
4085
  LOCATION_SATURATION_NEAR,
3882
4086
  MATERIAL_PROPERTY,
3883
4087
  MES_BIZSTEP,
4088
+ MES_COMMANDS,
3884
4089
  MES_LOCATION_TYPES,
3885
4090
  MES_TYPES,
3886
4091
  METER_DIRECTION,
@@ -3890,6 +4095,7 @@ function computeOee(c, nowMs) {
3890
4095
  ORDER_TERMINAL_STATUS,
3891
4096
  PRIORITY_UNSET,
3892
4097
  RETIRED_VOCABULARY,
4098
+ SCHEDULE_STATUS,
3893
4099
  TWIN_AXES,
3894
4100
  TWIN_PROPERTIES,
3895
4101
  TWIN_RELATIONS,
@@ -3903,10 +4109,13 @@ function computeOee(c, nowMs) {
3903
4109
  YARD_BIZSTEP,
3904
4110
  YMS_LOCATION_TYPES,
3905
4111
  YMS_TYPES,
4112
+ acceptsPerformance,
3906
4113
  activeShiftAt,
3907
4114
  activeShiftOf,
3908
4115
  aggregationEvent,
3909
4116
  analyzeCapacity,
4117
+ approveCommand,
4118
+ asApproved,
3910
4119
  axesOfSystem,
3911
4120
  axisAppliesTo,
3912
4121
  axisInfo,
@@ -3918,6 +4127,9 @@ function computeOee(c, nowMs) {
3918
4127
  checkSequenceRun,
3919
4128
  classClosure,
3920
4129
  classIdentifierViolation,
4130
+ commandFromSpec,
4131
+ commandSpecGaps,
4132
+ commandTypeOf,
3921
4133
  commandsOf,
3922
4134
  computeOee,
3923
4135
  conversionFactorOf,
@@ -3926,6 +4138,7 @@ function computeOee(c, nowMs) {
3926
4138
  dueStatusOf,
3927
4139
  effectivityAt,
3928
4140
  electricalUpstreamOf,
4141
+ erpCapabilityGaps,
3929
4142
  expiryFromAttributes,
3930
4143
  gdtiUri,
3931
4144
  generationFractionAt,
@@ -3947,6 +4160,7 @@ function computeOee(c, nowMs) {
3947
4160
  ingestMasterData,
3948
4161
  ingestOperationalRecords,
3949
4162
  isAggregationRecord,
4163
+ isCommandTerminal,
3950
4164
  isElectricalLocationType,
3951
4165
  isEnergyBillRecord,
3952
4166
  isEnergyEquipmentRecord,
@@ -3973,8 +4187,11 @@ function computeOee(c, nowMs) {
3973
4187
  lotFromAttributes,
3974
4188
  mapRecord,
3975
4189
  mapRecordChecked,
4190
+ materialMovementKey,
3976
4191
  meetsTests,
3977
4192
  minuteOfDayAt,
4193
+ missingCommandPayload,
4194
+ nextCommandState,
3978
4195
  objectEvent,
3979
4196
  objectUri,
3980
4197
  observationAt,
@@ -3985,6 +4202,8 @@ function computeOee(c, nowMs) {
3985
4202
  outsideLimit,
3986
4203
  parseEpc,
3987
4204
  parseIsoDuration,
4205
+ passThroughReversible,
4206
+ performanceKey,
3988
4207
  periodFactId,
3989
4208
  priorityRank,
3990
4209
  procedureViolations,
@@ -3996,8 +4215,10 @@ function computeOee(c, nowMs) {
3996
4215
  relationsFrom,
3997
4216
  relationsTo,
3998
4217
  requiredTestsFor,
4218
+ requiresApproval,
3999
4219
  resolveSubject,
4000
4220
  retiredVocabularyIn,
4221
+ reversalKey,
4001
4222
  sgtinClass,
4002
4223
  sgtinUri,
4003
4224
  ssccUri,
@@ -4009,6 +4230,7 @@ function computeOee(c, nowMs) {
4009
4230
  transformationEvent,
4010
4231
  validateDomainDefinition,
4011
4232
  validateEpcisEvent,
4233
+ validatePerformance,
4012
4234
  validateScenario,
4013
4235
  webhookSenderAction,
4014
4236
  weekdayAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Operations domain contract — the standard vocabulary that producers and readers agree on (EPCIS 2.0/GS1, ISA-95, IEC 61850/ISO 50001). Types, guards, validation. No state, no engine.",
5
5
  "type": "module",
6
6
  "main": "./dist-cjs/index.cjs",