@operato/twin-kernel 0.11.17 → 0.11.19
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/dist/allocation-policy.d.ts +41 -2
- package/dist/allocation-policy.js +78 -12
- package/dist/flow-engine.d.ts +53 -2
- package/dist/flow-engine.js +57 -6
- package/dist/kernel.js +23 -8
- package/dist/mes-kernel.js +15 -14
- package/dist/observed-reducer.d.ts +3 -0
- package/dist/observed-reducer.js +13 -1
- package/dist/yms-kernel.js +3 -2
- package/dist-cjs/index.cjs +163 -43
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AllocatedQuantity, AllocationPolicyName } from '@operato/ops-contract';
|
|
1
2
|
/** 배치(placement) 대상 후보 슬롯의 관측 뷰 (내부 상태 누출 없이 결정에 필요한 만큼만). */
|
|
2
3
|
export interface SlotView {
|
|
3
4
|
id: string;
|
|
@@ -11,6 +12,19 @@ export interface StockView {
|
|
|
11
12
|
location: string;
|
|
12
13
|
qty: number;
|
|
13
14
|
expiry?: number;
|
|
15
|
+
/**
|
|
16
|
+
* **이 재고가 들어온 시각**(sim-ms) — 출처는 receiving 관측의 `eventTime` 이다(수집 시각이 아니다).
|
|
17
|
+
*
|
|
18
|
+
* ── 없는 값이 있다 ──────────────────────────────────────────────────────────
|
|
19
|
+
* 씨앗으로 선 재고, 입고 기록 없이 서 있던 재고는 이 값을 갖지 않는다. **없음을 시각으로 메우지
|
|
20
|
+
* 않는다** — 0 으로 두면 가장 오래된 것이 되어 먼저 나가고, 지금 시각으로 두면 영영 안 나간다.
|
|
21
|
+
* 둘 다 없는 근거로 순서를 정하는 것이다.
|
|
22
|
+
*
|
|
23
|
+
* 지금 이 값을 읽는 정책은 없다(닫힌 목록 셋 중 시각을 보는 것은 없다 — `firstFit` 은 식별자 순이다).
|
|
24
|
+
* 「먼저 들어온 것 먼저」를 실제로 하는 정책이 설 때, 시각 없는 재고를 어디에 세울지는 그 정책이
|
|
25
|
+
* 한 곳에서 선언한다.
|
|
26
|
+
*/
|
|
27
|
+
receivedAtMs?: number;
|
|
14
28
|
}
|
|
15
29
|
export interface PlacementContext {
|
|
16
30
|
item: {
|
|
@@ -39,8 +53,18 @@ export interface StockRequest {
|
|
|
39
53
|
export interface AllocationPolicy {
|
|
40
54
|
/** 배치 목적지 슬롯 id. 수용 불가면 null(도크 대기 → 다음 tick 재시도). */
|
|
41
55
|
selectPlacement(ctx: PlacementContext): string | null;
|
|
42
|
-
/**
|
|
43
|
-
|
|
56
|
+
/**
|
|
57
|
+
* 이번 라운드에 잡을 **줄들**(부분 가능). 빈 배열 = 이번엔 할당 보류.
|
|
58
|
+
*
|
|
59
|
+
* ── 왜 줄인가 (2026-09-19, ADR-0076 보탬) ─────────────────────────────────
|
|
60
|
+
* 예전에는 EPC 목록이었고 **줄 수**를 셌다(`total++`). 한 EPC = 한 개라는 전제였고, 그래서
|
|
61
|
+
* 「100 중 30」을 표현할 수 없었다 — 수량 로트를 다루는 창고와 MES 의 자재 확보(밀가루 3.5kg)가
|
|
62
|
+
* 그 전제 밖이다. 이제 각 줄이 자기 수를 들고, 마지막 줄은 요청에 맞춰 일부만 잡을 수 있다.
|
|
63
|
+
*
|
|
64
|
+
* 열쇠의 낱말은 `epc` 그대로다 — 수량 로트의 줄도 그 식별자로 불린다(한 개념에 낱말 둘을 만들지
|
|
65
|
+
* 않는다).
|
|
66
|
+
*/
|
|
67
|
+
selectStock(ctx: StockRequest): readonly AllocatedQuantity[];
|
|
44
68
|
}
|
|
45
69
|
/**
|
|
46
70
|
* 기본 정책 — first-fit slot + all-or-nothing 오더 할당.
|
|
@@ -57,3 +81,18 @@ export declare const partialFitPolicy: AllocationPolicy;
|
|
|
57
81
|
* all-or-nothing(전량 확보 전 대기)이되 선택 순서는 만료 오름차순(동률은 epc). 코어 수정 없이 정책 교체만으로 확장(원칙 2).
|
|
58
82
|
*/
|
|
59
83
|
export declare const fefoPolicy: AllocationPolicy;
|
|
84
|
+
/**
|
|
85
|
+
* 이름 → 정책 — **닫힌 목록**(계약의 `ALLOCATION_POLICY`).
|
|
86
|
+
*
|
|
87
|
+
* 선언은 「이 현장은 어느 규칙으로 고르나」이고(트윈 모델의 `allocationPolicy`), 그것을 창고 앱과
|
|
88
|
+
* 커널이 **같이** 읽는다. 등록부가 없으면 선언이 이름만 남고 커널은 계속 기본 정책으로 돈다 —
|
|
89
|
+
* 그러면 트윈이 그 창고의 미래를 다른 규칙으로 실행한다(ADR-0076 결정 1).
|
|
90
|
+
*/
|
|
91
|
+
export declare const ALLOCATION_POLICIES: Record<AllocationPolicyName, AllocationPolicy>;
|
|
92
|
+
/**
|
|
93
|
+
* 선언한 이름의 정책을 찾는다. **모르는 이름은 거절한다** — 기본값으로 조용히 떨어지면 선언한
|
|
94
|
+
* 규칙과 도는 규칙이 갈리고, 그 사실은 아무 데도 나타나지 않는다.
|
|
95
|
+
*
|
|
96
|
+
* 선언이 없으면(`undefined`) 기본 정책이다 — 선언하지 않은 현장은 지금까지와 같이 돈다.
|
|
97
|
+
*/
|
|
98
|
+
export declare function allocationPolicyOf(name: string | undefined | null): AllocationPolicy;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* 결정은 정책에 위임한다. 고객은 FIFO/FEFO/nearest/zone·부분할당 등을 정책 교체로만
|
|
6
6
|
* 확장 — 코어 수정 없이. (설계 gap #5 "할당 정책 플러그")
|
|
7
7
|
*/
|
|
8
|
+
import { ALLOCATION_POLICY } from '@operato/ops-contract';
|
|
8
9
|
/*
|
|
9
10
|
* ── 식별자 비교에 `localeCompare` 를 쓰지 않는다 (2026-08-21 프로파일) ───────
|
|
10
11
|
*
|
|
@@ -37,24 +38,60 @@ function freeBinsFirstFit(slots) {
|
|
|
37
38
|
* 작은 정렬 버퍼를 들고 한 번 순회한다. k 가 작을 때(오더가 요구하는 수는 보통 한 자리다) 전량 정렬보다
|
|
38
39
|
* 훨씬 싸고, 결과는 정렬한 뒤 앞의 k 개와 **같다**.
|
|
39
40
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
/*
|
|
42
|
+
* 줄 하나가 든 수 — **틱의 뜨거운 길**이라 흔한 경우를 먼저 본다(`Number()` 변환을 태우지 않는다).
|
|
43
|
+
* 값이 수가 아니거나 0 이하면 0 이다: 그 줄에서 잡을 수 있는 것이 없다는 뜻이고, 「모른다」를 1 로
|
|
44
|
+
* 메우면 없는 재고를 잡는다.
|
|
45
|
+
*/
|
|
46
|
+
function qtyOf(s) {
|
|
47
|
+
const q = s?.qty;
|
|
48
|
+
return typeof q === 'number' && q > 0 && q < Infinity ? q : 0;
|
|
49
|
+
}
|
|
50
|
+
function smallestByEpc(available, need) {
|
|
51
|
+
if (need <= 0)
|
|
42
52
|
return { picked: [], total: 0 };
|
|
43
53
|
const out = [];
|
|
54
|
+
/* 버퍼가 들고 있는 수 — 요청을 덮는 **가장 짧은 앞머리**만 남긴다. */
|
|
55
|
+
let held = 0;
|
|
44
56
|
let total = 0;
|
|
45
57
|
for (const s of available) {
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
const q = qtyOf(s);
|
|
59
|
+
total += q;
|
|
60
|
+
if (q === 0)
|
|
61
|
+
continue;
|
|
62
|
+
/* 이미 덮었고 이 줄이 마지막 것보다 뒤면 볼 것 없다 — 순회 한 번을 지키는 자리다. */
|
|
63
|
+
if (held >= need && byId(s.epc, out[out.length - 1].epc) >= 0)
|
|
48
64
|
continue;
|
|
49
65
|
let i = out.length;
|
|
50
66
|
while (i > 0 && byId(out[i - 1].epc, s.epc) > 0)
|
|
51
67
|
i--;
|
|
52
68
|
out.splice(i, 0, s);
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
held += q;
|
|
70
|
+
/* 뒤에서부터 덜어 낸다 — 빼도 여전히 덮이면 그 줄은 필요 없다. */
|
|
71
|
+
while (out.length > 0 && held - qtyOf(out[out.length - 1]) >= need)
|
|
72
|
+
held -= qtyOf(out.pop());
|
|
55
73
|
}
|
|
56
74
|
return { picked: out, total };
|
|
57
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* 고른 줄들에서 요청한 **수만큼** 떼어 낸다 — 마지막 줄은 일부만 잡힐 수 있다.
|
|
78
|
+
*
|
|
79
|
+
* 「100 중 30」이 여기서 생긴다. 직렬 물품(`qty: 1`)은 예전과 같이 줄마다 하나씩이다.
|
|
80
|
+
*/
|
|
81
|
+
function takeFrom(views, need) {
|
|
82
|
+
const out = [];
|
|
83
|
+
let left = need;
|
|
84
|
+
for (const s of views) {
|
|
85
|
+
if (left <= 0)
|
|
86
|
+
break;
|
|
87
|
+
const take = Math.min(left, qtyOf(s));
|
|
88
|
+
if (take <= 0)
|
|
89
|
+
continue;
|
|
90
|
+
out.push({ epc: s.epc, qty: take });
|
|
91
|
+
left -= take;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
58
95
|
/**
|
|
59
96
|
* 기본 정책 — first-fit slot + all-or-nothing 오더 할당.
|
|
60
97
|
* (재고가 요청 전량을 못 채우면 이번 라운드 보류 → 전량 확보 시 단일 출하.)
|
|
@@ -62,11 +99,11 @@ function smallestByEpc(available, k) {
|
|
|
62
99
|
export const firstFitPolicy = {
|
|
63
100
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
64
101
|
selectStock: ({ qty, available }) => {
|
|
65
|
-
/* 고르는 것과 세는 것을 **한 번의 순회**로 한다 — 전량 확보 판정에
|
|
102
|
+
/* 고르는 것과 세는 것을 **한 번의 순회**로 한다 — 전량 확보 판정에 총수가 필요하다. */
|
|
66
103
|
const { picked, total } = smallestByEpc(available, qty);
|
|
67
104
|
if (total < qty)
|
|
68
|
-
return []; // 전량 확보 전엔 대기
|
|
69
|
-
return picked
|
|
105
|
+
return []; // 전량 확보 전엔 대기 — 세는 것은 줄 수가 아니라 수다
|
|
106
|
+
return takeFrom(picked, qty);
|
|
70
107
|
}
|
|
71
108
|
};
|
|
72
109
|
/**
|
|
@@ -75,7 +112,7 @@ export const firstFitPolicy = {
|
|
|
75
112
|
*/
|
|
76
113
|
export const partialFitPolicy = {
|
|
77
114
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
78
|
-
selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked
|
|
115
|
+
selectStock: ({ qty, available }) => takeFrom(smallestByEpc(available, qty).picked, qty)
|
|
79
116
|
};
|
|
80
117
|
/**
|
|
81
118
|
* FEFO 정책 — First-Expired-First-Out. 만료 임박 로트를 먼저 출고(신선/제약 도메인).
|
|
@@ -86,9 +123,38 @@ export const fefoPolicy = {
|
|
|
86
123
|
selectStock: ({ qty, available }) => {
|
|
87
124
|
/* 만료 순이 필요하므로 전체를 펼친다 — 이 정책이 실제로 필요해서 치르는 비용이다. */
|
|
88
125
|
const all = [...available];
|
|
89
|
-
|
|
126
|
+
const total = all.reduce((sum, s) => sum + qtyOf(s), 0);
|
|
127
|
+
if (total < qty)
|
|
90
128
|
return []; // 전량 확보 전엔 대기
|
|
91
129
|
const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
|
|
92
|
-
return byExpiry
|
|
130
|
+
return takeFrom(byExpiry, qty);
|
|
93
131
|
}
|
|
94
132
|
};
|
|
133
|
+
/**
|
|
134
|
+
* 이름 → 정책 — **닫힌 목록**(계약의 `ALLOCATION_POLICY`).
|
|
135
|
+
*
|
|
136
|
+
* 선언은 「이 현장은 어느 규칙으로 고르나」이고(트윈 모델의 `allocationPolicy`), 그것을 창고 앱과
|
|
137
|
+
* 커널이 **같이** 읽는다. 등록부가 없으면 선언이 이름만 남고 커널은 계속 기본 정책으로 돈다 —
|
|
138
|
+
* 그러면 트윈이 그 창고의 미래를 다른 규칙으로 실행한다(ADR-0076 결정 1).
|
|
139
|
+
*/
|
|
140
|
+
export const ALLOCATION_POLICIES = {
|
|
141
|
+
[ALLOCATION_POLICY.firstFit]: firstFitPolicy,
|
|
142
|
+
[ALLOCATION_POLICY.partialFit]: partialFitPolicy,
|
|
143
|
+
[ALLOCATION_POLICY.fefo]: fefoPolicy
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* 선언한 이름의 정책을 찾는다. **모르는 이름은 거절한다** — 기본값으로 조용히 떨어지면 선언한
|
|
147
|
+
* 규칙과 도는 규칙이 갈리고, 그 사실은 아무 데도 나타나지 않는다.
|
|
148
|
+
*
|
|
149
|
+
* 선언이 없으면(`undefined`) 기본 정책이다 — 선언하지 않은 현장은 지금까지와 같이 돈다.
|
|
150
|
+
*/
|
|
151
|
+
export function allocationPolicyOf(name) {
|
|
152
|
+
const key = String(name ?? '').trim();
|
|
153
|
+
if (!key)
|
|
154
|
+
return firstFitPolicy;
|
|
155
|
+
const found = ALLOCATION_POLICIES[key];
|
|
156
|
+
if (!found) {
|
|
157
|
+
throw new Error(`unknown allocation policy "${key}" — declare one of: ${Object.keys(ALLOCATION_POLICIES).join(' · ')}`);
|
|
158
|
+
}
|
|
159
|
+
return found;
|
|
160
|
+
}
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact } from '@operato/ops-contract';
|
|
1
|
+
import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact, AllocatedQuantity, MaterialLotUse } from '@operato/ops-contract';
|
|
2
2
|
import { type RatedUsage, type UsedUsage } from '@operato/ops-contract';
|
|
3
3
|
import type { VocabularyElement } from '@operato/ops-contract';
|
|
4
4
|
import type { ReducerCheckpoint } from './observed-reducer.ts';
|
|
@@ -42,6 +42,16 @@ export interface FlowItem {
|
|
|
42
42
|
/** 이 물류단위를 싣고 있는 반복사용 자산(GRAI) — `parent` 와 다른 축. */
|
|
43
43
|
carriedBy?: string;
|
|
44
44
|
expiry?: number;
|
|
45
|
+
/**
|
|
46
|
+
* **이 재고가 들어온 시각**(sim-ms) — receiving 관측의 `eventTime` 이다(수집 시각이 아니다).
|
|
47
|
+
*
|
|
48
|
+
* 「먼저 들어온 것 먼저」를 실제로 하려면 이 축이 있어야 한다 — 지금 시각 축은 만료(`expiry`)
|
|
49
|
+
* 뿐이고, 그래서 `firstFit` 은 FIFO 가 아니라 식별자 순이다(ADR-0076 정정 ②).
|
|
50
|
+
*
|
|
51
|
+
* **없는 값이 있다**: 씨앗으로 선 재고, 입고 기록 없이 서 있던 재고. 없음을 시각으로 메우지
|
|
52
|
+
* 않는다 — 0 도 지금도 없는 근거다(§`StockView.receivedAtMs`).
|
|
53
|
+
*/
|
|
54
|
+
receivedAtMs?: number;
|
|
45
55
|
/** 개체·로트 마스터데이터 원문 — 생겨날 때 정해지고 뒤 이벤트가 지우지 않는다. */
|
|
46
56
|
ilmd?: Record<string, unknown>;
|
|
47
57
|
/** 선언된 모든 수량 — 표준 `MaterialLot.Quantity`(복수). 관측에서 온 물품이 여러 단위를 들 수 있다. */
|
|
@@ -53,6 +63,13 @@ export interface FlowItem {
|
|
|
53
63
|
*/
|
|
54
64
|
nonconformance?: DispositionFact;
|
|
55
65
|
status?: string;
|
|
66
|
+
/**
|
|
67
|
+
* 그 상태의 **효과** — 이 로트를 쓸 수 있나(계약 `MATERIAL_LOT_USE`). 할당이 읽는 유일한 칸이다.
|
|
68
|
+
*
|
|
69
|
+
* 낱말(`status`)은 현장의 것이라 커널이 읽지 않는다 — 읽으면 현장이 다른 낱말을 쓰는 날 보류가
|
|
70
|
+
* 조용히 할당에 든다. **없으면 「모름」이다**(`allowed` 가 아니다).
|
|
71
|
+
*/
|
|
72
|
+
use?: MaterialLotUse;
|
|
56
73
|
}
|
|
57
74
|
/** 물리 자산 — 반복사용(팔레트·랙·용기). 설비도 물품도 아니다(GRAI vs SSCC 구분은 계약 주석 참조). */
|
|
58
75
|
export interface FlowAsset extends EffectivePeriod {
|
|
@@ -239,7 +256,20 @@ export interface FlowTask {
|
|
|
239
256
|
export interface OrderLine {
|
|
240
257
|
gtin: string;
|
|
241
258
|
requested: number;
|
|
259
|
+
/** 이 줄이 이행한 수 — 입고 오더는 「몇 개 받았나」다. */
|
|
260
|
+
fulfilled?: number;
|
|
261
|
+
/**
|
|
262
|
+
* 이 줄이 **잡아 둔 수** — 「100 중 30 을 잡았다」의 30(계약 `ObservedOrderLine.allocated`).
|
|
263
|
+
*
|
|
264
|
+
* 전량 확보는 물류단위의 처분 관측으로도 나가지만 부분 확보는 그렇게 적을 수 없다(나머지까지
|
|
265
|
+
* 잡힌 것으로 읽힌다 — ADR-0076 결정 2 의 정정). 그 수가 사는 자리가 여기다.
|
|
266
|
+
*/
|
|
267
|
+
allocated?: number;
|
|
242
268
|
}
|
|
269
|
+
/** 잡아 둔 줄들의 **합계** — 줄 수가 아니라 수다(직렬 물품은 둘이 같다). */
|
|
270
|
+
export declare function allocatedQty(rows: readonly AllocatedQuantity[] | undefined): number;
|
|
271
|
+
/** 잡아 둔 줄들의 식별자 — EPCIS 이벤트가 싣는 것은 열쇠뿐이다. */
|
|
272
|
+
export declare function allocatedEpcs(rows: readonly AllocatedQuantity[] | undefined): string[];
|
|
243
273
|
export interface FlowOrder {
|
|
244
274
|
id: string;
|
|
245
275
|
kind: string;
|
|
@@ -247,7 +277,13 @@ export interface FlowOrder {
|
|
|
247
277
|
requested: number;
|
|
248
278
|
fulfilled: number;
|
|
249
279
|
bizTransaction: string;
|
|
250
|
-
|
|
280
|
+
/**
|
|
281
|
+
* 이 오더가 잡아 둔 줄들 — **무엇을 얼마나**(계약 `AllocatedQuantity`).
|
|
282
|
+
*
|
|
283
|
+
* 2026-09-19 에 EPC 목록에서 넓혔다: 한 EPC = 한 개라는 전제가 수량 로트(케이스 100 중 30 ·
|
|
284
|
+
* 밀가루 3.5kg)를 적지 못했다. 직렬 물품은 `qty: 1` 이고 거동이 같다.
|
|
285
|
+
*/
|
|
286
|
+
allocated: AllocatedQuantity[];
|
|
251
287
|
picked: string[];
|
|
252
288
|
gtin?: string;
|
|
253
289
|
shipmentEpc?: string | null;
|
|
@@ -633,6 +669,8 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
633
669
|
* 부족한 씨앗 위에서 낸 답을 완전한 답으로 읽는다.
|
|
634
670
|
*/
|
|
635
671
|
private seedDanglingRefs;
|
|
672
|
+
/** 로트 상태를 모르는 채로 할당 후보에 든 수 — 「모름」을 안전으로 읽지 않기 위한 셈(ADR-0080 결정 2). */
|
|
673
|
+
private allocatedWithUnknownLotUse;
|
|
636
674
|
private transformInputsAbsent;
|
|
637
675
|
/**
|
|
638
676
|
* 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
|
|
@@ -771,6 +809,19 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
771
809
|
* 그래서 심을 때 맞춘다. **없는 물품을 만들어 채우지 않는다** — 채우면 계보와 재고가 거짓 위에 선다.
|
|
772
810
|
* 대신 몇 건이 맞지 않았는지 남겨 씨앗이 불완전했다는 사실을 답에 실을 수 있게 한다.
|
|
773
811
|
*/
|
|
812
|
+
/**
|
|
813
|
+
* **이 로트를 할당 후보에 넣어도 되나** — 판정은 이 한 곳이다(ADR-0080 결정 2).
|
|
814
|
+
*
|
|
815
|
+
* 읽는 것은 효과(`use`)뿐이고 낱말(`status`)은 읽지 않는다. 낱말을 읽으면 현장이 `quarantine` ·
|
|
816
|
+
* `검사대기` 를 쓰는 날 **오류 없이** 보류된 로트가 할당에 든다.
|
|
817
|
+
*
|
|
818
|
+
* **말해 주지 않은 로트는 「모름」이다.** 지금 거동 그대로 후보에 넣되 그 수를 센다 — 멈추면 로트
|
|
819
|
+
* 상태를 아직 안 보내는 현장의 트윈이 통째로 서고, 「모름」을 안전으로 읽으면 보류가 새는 것을
|
|
820
|
+
* 아무도 모른다. 답에 실린 그 수가 「이 원본은 아직 상태를 안 보낸다」를 말한다.
|
|
821
|
+
*/
|
|
822
|
+
protected usableLot(item: {
|
|
823
|
+
use?: MaterialLotUse;
|
|
824
|
+
}): boolean;
|
|
774
825
|
private resolvedAllocated;
|
|
775
826
|
hydrateObserved(snap: {
|
|
776
827
|
locations: LocationState[];
|
package/dist/flow-engine.js
CHANGED
|
@@ -13,7 +13,9 @@ import { OP_EVENT, CMD, USE_UOM, locationStatusOf, readBoardEquipment, equipment
|
|
|
13
13
|
import { ObservedReducer } from "./observed-reducer.js";
|
|
14
14
|
/* 주체를 정하는 규칙은 유입 문과 한 벌이다 — 두 곳에 적으면 한쪽만 고쳐진다. */
|
|
15
15
|
import { resolveSubject } from '@operato/ops-contract';
|
|
16
|
+
import { WMS_ORDER_KIND } from '@operato/ops-contract';
|
|
16
17
|
import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from '@operato/ops-contract';
|
|
18
|
+
import { allocationPolicyOf } from "./allocation-policy.js";
|
|
17
19
|
import { OP_PARAM } from '@operato/ops-contract';
|
|
18
20
|
import { parseIsoDuration } from '@operato/ops-contract';
|
|
19
21
|
import { analyzeCapacity } from '@operato/ops-contract';
|
|
@@ -33,6 +35,14 @@ function effectiveOnly(r) {
|
|
|
33
35
|
...(r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {})
|
|
34
36
|
};
|
|
35
37
|
}
|
|
38
|
+
/** 잡아 둔 줄들의 **합계** — 줄 수가 아니라 수다(직렬 물품은 둘이 같다). */
|
|
39
|
+
export function allocatedQty(rows) {
|
|
40
|
+
return (rows ?? []).reduce((sum, a) => sum + (Number(a?.qty) || 0), 0);
|
|
41
|
+
}
|
|
42
|
+
/** 잡아 둔 줄들의 식별자 — EPCIS 이벤트가 싣는 것은 열쇠뿐이다. */
|
|
43
|
+
export function allocatedEpcs(rows) {
|
|
44
|
+
return (rows ?? []).map(a => a.epc);
|
|
45
|
+
}
|
|
36
46
|
function mulberry32(seed) {
|
|
37
47
|
let a = seed >>> 0;
|
|
38
48
|
const fn = (() => {
|
|
@@ -743,6 +753,8 @@ export class FlowEngine {
|
|
|
743
753
|
* 부족한 씨앗 위에서 낸 답을 완전한 답으로 읽는다.
|
|
744
754
|
*/
|
|
745
755
|
seedDanglingRefs = 0;
|
|
756
|
+
/** 로트 상태를 모르는 채로 할당 후보에 든 수 — 「모름」을 안전으로 읽지 않기 위한 셈(ADR-0080 결정 2). */
|
|
757
|
+
allocatedWithUnknownLotUse = 0;
|
|
746
758
|
transformInputsAbsent = 0;
|
|
747
759
|
/**
|
|
748
760
|
* 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
|
|
@@ -784,6 +796,18 @@ export class FlowEngine {
|
|
|
784
796
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
785
797
|
loadTwinModel(def) {
|
|
786
798
|
this.boardDef = def;
|
|
799
|
+
/*
|
|
800
|
+
* **선언한 규칙으로 고른다** (2026-09-19, ADR-0076 결정 1).
|
|
801
|
+
*
|
|
802
|
+
* 고르는 일은 기록의 주인(창고 앱)이 하고 예측 · what-if 는 커널이 한다. 둘이 같은 선언을 읽지
|
|
803
|
+
* 않으면 트윈이 그 현장의 미래를 **다른 규칙으로** 실행한다. 선언이 없으면 생성자가 준 정책
|
|
804
|
+
* 그대로다 — 지금까지 세운 트윈은 아무것도 바뀌지 않는다.
|
|
805
|
+
*
|
|
806
|
+
* 모르는 이름은 `allocationPolicyOf` 가 거절한다. 기본값으로 조용히 떨어지면 선언한 규칙과 도는
|
|
807
|
+
* 규칙이 갈리고 그 사실이 아무 데도 나타나지 않는다.
|
|
808
|
+
*/
|
|
809
|
+
if (def.allocationPolicy)
|
|
810
|
+
this.policy = allocationPolicyOf(def.allocationPolicy);
|
|
787
811
|
/*
|
|
788
812
|
* **생산 선언은 어느 커널이든 싣는다.** 예전에는 MES 커널만 생성자에서 이것을 실었고(그 시절
|
|
789
813
|
* 이름은 `mesSpec` 이었다), 그래서 창고 트윈은 "자재를 소비해 자재를 산출하는 공정" 을 선언할
|
|
@@ -980,11 +1004,28 @@ export class FlowEngine {
|
|
|
980
1004
|
* 그래서 심을 때 맞춘다. **없는 물품을 만들어 채우지 않는다** — 채우면 계보와 재고가 거짓 위에 선다.
|
|
981
1005
|
* 대신 몇 건이 맞지 않았는지 남겨 씨앗이 불완전했다는 사실을 답에 실을 수 있게 한다.
|
|
982
1006
|
*/
|
|
1007
|
+
/**
|
|
1008
|
+
* **이 로트를 할당 후보에 넣어도 되나** — 판정은 이 한 곳이다(ADR-0080 결정 2).
|
|
1009
|
+
*
|
|
1010
|
+
* 읽는 것은 효과(`use`)뿐이고 낱말(`status`)은 읽지 않는다. 낱말을 읽으면 현장이 `quarantine` ·
|
|
1011
|
+
* `검사대기` 를 쓰는 날 **오류 없이** 보류된 로트가 할당에 든다.
|
|
1012
|
+
*
|
|
1013
|
+
* **말해 주지 않은 로트는 「모름」이다.** 지금 거동 그대로 후보에 넣되 그 수를 센다 — 멈추면 로트
|
|
1014
|
+
* 상태를 아직 안 보내는 현장의 트윈이 통째로 서고, 「모름」을 안전으로 읽으면 보류가 새는 것을
|
|
1015
|
+
* 아무도 모른다. 답에 실린 그 수가 「이 원본은 아직 상태를 안 보낸다」를 말한다.
|
|
1016
|
+
*/
|
|
1017
|
+
usableLot(item) {
|
|
1018
|
+
if (item.use === 'blocked')
|
|
1019
|
+
return false;
|
|
1020
|
+
if (item.use === undefined)
|
|
1021
|
+
this.allocatedWithUnknownLotUse++;
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
983
1024
|
resolvedAllocated(allocated) {
|
|
984
1025
|
const all = allocated ?? [];
|
|
985
1026
|
if (!all.length)
|
|
986
1027
|
return { kept: [], dropped: 0 };
|
|
987
|
-
const kept = all.filter(
|
|
1028
|
+
const kept = all.filter(a => this.items.has(a.epc));
|
|
988
1029
|
const dropped = all.length - kept.length;
|
|
989
1030
|
this.seedDanglingRefs += dropped;
|
|
990
1031
|
return { kept, dropped };
|
|
@@ -1041,7 +1082,9 @@ export class FlowEngine {
|
|
|
1041
1082
|
* 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
|
|
1042
1083
|
*/
|
|
1043
1084
|
...(it.nonconformance ? { nonconformance: it.nonconformance } : {}),
|
|
1044
|
-
...(it.status ? { status: it.status } : {})
|
|
1085
|
+
...(it.status ? { status: it.status } : {}),
|
|
1086
|
+
/* 그 상태의 효과 — 이 칸이 빠지면 미러가 보류를 알고도 할당이 모른다(ADR-0080 결정 2). */
|
|
1087
|
+
...(it.use ? { use: it.use } : {})
|
|
1045
1088
|
});
|
|
1046
1089
|
}
|
|
1047
1090
|
for (const m of snap.equipment) {
|
|
@@ -1645,11 +1688,12 @@ export class FlowEngine {
|
|
|
1645
1688
|
nowTime: this.now(), // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
|
|
1646
1689
|
identityGrounding: this.identityGroundingView(),
|
|
1647
1690
|
/* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
|
|
1648
|
-
...(this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps
|
|
1691
|
+
...(this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps || this.allocatedWithUnknownLotUse
|
|
1649
1692
|
? { conformance: {
|
|
1650
1693
|
...(this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {}),
|
|
1651
1694
|
...(this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {}),
|
|
1652
|
-
...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {})
|
|
1695
|
+
...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}),
|
|
1696
|
+
...(this.allocatedWithUnknownLotUse ? { allocatedWithUnknownLotUse: this.allocatedWithUnknownLotUse } : {})
|
|
1653
1697
|
} }
|
|
1654
1698
|
: {}),
|
|
1655
1699
|
/* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
@@ -3879,7 +3923,9 @@ export class FlowEngine {
|
|
|
3879
3923
|
...(i.ilmd ? { ilmd: i.ilmd } : {}),
|
|
3880
3924
|
/* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
|
|
3881
3925
|
...(i.nonconformance ? { nonconformance: i.nonconformance } : {}),
|
|
3882
|
-
...(i.status ? { status: i.status } : {})
|
|
3926
|
+
...(i.status ? { status: i.status } : {}),
|
|
3927
|
+
/* 그 상태의 효과 — 화면과 다음 씨앗이 읽는다(§`ItemState.use`). */
|
|
3928
|
+
...(i.use ? { use: i.use } : {})
|
|
3883
3929
|
};
|
|
3884
3930
|
}
|
|
3885
3931
|
/**
|
|
@@ -3934,7 +3980,12 @@ export class FlowEngine {
|
|
|
3934
3980
|
processOrders() {
|
|
3935
3981
|
/* **우선순위 순으로 할당한다** — 표준 `OperationsRequest.Priority`. 같은 우선순위는 입력 순서를
|
|
3936
3982
|
지켜 결정성을 잃지 않는다(정렬이 안정적이어야 같은 seed 가 같은 결과를 낸다). */
|
|
3937
|
-
|
|
3983
|
+
/*
|
|
3984
|
+
* **입고 오더는 고르기의 대상이 아니다** (2026-09-19). 그 오더가 기다리는 것은 재고가 아니라
|
|
3985
|
+
* 물건의 도착이고, 커널이 그것을 고를 수는 없다. 걸러 내지 않으면 `allocate` 가 매 틱마다 라인을
|
|
3986
|
+
* 들고 재고를 뒤지고, 못 찾아 `shortage` 를 적는다 — 「입고 대기」가 「자재 부족」으로 보인다.
|
|
3987
|
+
*/
|
|
3988
|
+
const pending = [...this.orders.values()].filter(o => o.status === 'created' && !o.held && o.kind !== WMS_ORDER_KIND.inbound);
|
|
3938
3989
|
if (pending.some(o => o.priority !== undefined))
|
|
3939
3990
|
pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
|
|
3940
3991
|
for (const o of pending)
|
package/dist/kernel.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { CMD } from '@operato/ops-contract';
|
|
9
9
|
import { firstFitPolicy } from "./allocation-policy.js";
|
|
10
|
-
import { FlowEngine } from "./flow-engine.js";
|
|
10
|
+
import { FlowEngine, allocatedQty } from "./flow-engine.js";
|
|
11
11
|
import { planMakeToOrder } from "./make-to-order.js";
|
|
12
12
|
import { BIZSTEP, BTT, WMS_ORDER_KIND } from '@operato/ops-contract';
|
|
13
13
|
import { DISP, ILMD_ATTR, aggregationEvent, objectEvent, transactionEvent } from '@operato/ops-contract';
|
|
@@ -64,7 +64,11 @@ export class WmsKernel extends FlowEngine {
|
|
|
64
64
|
/* 방출한 마스터데이터를 **상태에도 들고 있는다** — 자기가 선언한 것을 자기가 모르면, 미러는 알고
|
|
65
65
|
* 시뮬은 모르는 비대칭이 생긴다(파리티 테스트가 잡은 바로 그 종류). */
|
|
66
66
|
const ilmd = { [ILMD_ATTR.expiry]: expiry };
|
|
67
|
-
|
|
67
|
+
/*
|
|
68
|
+
* 들어온 시각을 재고가 들고 있는다 — 「먼저 들어온 것 먼저」를 할 수 있는 유일한 근거다.
|
|
69
|
+
* 값은 **receiving 이 일어난 시각**이고(수집 시각이 아니다), 여기서는 그 둘이 같다.
|
|
70
|
+
*/
|
|
71
|
+
this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd, receivedAtMs: this.nowMs() });
|
|
68
72
|
dock.occupancy++;
|
|
69
73
|
this.emit(transactionEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
|
|
70
74
|
this.emit(aggregationEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
|
|
@@ -152,17 +156,28 @@ export class WmsKernel extends FlowEngine {
|
|
|
152
156
|
const staging = this.builtInLocation('staging', 'picked pallets wait here before shipping');
|
|
153
157
|
const chosenAll = [];
|
|
154
158
|
for (const line of o.lines) {
|
|
155
|
-
const already = o.allocated.filter(
|
|
159
|
+
const already = allocatedQty(o.allocated.filter(a => this.items.get(a.epc)?.gtin === line.gtin));
|
|
156
160
|
const need = line.requested - already;
|
|
157
161
|
if (need <= 0)
|
|
158
162
|
continue;
|
|
163
|
+
/*
|
|
164
|
+
* ── 이 커널이 잡는 단위는 **팔레트 하나**다 (2026-09-19) ────────────────
|
|
165
|
+
*
|
|
166
|
+
* 요청(`line.requested`)도 팔레트 수이고, 뒤의 걸음(pick · pack · 라인 차감)이 전부 팔레트를
|
|
167
|
+
* 하나씩 움직인다. 그래서 후보 줄도 **1** 을 낸다 — 팔레트 안의 케이스 수(`i.qty`)를 내면
|
|
168
|
+
* 요청과 단위가 갈려 두 배로 잡는다.
|
|
169
|
+
*
|
|
170
|
+
* 수량으로 잡는 쪽은 MES 의 자재 확보다(밀가루 3.5kg — `mes-kernel.ts`). 시임은 두 단위를
|
|
171
|
+
* 모두 표현할 수 있고, **단위를 정하는 것은 부르는 쪽**이다.
|
|
172
|
+
*/
|
|
159
173
|
const available = [...this.items.values()]
|
|
160
174
|
.filter(i => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'storage')
|
|
161
|
-
.
|
|
175
|
+
.filter(i => this.usableLot(i))
|
|
176
|
+
.map(i => ({ epc: i.epc, location: i.location, qty: 1, expiry: i.expiry, ...(i.receivedAtMs !== undefined ? { receivedAtMs: i.receivedAtMs } : {}) }));
|
|
162
177
|
const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
|
|
163
|
-
for (const
|
|
164
|
-
o.allocated.push(
|
|
165
|
-
chosenAll.push(epc);
|
|
178
|
+
for (const row of chosen) {
|
|
179
|
+
o.allocated.push(row);
|
|
180
|
+
chosenAll.push(row.epc);
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
/*
|
|
@@ -369,7 +384,7 @@ export class WmsKernel extends FlowEngine {
|
|
|
369
384
|
order.status = 'packed';
|
|
370
385
|
this.emit(objectEvent({ eventTime, action: 'OBSERVE', bizStep: BIZSTEP.staging_outbound, disposition: DISP.reserved, epcList: [shipment], readPoint: staging.id, bizLocation: staging.id, bizTransactionList: soTxn }));
|
|
371
386
|
this.emit(objectEvent({ eventTime, action: 'DELETE', bizStep: BIZSTEP.shipping, disposition: DISP.in_transit, epcList: [shipment], readPoint: shipDock.id, bizTransactionList: soTxn }));
|
|
372
|
-
order.fulfilled += order.allocated
|
|
387
|
+
order.fulfilled += allocatedQty(order.allocated);
|
|
373
388
|
// 라인 잔량 차감(멀티SKU 백오더) — 출하 팔레트의 gtin 으로 라인 매핑(라인 SKU distinct).
|
|
374
389
|
if (order.lines)
|
|
375
390
|
for (const epc of order.picked) {
|
package/dist/mes-kernel.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { identityGroundingOf, procedureViolations } from '@operato/ops-contract';
|
|
12
12
|
import { firstFitPolicy } from "./allocation-policy.js";
|
|
13
|
-
import { FlowEngine } from "./flow-engine.js";
|
|
13
|
+
import { FlowEngine, allocatedEpcs, allocatedQty } from "./flow-engine.js";
|
|
14
14
|
import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass, bizTransactionUri } from '@operato/ops-contract';
|
|
15
15
|
import { MES_BIZSTEP, BTT_PRODORDER, sgtinUri } from '@operato/ops-contract';
|
|
16
16
|
import { OP_PARAM } from '@operato/ops-contract';
|
|
@@ -426,8 +426,9 @@ export class MesKernel extends FlowEngine {
|
|
|
426
426
|
const order = t.orderId ? this.orders.get(t.orderId) : undefined;
|
|
427
427
|
if (!order)
|
|
428
428
|
return false;
|
|
429
|
+
/* 자리가 만든 것도 오더가 들고 있는 것에 들어간다 — 직렬 개체라 줄마다 하나다. */
|
|
429
430
|
for (const epc of epcs)
|
|
430
|
-
order.allocated.push(epc);
|
|
431
|
+
order.allocated.push({ epc, qty: 1 });
|
|
431
432
|
return true;
|
|
432
433
|
}
|
|
433
434
|
// ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
|
|
@@ -818,12 +819,12 @@ export class MesKernel extends FlowEngine {
|
|
|
818
819
|
}
|
|
819
820
|
else {
|
|
820
821
|
for (const i of this.items.ofGtin(g)) {
|
|
821
|
-
if (i.disposition === DISP.sellable)
|
|
822
|
+
if (i.disposition === DISP.sellable && this.usableLot(i))
|
|
822
823
|
available.push({ epc: i.epc, location: i.location, qty: 1 });
|
|
823
824
|
}
|
|
824
825
|
}
|
|
825
826
|
const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
|
|
826
|
-
if (chosen
|
|
827
|
+
if (allocatedQty(chosen) < line.qty) {
|
|
827
828
|
/*
|
|
828
829
|
* **왜 대기하는지 남긴다** (2026-08-21).
|
|
829
830
|
*
|
|
@@ -834,7 +835,7 @@ export class MesKernel extends FlowEngine {
|
|
|
834
835
|
* 첫 줄에서 멈추지 않고 **모자란 줄을 다 센다.** 하나만 알려 주면 그것을 채운 뒤 다음 줄에서
|
|
835
836
|
* 또 막히고, 사람은 같은 진단을 여섯 번 반복한다.
|
|
836
837
|
*/
|
|
837
|
-
short.push({ material: line.material, need: line.qty, have: chosen
|
|
838
|
+
short.push({ material: line.material, need: line.qty, have: allocatedQty(chosen) });
|
|
838
839
|
continue;
|
|
839
840
|
}
|
|
840
841
|
picks.push(...chosen);
|
|
@@ -848,11 +849,11 @@ export class MesKernel extends FlowEngine {
|
|
|
848
849
|
/* 채워졌으면 표시를 지운다 — 남겨 두면 화면이 이미 해결된 것을 계속 말한다. */
|
|
849
850
|
if (o.shortage)
|
|
850
851
|
delete o.shortage;
|
|
851
|
-
for (const
|
|
852
|
-
o.allocated.push(
|
|
853
|
-
this.reserve(picks, MES_BIZSTEP.producing);
|
|
854
|
-
this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated
|
|
855
|
-
this.emitStationDef(o, ops[0], o.allocated[0]);
|
|
852
|
+
for (const row of picks)
|
|
853
|
+
o.allocated.push(row);
|
|
854
|
+
this.reserve(allocatedEpcs(picks), MES_BIZSTEP.producing);
|
|
855
|
+
this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: allocatedEpcs(o.allocated) }));
|
|
856
|
+
this.emitStationDef(o, ops[0], o.allocated[0]?.epc);
|
|
856
857
|
o.status = 'op-' + ops[0].key;
|
|
857
858
|
this.emitOrder(o);
|
|
858
859
|
}
|
|
@@ -894,13 +895,13 @@ export class MesKernel extends FlowEngine {
|
|
|
894
895
|
const producedKey = this.producedMaterialKeyOf(t.kind);
|
|
895
896
|
const next = ops[i + 1];
|
|
896
897
|
if (producedKey) {
|
|
897
|
-
const inputs = order.allocated
|
|
898
|
+
const inputs = allocatedEpcs(order.allocated);
|
|
898
899
|
const made = this.serialOf(producedKey, ++this.wipSeq);
|
|
899
900
|
this.transform(inputs, [{ epc: made, gtin: this.classOf(producedKey), qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
|
|
900
|
-
order.allocated = [made];
|
|
901
|
+
order.allocated = [{ epc: made, qty: 1 }];
|
|
901
902
|
}
|
|
902
903
|
/* 다음 자리에 무엇을 들고 가는가 — 만든 것이 있으면 그것, 없으면 들고 있던 것. */
|
|
903
|
-
const carried = order.allocated[0];
|
|
904
|
+
const carried = order.allocated[0]?.epc;
|
|
904
905
|
/*
|
|
905
906
|
* ── 개체가 없어도 **진행은 막지 않는다** (2026-08-24) ───────────────────────
|
|
906
907
|
* 예전에는 여기서 무조건 던졌다. 그래서 **미러에서 예측이 구조적으로 실패했다**: 미러의 오더는
|
|
@@ -962,7 +963,7 @@ export class MesKernel extends FlowEngine {
|
|
|
962
963
|
*
|
|
963
964
|
* 중간 산출물을 선언한 경로에서는 `allocated` 가 이미 `[made]` 하나이므로 거동이 같다.
|
|
964
965
|
*/
|
|
965
|
-
const consumed = order.allocated
|
|
966
|
+
const consumed = allocatedEpcs(order.allocated);
|
|
966
967
|
if (!consumed.length) {
|
|
967
968
|
/*
|
|
968
969
|
* 씨앗이 이 오더의 확보분을 다 심지 못했으면(원본이 말한 물품이 스냅샷에 없었다) 이 오더는
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift, DispositionFact } from '@operato/ops-contract';
|
|
2
|
+
import type { MaterialLotUse } from '@operato/ops-contract';
|
|
2
3
|
import { type VocabularyElement } from '@operato/ops-contract';
|
|
3
4
|
interface ProjItem {
|
|
4
5
|
epc: string;
|
|
@@ -14,6 +15,8 @@ interface ProjItem {
|
|
|
14
15
|
nonconformance?: DispositionFact;
|
|
15
16
|
/** ISA-95 `MaterialLot.Status` — `nonconformance` 와 다른 칸(§계약 `ItemState.status`). 마지막 하나만. */
|
|
16
17
|
status?: string;
|
|
18
|
+
/** 그 상태의 효과 — 이 로트를 쓸 수 있나(계약 `MATERIAL_LOT_USE`). 없으면 「모름」이다. */
|
|
19
|
+
use?: MaterialLotUse;
|
|
17
20
|
parent?: string;
|
|
18
21
|
qty?: number;
|
|
19
22
|
uom?: string;
|
package/dist/observed-reducer.js
CHANGED
|
@@ -583,8 +583,20 @@ export class ObservedReducer {
|
|
|
583
583
|
this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
|
|
584
584
|
break;
|
|
585
585
|
}
|
|
586
|
-
|
|
586
|
+
/*
|
|
587
|
+
* 낱말과 **효과**를 함께 앉힌다(ADR-0080 결정 2). 낱말은 사람이 읽고, 효과(`use`)는 할당이 읽는다.
|
|
588
|
+
*
|
|
589
|
+
* 레코드가 효과를 말하지 않으면 **비운다.** 앞의 값을 남기면 원본이 상태를 바꿨는데 효과는 옛
|
|
590
|
+
* 결정으로 남는다 — 보류가 풀렸는데 계속 막히거나, 그 반대가 된다. 비어 있는 것은 「모름」이고
|
|
591
|
+
* 할당이 그것을 세어 답에 싣는다.
|
|
592
|
+
*/
|
|
593
|
+
for (const part of parts) {
|
|
587
594
|
part.status = d.status;
|
|
595
|
+
if (d.use)
|
|
596
|
+
part.use = d.use;
|
|
597
|
+
else
|
|
598
|
+
delete part.use;
|
|
599
|
+
}
|
|
588
600
|
break;
|
|
589
601
|
}
|
|
590
602
|
case OP_EVENT.observation: {
|
package/dist/yms-kernel.js
CHANGED
|
@@ -77,7 +77,7 @@ export class YmsKernel extends FlowEngine {
|
|
|
77
77
|
const id = `order-${++this.orderSeq}`;
|
|
78
78
|
const apSeq = ++this.soSeq;
|
|
79
79
|
const appt = this.requireBizTransactionId(`APPT-${apSeq}`, 'appointment');
|
|
80
|
-
const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [epc], picked: [], dockDoor: door.id,
|
|
80
|
+
const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [{ epc, qty: 1 }], picked: [], dockDoor: door.id,
|
|
81
81
|
/* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
|
|
82
82
|
windowStartMs: this.clockMs + WINDOW_DELAY_MS,
|
|
83
83
|
/* 표준 필드로도 함께 낸다 — 소비처(화면·집계·미러)는 표준 축으로 읽는다.
|
|
@@ -105,7 +105,8 @@ export class YmsKernel extends FlowEngine {
|
|
|
105
105
|
* 하나라도 안 되면 대기(다음 tick 재시도). = 시간창 예약 스케줄링.
|
|
106
106
|
*/
|
|
107
107
|
allocate(o) {
|
|
108
|
-
|
|
108
|
+
/* 어포인트먼트가 든 것은 트레일러 하나다 — 줄마다 하나이므로 첫 줄의 열쇠를 본다. */
|
|
109
|
+
const trailer = this.items.get(o.allocated[0]?.epc);
|
|
109
110
|
if (!trailer)
|
|
110
111
|
return;
|
|
111
112
|
if (this.clockMs < (o.windowStartMs ?? 0))
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.ts
|
|
20
20
|
var index_exports = {};
|
|
21
21
|
__export(index_exports, {
|
|
22
|
+
ALLOCATION_POLICIES: () => ALLOCATION_POLICIES,
|
|
22
23
|
ATTENTION_DEFAULTS: () => ATTENTION_DEFAULTS,
|
|
23
24
|
ATTENTION_PROPERTY: () => ATTENTION_PROPERTY,
|
|
24
25
|
ClassMasterTable: () => ClassMasterTable,
|
|
@@ -36,6 +37,9 @@ __export(index_exports, {
|
|
|
36
37
|
TwinRuntime: () => TwinRuntime,
|
|
37
38
|
WmsKernel: () => WmsKernel,
|
|
38
39
|
YmsKernel: () => YmsKernel,
|
|
40
|
+
allocatedEpcs: () => allocatedEpcs,
|
|
41
|
+
allocatedQty: () => allocatedQty,
|
|
42
|
+
allocationPolicyOf: () => allocationPolicyOf,
|
|
39
43
|
attributeEnergy: () => attributeEnergy,
|
|
40
44
|
compareStates: () => compareStates,
|
|
41
45
|
constantDuration: () => constantDuration,
|
|
@@ -247,6 +251,7 @@ function locationStatusOf(n) {
|
|
|
247
251
|
const r = (n.occupancy ?? 0) / cap;
|
|
248
252
|
return r >= 1 ? "full" : r >= LOCATION_SATURATION_NEAR ? "near-full" : "available";
|
|
249
253
|
}
|
|
254
|
+
var MATERIAL_LOT_USE = ["allowed", "blocked"];
|
|
250
255
|
var DISPOSITION_DECISION = [
|
|
251
256
|
/** 원 규격으로 되돌린다 — 공정에 다시 들어가고, 산출물은 원 품목이다. */
|
|
252
257
|
"rework",
|
|
@@ -660,15 +665,20 @@ var OP_EVENT = {
|
|
|
660
665
|
* 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
|
|
661
666
|
* 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
|
|
662
667
|
*
|
|
663
|
-
* ── 낱말은 열려
|
|
664
|
-
*
|
|
668
|
+
* ── 낱말은 열려 있고, **효과는 닫혀 있다** (2026-09-19, ADR-0080 결정 2) ────
|
|
669
|
+
* `status` 는 도메인이 소유한다 — 오더 상태와 같은 규율이다. 커널은 그 낱말로 계산하지 않는다.
|
|
670
|
+
*
|
|
671
|
+
* 계산하는 것은 **`use`** 다(§`MATERIAL_LOT_USE`): 닫힌 둘이고, 「이 로트를 쓸 수 있나」만 말한다.
|
|
672
|
+
* 낱말을 읽어 효과를 정하면 현장이 `quarantine` · `검사대기` 를 쓰는 날 조용히 새기 때문이다 —
|
|
673
|
+
* 자원 속성에서 이미 선 원칙 그대로다(속성은 열리고 효과는 닫힌다). 둘은 같은 레코드에 함께 오고,
|
|
674
|
+
* 그 낱말의 주인이 짝을 정한다.
|
|
665
675
|
*
|
|
666
676
|
* ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
|
|
667
677
|
* 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
|
|
668
678
|
* 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
|
|
669
679
|
*/
|
|
670
680
|
materialLot: "material-lot.status",
|
|
671
|
-
// 레코드: { lotId(표준 MaterialLotID = ItemState.epc 의 값), status, decidedBy?, reason?, decidedAt? }
|
|
681
|
+
// 레코드: { lotId(표준 MaterialLotID = ItemState.epc 의 값), status, use?, decidedBy?, reason?, decidedAt? }
|
|
672
682
|
/**
|
|
673
683
|
* **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
|
|
674
684
|
*
|
|
@@ -814,6 +824,14 @@ var ENERGY_EVENT = {
|
|
|
814
824
|
drSuggested: "energy.dr.suggested"
|
|
815
825
|
};
|
|
816
826
|
var MATERIAL_ACTUAL_USE = ["consumed", "produced"];
|
|
827
|
+
var ALLOCATION_POLICY = {
|
|
828
|
+
/** 가장 작은 식별자부터. FIFO 가 아니다 — 입고 시각을 보지 않는다. */
|
|
829
|
+
firstFit: "firstFit",
|
|
830
|
+
/** 모자라도 있는 만큼 잡는다. */
|
|
831
|
+
partialFit: "partialFit",
|
|
832
|
+
/** 먼저 만료되는 것부터(First Expired, First Out). */
|
|
833
|
+
fefo: "fefo"
|
|
834
|
+
};
|
|
817
835
|
var CMD = {
|
|
818
836
|
orderHold: "order.hold",
|
|
819
837
|
orderResume: "order.resume",
|
|
@@ -1007,7 +1025,12 @@ var BTT = {
|
|
|
1007
1025
|
bol: "urn:epcglobal:cbv:btt:bol"
|
|
1008
1026
|
};
|
|
1009
1027
|
var WMS_ORDER_KIND = {
|
|
1010
|
-
/**
|
|
1028
|
+
/**
|
|
1029
|
+
* Expected receipt (ASN) — what was promised to arrive, and how much of it has.
|
|
1030
|
+
*
|
|
1031
|
+
* Flow metrics leave it out: the arrival both raises and fulfils it, so counting it would add a
|
|
1032
|
+
* completed order per arrival and never clear the backlog (`isFlowOrder`).
|
|
1033
|
+
*/
|
|
1011
1034
|
inbound: "inbound",
|
|
1012
1035
|
/** Outbound order — what the kernel's sales order is. */
|
|
1013
1036
|
outbound: "outbound",
|
|
@@ -1016,6 +1039,7 @@ var WMS_ORDER_KIND = {
|
|
|
1016
1039
|
/** Stock transfer between sites. CBV has no document type for it: carry the id without a type. */
|
|
1017
1040
|
transfer: "transfer"
|
|
1018
1041
|
};
|
|
1042
|
+
var WMS_RECEIVING_ORDER_KINDS = [WMS_ORDER_KIND.inbound, WMS_ORDER_KIND.return];
|
|
1019
1043
|
var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship", "vas-station"];
|
|
1020
1044
|
var WMS_LOCATION_LEVEL = {
|
|
1021
1045
|
dock: "StorageZone",
|
|
@@ -1939,7 +1963,8 @@ var SPECS = {
|
|
|
1939
1963
|
priority: "number",
|
|
1940
1964
|
startTime: "string",
|
|
1941
1965
|
endTime: "string",
|
|
1942
|
-
|
|
1966
|
+
/* 확보분은 `{ epc, qty }` 줄이다(2026-09-19, ADR-0076 보탬) — 옛 EPC 목록은 받지 않는다. */
|
|
1967
|
+
allocated: "object[]",
|
|
1943
1968
|
bizTransaction: "string",
|
|
1944
1969
|
operationsRequestId: "string",
|
|
1945
1970
|
dockDoor: "string",
|
|
@@ -1962,6 +1987,17 @@ var SPECS = {
|
|
|
1962
1987
|
* 새 어휘가 아니다 — 계약이 `recipeKey` 를 네 자리에서 이미 쓴다. 오더 관측에만 자리가 없었다.
|
|
1963
1988
|
*/
|
|
1964
1989
|
recipeKey: "string"
|
|
1990
|
+
},
|
|
1991
|
+
/*
|
|
1992
|
+
* 확보분의 안쪽을 본다 — 열쇠와 수 둘이다(§`AllocatedQuantity`). 안 보면 틀린 이름이 조용히
|
|
1993
|
+
* 지나가고(`epcs`·`quantity` 따위), 미러는 잡힌 수를 0 으로 읽는다.
|
|
1994
|
+
*/
|
|
1995
|
+
shapes: {
|
|
1996
|
+
allocated: {
|
|
1997
|
+
required: ["epc", "qty"],
|
|
1998
|
+
fields: { epc: "string", qty: "number" },
|
|
1999
|
+
positive: ["qty"]
|
|
2000
|
+
}
|
|
1965
2001
|
}
|
|
1966
2002
|
/* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
|
|
1967
2003
|
},
|
|
@@ -2084,6 +2120,17 @@ var SPECS = {
|
|
|
2084
2120
|
* (§`operationalKindOf`). 값은 로트의 식별자(`ItemState.epc` 와 같은 값)다. 상태 낱말은 열려 있어 enum 이
|
|
2085
2121
|
* 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 부분(`subLotId`)은 받지 않는다 — 부분마다 다른 상태가
|
|
2086
2122
|
* 필요해지면 그것은 다른 사실이다.
|
|
2123
|
+
*
|
|
2124
|
+
* ── 낱말은 열리고 **효과는 닫힌다** (2026-09-19, ADR-0080 결정 2) ────────────
|
|
2125
|
+
* `status` 는 현장의 낱말이다(`on-hold` · `quarantine` · `검사대기` — 도메인이 소유한다). 커널이 그
|
|
2126
|
+
* 낱말을 읽어 「보류면 할당에서 뺀다」를 정하면, 현장이 다른 낱말을 쓰는 날 조용히 샌다.
|
|
2127
|
+
*
|
|
2128
|
+
* 그래서 **효과를 따로 싣는다**: `use` 는 닫힌 둘(`allowed` · `blocked`)이고 기계가 읽는다. 낱말의
|
|
2129
|
+
* 주인이 둘을 같이 정한다 — 자원 속성에서 이미 선 원칙 그대로다(속성은 열리고 효과는 닫힌다).
|
|
2130
|
+
*
|
|
2131
|
+
* **`use` 가 없는 레코드는 `allowed` 가 아니라 「모름」이다.** 그래서 필수로 만들지 않는다: 이 문을
|
|
2132
|
+
* 이미 쓰고 있는 원본이 있으면 그 사실이 통째로 거절되는 것이 더 나쁘다. 모르는 것을 할당이 어떻게
|
|
2133
|
+
* 다루는지는 커널이 한 곳에서 정하고 그 수를 센다.
|
|
2087
2134
|
*/
|
|
2088
2135
|
"material-lot": {
|
|
2089
2136
|
eventType: OP_EVENT.materialLot,
|
|
@@ -2091,7 +2138,8 @@ var SPECS = {
|
|
|
2091
2138
|
matchOrder: 85,
|
|
2092
2139
|
identity: "lotId",
|
|
2093
2140
|
required: ["lotId", "status"],
|
|
2094
|
-
fields: { lotId: "string", status: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" }
|
|
2141
|
+
fields: { lotId: "string", status: "string", use: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" },
|
|
2142
|
+
enums: { use: MATERIAL_LOT_USE }
|
|
2095
2143
|
},
|
|
2096
2144
|
test: {
|
|
2097
2145
|
eventType: OP_EVENT.test,
|
|
@@ -2944,7 +2992,11 @@ var ObservedReducer = class {
|
|
|
2944
2992
|
this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
|
|
2945
2993
|
break;
|
|
2946
2994
|
}
|
|
2947
|
-
for (const part of parts)
|
|
2995
|
+
for (const part of parts) {
|
|
2996
|
+
part.status = d.status;
|
|
2997
|
+
if (d.use) part.use = d.use;
|
|
2998
|
+
else delete part.use;
|
|
2999
|
+
}
|
|
2948
3000
|
break;
|
|
2949
3001
|
}
|
|
2950
3002
|
case OP_EVENT.observation: {
|
|
@@ -3542,41 +3594,76 @@ function freeBinsFirstFit(slots) {
|
|
|
3542
3594
|
}
|
|
3543
3595
|
return best?.id ?? null;
|
|
3544
3596
|
}
|
|
3545
|
-
function
|
|
3546
|
-
|
|
3597
|
+
function qtyOf(s) {
|
|
3598
|
+
const q = s?.qty;
|
|
3599
|
+
return typeof q === "number" && q > 0 && q < Infinity ? q : 0;
|
|
3600
|
+
}
|
|
3601
|
+
function smallestByEpc(available, need) {
|
|
3602
|
+
if (need <= 0) return { picked: [], total: 0 };
|
|
3547
3603
|
const out = [];
|
|
3604
|
+
let held = 0;
|
|
3548
3605
|
let total = 0;
|
|
3549
3606
|
for (const s of available) {
|
|
3550
|
-
|
|
3551
|
-
|
|
3607
|
+
const q = qtyOf(s);
|
|
3608
|
+
total += q;
|
|
3609
|
+
if (q === 0) continue;
|
|
3610
|
+
if (held >= need && byId(s.epc, out[out.length - 1].epc) >= 0) continue;
|
|
3552
3611
|
let i = out.length;
|
|
3553
3612
|
while (i > 0 && byId(out[i - 1].epc, s.epc) > 0) i--;
|
|
3554
3613
|
out.splice(i, 0, s);
|
|
3555
|
-
|
|
3614
|
+
held += q;
|
|
3615
|
+
while (out.length > 0 && held - qtyOf(out[out.length - 1]) >= need) held -= qtyOf(out.pop());
|
|
3556
3616
|
}
|
|
3557
3617
|
return { picked: out, total };
|
|
3558
3618
|
}
|
|
3619
|
+
function takeFrom(views, need) {
|
|
3620
|
+
const out = [];
|
|
3621
|
+
let left = need;
|
|
3622
|
+
for (const s of views) {
|
|
3623
|
+
if (left <= 0) break;
|
|
3624
|
+
const take = Math.min(left, qtyOf(s));
|
|
3625
|
+
if (take <= 0) continue;
|
|
3626
|
+
out.push({ epc: s.epc, qty: take });
|
|
3627
|
+
left -= take;
|
|
3628
|
+
}
|
|
3629
|
+
return out;
|
|
3630
|
+
}
|
|
3559
3631
|
var firstFitPolicy = {
|
|
3560
3632
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
3561
3633
|
selectStock: ({ qty, available }) => {
|
|
3562
3634
|
const { picked, total } = smallestByEpc(available, qty);
|
|
3563
3635
|
if (total < qty) return [];
|
|
3564
|
-
return picked
|
|
3636
|
+
return takeFrom(picked, qty);
|
|
3565
3637
|
}
|
|
3566
3638
|
};
|
|
3567
3639
|
var partialFitPolicy = {
|
|
3568
3640
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
3569
|
-
selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked
|
|
3641
|
+
selectStock: ({ qty, available }) => takeFrom(smallestByEpc(available, qty).picked, qty)
|
|
3570
3642
|
};
|
|
3571
3643
|
var fefoPolicy = {
|
|
3572
3644
|
selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
|
|
3573
3645
|
selectStock: ({ qty, available }) => {
|
|
3574
3646
|
const all = [...available];
|
|
3575
|
-
|
|
3647
|
+
const total = all.reduce((sum, s) => sum + qtyOf(s), 0);
|
|
3648
|
+
if (total < qty) return [];
|
|
3576
3649
|
const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
|
|
3577
|
-
return byExpiry
|
|
3650
|
+
return takeFrom(byExpiry, qty);
|
|
3578
3651
|
}
|
|
3579
3652
|
};
|
|
3653
|
+
var ALLOCATION_POLICIES = {
|
|
3654
|
+
[ALLOCATION_POLICY.firstFit]: firstFitPolicy,
|
|
3655
|
+
[ALLOCATION_POLICY.partialFit]: partialFitPolicy,
|
|
3656
|
+
[ALLOCATION_POLICY.fefo]: fefoPolicy
|
|
3657
|
+
};
|
|
3658
|
+
function allocationPolicyOf(name) {
|
|
3659
|
+
const key = String(name ?? "").trim();
|
|
3660
|
+
if (!key) return firstFitPolicy;
|
|
3661
|
+
const found = ALLOCATION_POLICIES[key];
|
|
3662
|
+
if (!found) {
|
|
3663
|
+
throw new Error(`unknown allocation policy "${key}" \u2014 declare one of: ${Object.keys(ALLOCATION_POLICIES).join(" \xB7 ")}`);
|
|
3664
|
+
}
|
|
3665
|
+
return found;
|
|
3666
|
+
}
|
|
3580
3667
|
|
|
3581
3668
|
// src/flow-engine.ts
|
|
3582
3669
|
var BASE_EPOCH = Date.parse("2026-01-01T00:00:00Z");
|
|
@@ -3586,6 +3673,12 @@ function effectiveOnly(r) {
|
|
|
3586
3673
|
...r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}
|
|
3587
3674
|
};
|
|
3588
3675
|
}
|
|
3676
|
+
function allocatedQty(rows) {
|
|
3677
|
+
return (rows ?? []).reduce((sum, a) => sum + (Number(a?.qty) || 0), 0);
|
|
3678
|
+
}
|
|
3679
|
+
function allocatedEpcs(rows) {
|
|
3680
|
+
return (rows ?? []).map((a) => a.epc);
|
|
3681
|
+
}
|
|
3589
3682
|
function mulberry32(seed) {
|
|
3590
3683
|
let a = seed >>> 0;
|
|
3591
3684
|
const fn = () => {
|
|
@@ -4128,6 +4221,8 @@ var FlowEngine = class {
|
|
|
4128
4221
|
* 부족한 씨앗 위에서 낸 답을 완전한 답으로 읽는다.
|
|
4129
4222
|
*/
|
|
4130
4223
|
seedDanglingRefs = 0;
|
|
4224
|
+
/** 로트 상태를 모르는 채로 할당 후보에 든 수 — 「모름」을 안전으로 읽지 않기 위한 셈(ADR-0080 결정 2). */
|
|
4225
|
+
allocatedWithUnknownLotUse = 0;
|
|
4131
4226
|
transformInputsAbsent = 0;
|
|
4132
4227
|
/**
|
|
4133
4228
|
* 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
|
|
@@ -4169,6 +4264,7 @@ var FlowEngine = class {
|
|
|
4169
4264
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
4170
4265
|
loadTwinModel(def) {
|
|
4171
4266
|
this.boardDef = def;
|
|
4267
|
+
if (def.allocationPolicy) this.policy = allocationPolicyOf(def.allocationPolicy);
|
|
4172
4268
|
if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
|
|
4173
4269
|
for (const n of readBoardLocations(def)) this.locations.set(n.id, this.buildLocation(n));
|
|
4174
4270
|
this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
|
|
@@ -4353,10 +4449,25 @@ var FlowEngine = class {
|
|
|
4353
4449
|
* 그래서 심을 때 맞춘다. **없는 물품을 만들어 채우지 않는다** — 채우면 계보와 재고가 거짓 위에 선다.
|
|
4354
4450
|
* 대신 몇 건이 맞지 않았는지 남겨 씨앗이 불완전했다는 사실을 답에 실을 수 있게 한다.
|
|
4355
4451
|
*/
|
|
4452
|
+
/**
|
|
4453
|
+
* **이 로트를 할당 후보에 넣어도 되나** — 판정은 이 한 곳이다(ADR-0080 결정 2).
|
|
4454
|
+
*
|
|
4455
|
+
* 읽는 것은 효과(`use`)뿐이고 낱말(`status`)은 읽지 않는다. 낱말을 읽으면 현장이 `quarantine` ·
|
|
4456
|
+
* `검사대기` 를 쓰는 날 **오류 없이** 보류된 로트가 할당에 든다.
|
|
4457
|
+
*
|
|
4458
|
+
* **말해 주지 않은 로트는 「모름」이다.** 지금 거동 그대로 후보에 넣되 그 수를 센다 — 멈추면 로트
|
|
4459
|
+
* 상태를 아직 안 보내는 현장의 트윈이 통째로 서고, 「모름」을 안전으로 읽으면 보류가 새는 것을
|
|
4460
|
+
* 아무도 모른다. 답에 실린 그 수가 「이 원본은 아직 상태를 안 보낸다」를 말한다.
|
|
4461
|
+
*/
|
|
4462
|
+
usableLot(item) {
|
|
4463
|
+
if (item.use === "blocked") return false;
|
|
4464
|
+
if (item.use === void 0) this.allocatedWithUnknownLotUse++;
|
|
4465
|
+
return true;
|
|
4466
|
+
}
|
|
4356
4467
|
resolvedAllocated(allocated) {
|
|
4357
4468
|
const all = allocated ?? [];
|
|
4358
4469
|
if (!all.length) return { kept: [], dropped: 0 };
|
|
4359
|
-
const kept = all.filter((
|
|
4470
|
+
const kept = all.filter((a) => this.items.has(a.epc));
|
|
4360
4471
|
const dropped = all.length - kept.length;
|
|
4361
4472
|
this.seedDanglingRefs += dropped;
|
|
4362
4473
|
return { kept, dropped };
|
|
@@ -4390,7 +4501,9 @@ var FlowEngine = class {
|
|
|
4390
4501
|
* 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
|
|
4391
4502
|
*/
|
|
4392
4503
|
...it.nonconformance ? { nonconformance: it.nonconformance } : {},
|
|
4393
|
-
...it.status ? { status: it.status } : {}
|
|
4504
|
+
...it.status ? { status: it.status } : {},
|
|
4505
|
+
/* 그 상태의 효과 — 이 칸이 빠지면 미러가 보류를 알고도 할당이 모른다(ADR-0080 결정 2). */
|
|
4506
|
+
...it.use ? { use: it.use } : {}
|
|
4394
4507
|
});
|
|
4395
4508
|
}
|
|
4396
4509
|
for (const m of snap.equipment) {
|
|
@@ -4864,10 +4977,11 @@ var FlowEngine = class {
|
|
|
4864
4977
|
// 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
|
|
4865
4978
|
identityGrounding: this.identityGroundingView(),
|
|
4866
4979
|
/* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
|
|
4867
|
-
...this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps ? { conformance: {
|
|
4980
|
+
...this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps || this.allocatedWithUnknownLotUse ? { conformance: {
|
|
4868
4981
|
...this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {},
|
|
4869
4982
|
...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {},
|
|
4870
|
-
...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}
|
|
4983
|
+
...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {},
|
|
4984
|
+
...this.allocatedWithUnknownLotUse ? { allocatedWithUnknownLotUse: this.allocatedWithUnknownLotUse } : {}
|
|
4871
4985
|
} } : {},
|
|
4872
4986
|
/* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
4873
4987
|
* 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
|
|
@@ -6800,7 +6914,9 @@ var FlowEngine = class {
|
|
|
6800
6914
|
...i.ilmd ? { ilmd: i.ilmd } : {},
|
|
6801
6915
|
/* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
|
|
6802
6916
|
...i.nonconformance ? { nonconformance: i.nonconformance } : {},
|
|
6803
|
-
...i.status ? { status: i.status } : {}
|
|
6917
|
+
...i.status ? { status: i.status } : {},
|
|
6918
|
+
/* 그 상태의 효과 — 화면과 다음 씨앗이 읽는다(§`ItemState.use`). */
|
|
6919
|
+
...i.use ? { use: i.use } : {}
|
|
6804
6920
|
};
|
|
6805
6921
|
}
|
|
6806
6922
|
/**
|
|
@@ -6845,7 +6961,7 @@ var FlowEngine = class {
|
|
|
6845
6961
|
}
|
|
6846
6962
|
}
|
|
6847
6963
|
processOrders() {
|
|
6848
|
-
const pending = [...this.orders.values()].filter((o) => o.status === "created" && !o.held);
|
|
6964
|
+
const pending = [...this.orders.values()].filter((o) => o.status === "created" && !o.held && o.kind !== WMS_ORDER_KIND.inbound);
|
|
6849
6965
|
if (pending.some((o) => o.priority !== void 0)) pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
|
|
6850
6966
|
for (const o of pending) this.allocate(o);
|
|
6851
6967
|
}
|
|
@@ -7079,7 +7195,7 @@ var WmsKernel = class extends FlowEngine {
|
|
|
7079
7195
|
const poTxn = [{ type: BTT.po, bizTransaction: po }];
|
|
7080
7196
|
const expiry = this.clockMs + SHELF_MS - this.epcSeq % 5 * SHELF_JITTER_MS;
|
|
7081
7197
|
const ilmd = { [ILMD_ATTR.expiry]: expiry };
|
|
7082
|
-
this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
|
|
7198
|
+
this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd, receivedAtMs: this.nowMs() });
|
|
7083
7199
|
dock.occupancy++;
|
|
7084
7200
|
this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
|
|
7085
7201
|
this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
|
|
@@ -7173,14 +7289,14 @@ var WmsKernel = class extends FlowEngine {
|
|
|
7173
7289
|
const staging = this.builtInLocation("staging", "picked pallets wait here before shipping");
|
|
7174
7290
|
const chosenAll = [];
|
|
7175
7291
|
for (const line of o.lines) {
|
|
7176
|
-
const already = o.allocated.filter((
|
|
7292
|
+
const already = allocatedQty(o.allocated.filter((a) => this.items.get(a.epc)?.gtin === line.gtin));
|
|
7177
7293
|
const need = line.requested - already;
|
|
7178
7294
|
if (need <= 0) continue;
|
|
7179
|
-
const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "storage").map((i) => ({ epc: i.epc, location: i.location, qty:
|
|
7295
|
+
const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "storage").filter((i) => this.usableLot(i)).map((i) => ({ epc: i.epc, location: i.location, qty: 1, expiry: i.expiry, ...i.receivedAtMs !== void 0 ? { receivedAtMs: i.receivedAtMs } : {} }));
|
|
7180
7296
|
const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
|
|
7181
|
-
for (const
|
|
7182
|
-
o.allocated.push(
|
|
7183
|
-
chosenAll.push(epc);
|
|
7297
|
+
for (const row of chosen) {
|
|
7298
|
+
o.allocated.push(row);
|
|
7299
|
+
chosenAll.push(row.epc);
|
|
7184
7300
|
}
|
|
7185
7301
|
}
|
|
7186
7302
|
if (chosenAll.length === 0) return this.makeShortLines(o);
|
|
@@ -7341,7 +7457,7 @@ var WmsKernel = class extends FlowEngine {
|
|
|
7341
7457
|
order.status = "packed";
|
|
7342
7458
|
this.emit(objectEvent({ eventTime, action: "OBSERVE", bizStep: BIZSTEP.staging_outbound, disposition: DISP.reserved, epcList: [shipment], readPoint: staging.id, bizLocation: staging.id, bizTransactionList: soTxn }));
|
|
7343
7459
|
this.emit(objectEvent({ eventTime, action: "DELETE", bizStep: BIZSTEP.shipping, disposition: DISP.in_transit, epcList: [shipment], readPoint: shipDock.id, bizTransactionList: soTxn }));
|
|
7344
|
-
order.fulfilled += order.allocated
|
|
7460
|
+
order.fulfilled += allocatedQty(order.allocated);
|
|
7345
7461
|
if (order.lines) for (const epc of order.picked) {
|
|
7346
7462
|
const g = this.items.get(epc)?.gtin;
|
|
7347
7463
|
const line = order.lines.find((l) => l.gtin === g && l.requested > 0);
|
|
@@ -7420,7 +7536,7 @@ var YmsKernel = class extends FlowEngine {
|
|
|
7420
7536
|
requested: 1,
|
|
7421
7537
|
fulfilled: 0,
|
|
7422
7538
|
bizTransaction: appt,
|
|
7423
|
-
allocated: [epc],
|
|
7539
|
+
allocated: [{ epc, qty: 1 }],
|
|
7424
7540
|
picked: [],
|
|
7425
7541
|
dockDoor: door.id,
|
|
7426
7542
|
/* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
|
|
@@ -7449,7 +7565,7 @@ var YmsKernel = class extends FlowEngine {
|
|
|
7449
7565
|
* 하나라도 안 되면 대기(다음 tick 재시도). = 시간창 예약 스케줄링.
|
|
7450
7566
|
*/
|
|
7451
7567
|
allocate(o) {
|
|
7452
|
-
const trailer = this.items.get(o.allocated[0]);
|
|
7568
|
+
const trailer = this.items.get(o.allocated[0]?.epc);
|
|
7453
7569
|
if (!trailer) return;
|
|
7454
7570
|
if (this.clockMs < (o.windowStartMs ?? 0)) return;
|
|
7455
7571
|
const door = o.dockDoor ? this.locations.get(o.dockDoor) : void 0;
|
|
@@ -7877,7 +7993,7 @@ The steps are the source of truth for execution order; the hierarchy only names
|
|
|
7877
7993
|
if (!this.producesOwnOutputs(t.kind)) return false;
|
|
7878
7994
|
const order = t.orderId ? this.orders.get(t.orderId) : void 0;
|
|
7879
7995
|
if (!order) return false;
|
|
7880
|
-
for (const epc of epcs) order.allocated.push(epc);
|
|
7996
|
+
for (const epc of epcs) order.allocated.push({ epc, qty: 1 });
|
|
7881
7997
|
return true;
|
|
7882
7998
|
}
|
|
7883
7999
|
// ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
|
|
@@ -8182,12 +8298,12 @@ The steps are the source of truth for execution order; the hierarchy only names
|
|
|
8182
8298
|
}
|
|
8183
8299
|
} else {
|
|
8184
8300
|
for (const i of this.items.ofGtin(g)) {
|
|
8185
|
-
if (i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
|
|
8301
|
+
if (i.disposition === DISP.sellable && this.usableLot(i)) available.push({ epc: i.epc, location: i.location, qty: 1 });
|
|
8186
8302
|
}
|
|
8187
8303
|
}
|
|
8188
8304
|
const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
|
|
8189
|
-
if (chosen
|
|
8190
|
-
short.push({ material: line.material, need: line.qty, have: chosen
|
|
8305
|
+
if (allocatedQty(chosen) < line.qty) {
|
|
8306
|
+
short.push({ material: line.material, need: line.qty, have: allocatedQty(chosen) });
|
|
8191
8307
|
continue;
|
|
8192
8308
|
}
|
|
8193
8309
|
picks.push(...chosen);
|
|
@@ -8198,10 +8314,10 @@ The steps are the source of truth for execution order; the hierarchy only names
|
|
|
8198
8314
|
return;
|
|
8199
8315
|
}
|
|
8200
8316
|
if (o.shortage) delete o.shortage;
|
|
8201
|
-
for (const
|
|
8202
|
-
this.reserve(picks, MES_BIZSTEP.producing);
|
|
8203
|
-
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated
|
|
8204
|
-
this.emitStationDef(o, ops[0], o.allocated[0]);
|
|
8317
|
+
for (const row of picks) o.allocated.push(row);
|
|
8318
|
+
this.reserve(allocatedEpcs(picks), MES_BIZSTEP.producing);
|
|
8319
|
+
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: allocatedEpcs(o.allocated) }));
|
|
8320
|
+
this.emitStationDef(o, ops[0], o.allocated[0]?.epc);
|
|
8205
8321
|
o.status = "op-" + ops[0].key;
|
|
8206
8322
|
this.emitOrder(o);
|
|
8207
8323
|
}
|
|
@@ -8232,12 +8348,12 @@ The steps are the source of truth for execution order; the hierarchy only names
|
|
|
8232
8348
|
const producedKey = this.producedMaterialKeyOf(t.kind);
|
|
8233
8349
|
const next = ops[i + 1];
|
|
8234
8350
|
if (producedKey) {
|
|
8235
|
-
const inputs = order.allocated
|
|
8351
|
+
const inputs = allocatedEpcs(order.allocated);
|
|
8236
8352
|
const made = this.serialOf(producedKey, ++this.wipSeq);
|
|
8237
8353
|
this.transform(inputs, [{ epc: made, gtin: this.classOf(producedKey), qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
|
|
8238
|
-
order.allocated = [made];
|
|
8354
|
+
order.allocated = [{ epc: made, qty: 1 }];
|
|
8239
8355
|
}
|
|
8240
|
-
const carried = order.allocated[0];
|
|
8356
|
+
const carried = order.allocated[0]?.epc;
|
|
8241
8357
|
if (!carried && !this.observationDriven) {
|
|
8242
8358
|
throw new Error(
|
|
8243
8359
|
`order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' \u2014 the order holds no allocated material and this step declares no produced material. Declare the step's input or its output in the model.`
|
|
@@ -8251,7 +8367,7 @@ The steps are the source of truth for execution order; the hierarchy only names
|
|
|
8251
8367
|
}
|
|
8252
8368
|
const declaredFg = this.declaredLocationTypeOfMaterial(rc.outputs[0].material);
|
|
8253
8369
|
const fgStore = declaredFg ? this.locationOfMaterial(rc.outputs[0].material) : loc;
|
|
8254
|
-
const consumed = order.allocated
|
|
8370
|
+
const consumed = allocatedEpcs(order.allocated);
|
|
8255
8371
|
if (!consumed.length) {
|
|
8256
8372
|
if (order.seedIncomplete) {
|
|
8257
8373
|
order.status = "blocked-seed-incomplete";
|
|
@@ -9949,6 +10065,7 @@ function electricityCost(input) {
|
|
|
9949
10065
|
}
|
|
9950
10066
|
// Annotate the CommonJS export names for ESM import in node:
|
|
9951
10067
|
0 && (module.exports = {
|
|
10068
|
+
ALLOCATION_POLICIES,
|
|
9952
10069
|
ATTENTION_DEFAULTS,
|
|
9953
10070
|
ATTENTION_PROPERTY,
|
|
9954
10071
|
ClassMasterTable,
|
|
@@ -9966,6 +10083,9 @@ function electricityCost(input) {
|
|
|
9966
10083
|
TwinRuntime,
|
|
9967
10084
|
WmsKernel,
|
|
9968
10085
|
YmsKernel,
|
|
10086
|
+
allocatedEpcs,
|
|
10087
|
+
allocatedQty,
|
|
10088
|
+
allocationPolicyOf,
|
|
9969
10089
|
attributeEnergy,
|
|
9970
10090
|
compareStates,
|
|
9971
10091
|
constantDuration,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operato/twin-kernel",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
|
|
6
6
|
"publishConfig": {
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"test": "node --test test/*.test.ts"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@operato/ops-contract": "^0.9.
|
|
31
|
+
"@operato/ops-contract": "^0.9.30"
|
|
32
32
|
}
|
|
33
33
|
}
|