@operato/twin-kernel 0.6.6 → 0.6.7

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.
@@ -216,6 +216,45 @@ export declare function testPassedAt(r: TestResult, at?: ISOTime): boolean;
216
216
  * 없는 것으로 막지 않는다). 결과가 있으면 그것이 유효한 합격이어야 한다.
217
217
  */
218
218
  export declare function meetsTests(required: readonly string[], results: readonly TestResult[] | undefined, at?: ISOTime): boolean;
219
+ /**
220
+ * 자원이 **지금 쓰일 수 있나, 아니면 왜 못 쓰이나** — ISA-95 `PersonnelCapability`/`EquipmentCapability`.
221
+ *
222
+ * ── 왜 계약이 이것을 소유해야 하나 ────────────────────────────────────────────
223
+ * 커널은 배정할 때 이미 이 판정을 한다(교대·고장·보류·유효기간·시험 만료). 그런데 그 규칙이 **엔진 안의
224
+ * 필터 조건으로만** 있어서, 화면은 같은 판정을 자기 코드로 다시 만들었다(`reasonOf`). 규칙이 두 벌이면
225
+ * 반드시 갈라진다 — 배정은 막는데 화면은 "가용" 이라 말하는 순간이 온다. 그 어긋남은 조용하다.
226
+ *
227
+ * 그래서 **이유까지 계약이 낸다.** 화면·예측·AI 가 같은 낱말로 말하고, 새 이유가 생기면(시험 만료가
228
+ * 그랬다) 한 곳만 늘어난다.
229
+ *
230
+ * ── 이유의 순서가 뜻이다 ──────────────────────────────────────────────────────
231
+ * 여러 이유가 겹칠 수 있다(폐기한 설비가 고장 상태로 남아 있는 것). **먼저 오는 것을 답한다** —
232
+ * "이미 모델 밖" 이 "고장" 보다 앞선다(폐기한 설비의 고장은 고칠 일이 아니다).
233
+ */
234
+ export type CapabilityReason =
235
+ /** 유효기간 전 — 아직 없는 자원(도입 예정). 기다릴 일이다. */
236
+ 'not-yet'
237
+ /** 유효기간 후 — 이미 없는 자원(폐기·퇴사). 지울 일이다. */
238
+ | 'retired'
239
+ /** 사람이 막았다(`held`) — 지시로 보류. */
240
+ | 'held'
241
+ /** 고장 — 설비만. 고칠 일이다. */
242
+ | 'down'
243
+ /** 근무·가동 시간 밖(교대 사이) — 기다리면 돌아온다. */
244
+ | 'off-shift'
245
+ /** 근무일이 아니다(휴일) — 하루 통째로 쉰다. `off-shift` 와 기다릴 시간이 다르다. */
246
+ | 'resting'
247
+ /** 요구된 시험의 결과가 만료·불합격 — 자격이 성립하지 않는다(§TestResult). */
248
+ | 'test-expired'
249
+ /** 지금 다른 일을 하고 있다 — 능력은 있고 여유가 없다. */
250
+ | 'working'
251
+ /** 쓸 수 있다. */
252
+ | 'available';
253
+ /** 가용 여부와 그 이유 — `available` 이면 `reason: 'available'`. */
254
+ export interface Capability {
255
+ available: boolean;
256
+ reason: CapabilityReason;
257
+ }
219
258
  export interface TestSpecification {
220
259
  /** 표준 `ID` — 자원의 `testSpecificationIds` 가 이 값을 가리킨다. */
221
260
  id: string;
@@ -578,6 +617,28 @@ export declare function offCalendarAt(r: {
578
617
  };
579
618
  workCalendar?: WorkCalendarEntry[];
580
619
  }, ms: number, utcOffsetMinutes?: number): boolean;
620
+ /**
621
+ * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
622
+ *
623
+ * 부르는 쪽이 시각과 시간대를 준다(커널은 `now()`·`utcOffsetMinutes`, 호스트는 관측 시각). 주지 않으면
624
+ * 시각에 달린 판정(유효기간·교대·시험 만료)은 **하지 않는다** — 모르면 판단하지 않는다는 규율이다.
625
+ *
626
+ * `requiredTests` 는 부르는 쪽이 등급에서 모아 넘긴다(등급 정의를 아는 것은 부르는 쪽이다).
627
+ */
628
+ export declare function capabilityOf(r: {
629
+ status?: string;
630
+ held?: boolean;
631
+ window?: {
632
+ startHour: number;
633
+ endHour: number;
634
+ };
635
+ workCalendar?: WorkCalendarEntry[];
636
+ testResults?: TestResult[];
637
+ } & EffectivePeriod, ctx?: {
638
+ at?: ISOTime;
639
+ utcOffsetMinutes?: number;
640
+ requiredTests?: readonly string[];
641
+ }): Capability;
581
642
  export interface LocationState {
582
643
  id: string;
583
644
  type: string;
package/dist/contract.js CHANGED
@@ -517,6 +517,43 @@ export function offCalendarAt(r, ms, utcOffsetMinutes) {
517
517
  const h = Math.floor(minute / 60);
518
518
  return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
519
519
  }
520
+ /**
521
+ * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
522
+ *
523
+ * 부르는 쪽이 시각과 시간대를 준다(커널은 `now()`·`utcOffsetMinutes`, 호스트는 관측 시각). 주지 않으면
524
+ * 시각에 달린 판정(유효기간·교대·시험 만료)은 **하지 않는다** — 모르면 판단하지 않는다는 규율이다.
525
+ *
526
+ * `requiredTests` 는 부르는 쪽이 등급에서 모아 넘긴다(등급 정의를 아는 것은 부르는 쪽이다).
527
+ */
528
+ export function capabilityOf(r, ctx) {
529
+ const at = ctx?.at;
530
+ /* 순서가 뜻이다 — "이미 모델 밖" 이 "고장" 보다 앞선다(폐기한 설비의 고장은 고칠 일이 아니다). */
531
+ const eff = effectivityAt(r, at);
532
+ if (eff === 'not-yet')
533
+ return { available: false, reason: 'not-yet' };
534
+ if (eff === 'expired')
535
+ return { available: false, reason: 'retired' };
536
+ if (r.held)
537
+ return { available: false, reason: 'held' };
538
+ if (r.status === 'down')
539
+ return { available: false, reason: 'down' };
540
+ if (at) {
541
+ const ms = Date.parse(at);
542
+ if (Number.isFinite(ms)) {
543
+ const why = offCalendarReasonAt(r, ms, ctx?.utcOffsetMinutes);
544
+ if (why === 'non-working')
545
+ return { available: false, reason: 'resting' };
546
+ if (why === 'off-hours')
547
+ return { available: false, reason: 'off-shift' };
548
+ }
549
+ }
550
+ /* 자격은 **결과가 선언됐을 때만** 제약이다(§meetsTests) — 없는 것으로 막으면 라인이 굶는다. */
551
+ if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
552
+ return { available: false, reason: 'test-expired' };
553
+ if (r.status && r.status !== 'idle' && r.status !== 'available')
554
+ return { available: false, reason: 'working' };
555
+ return { available: true, reason: 'available' };
556
+ }
520
557
  // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
521
558
  // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
522
559
  // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
@@ -809,7 +809,12 @@ export declare abstract class FlowEngine implements TwinKernel {
809
809
  * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
810
810
  * 조용히 통과시킨다.
811
811
  */
812
- private qualifiedByTests;
812
+ /**
813
+ * 이 사람이 속한 등급들이 **요구하는 시험 목록** — 판정은 계약이 한다(`capabilityOf`).
814
+ *
815
+ * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
816
+ */
817
+ private requiredTestsOf;
813
818
  private claimPersonnel;
814
819
  /**
815
820
  * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, meetsTests, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
15
15
  import { parseIsoDuration } from "./iso-duration.js";
@@ -1854,16 +1854,21 @@ export class FlowEngine {
1854
1854
  * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
1855
1855
  * 조용히 통과시킨다.
1856
1856
  */
1857
- qualifiedByTests(p) {
1857
+ /**
1858
+ * 이 사람이 속한 등급들이 **요구하는 시험 목록** — 판정은 계약이 한다(`capabilityOf`).
1859
+ *
1860
+ * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
1861
+ */
1862
+ requiredTestsOf(p) {
1858
1863
  const defs = this.classDefs.personnel;
1859
1864
  if (!defs?.length)
1860
- return true;
1865
+ return [];
1861
1866
  const closure = classClosure(p.personnelClassIds, defs, this.now());
1862
1867
  const required = [];
1863
1868
  for (const d of defs)
1864
1869
  if (closure.has(d.id))
1865
1870
  required.push(...(d.testSpecificationIds ?? []));
1866
- return meetsTests(required, p.testResults, this.now());
1871
+ return required;
1867
1872
  }
1868
1873
  claimPersonnel(t) {
1869
1874
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
@@ -1874,24 +1879,23 @@ export class FlowEngine {
1874
1879
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
1875
1880
  if (!want)
1876
1881
  continue;
1877
- const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
1878
- !picked.includes(p.id) &&
1879
- !this.personOffShift(p) &&
1880
- !this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
1881
- /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1882
- 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
1883
- 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
1884
- (req.personnelClass === undefined ||
1885
- classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)) &&
1882
+ const avail = [...this.persons.values()].filter(p => !picked.includes(p.id) &&
1886
1883
  /*
1887
- * **요구된 시험을 만족하나** — 등급이 요구하고(`testSpecificationIds`) 사람이 결과를 든다
1888
- * (`testResults`). 만료·불합격이면 자격은 성립하지 않는다.
1884
+ * **판정은 벌이다**(`capabilityOf`). 예전에는 자리에 조건을 늘어놓았고 화면은 같은
1885
+ * 판정을 자기 코드로 다시 만들었다 — 규칙이 두 벌이면 갈라지고, 배정은 막는데 화면은
1886
+ * "가용" 이라 말하는 순간이 온다(그 어긋남은 조용하다).
1889
1887
  *
1890
- * 결과가 **아예 없으면 막지 않는다**: 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히
1891
- * 굶고, 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은 것이다("선언한 것만
1892
- * 제약이 된다" — 이 커널의 규율).
1888
+ * 요구된 시험은 등급에서 모아 넘긴다 등급 정의를 아는 것은 이쪽이다.
1893
1889
  */
1894
- this.qualifiedByTests(p));
1890
+ capabilityOf(p, {
1891
+ at: this.now(),
1892
+ utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
1893
+ requiredTests: this.requiredTestsOf(p)
1894
+ }).available &&
1895
+ /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1896
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다. */
1897
+ (req.personnelClass === undefined ||
1898
+ classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)));
1895
1899
  if (avail.length < want)
1896
1900
  return null; // 한 등급이라도 모자라면 시작하지 않는다
1897
1901
  for (let i = 0; i < want; i++)
@@ -73,6 +73,7 @@ __export(index_exports, {
73
73
  axisInfo: () => axisInfo,
74
74
  axisSource: () => axisSource,
75
75
  capabilitiesForType: () => capabilitiesForType,
76
+ capabilityOf: () => capabilityOf,
76
77
  classClosure: () => classClosure,
77
78
  compareStates: () => compareStates,
78
79
  computeOee: () => computeOee,
@@ -409,6 +410,26 @@ function offCalendarAt(r, ms2, utcOffsetMinutes) {
409
410
  const h = Math.floor(minute / 60);
410
411
  return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
411
412
  }
413
+ function capabilityOf(r, ctx) {
414
+ const at = ctx?.at;
415
+ const eff = effectivityAt(r, at);
416
+ if (eff === "not-yet") return { available: false, reason: "not-yet" };
417
+ if (eff === "expired") return { available: false, reason: "retired" };
418
+ if (r.held) return { available: false, reason: "held" };
419
+ if (r.status === "down") return { available: false, reason: "down" };
420
+ if (at) {
421
+ const ms2 = Date.parse(at);
422
+ if (Number.isFinite(ms2)) {
423
+ const why = offCalendarReasonAt(r, ms2, ctx?.utcOffsetMinutes);
424
+ if (why === "non-working") return { available: false, reason: "resting" };
425
+ if (why === "off-hours") return { available: false, reason: "off-shift" };
426
+ }
427
+ }
428
+ if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
429
+ return { available: false, reason: "test-expired" };
430
+ if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
431
+ return { available: true, reason: "available" };
432
+ }
412
433
  var OP_EVENT = {
413
434
  task: "task.status",
414
435
  equipment: "equipment.status",
@@ -3849,13 +3870,18 @@ var FlowEngine = class {
3849
3870
  * 판정 규칙은 계약이 소유한다(`meetsTests`) — 여기서 다시 적으면 규칙이 두 곳이 되고, 한쪽이 만료를
3850
3871
  * 조용히 통과시킨다.
3851
3872
  */
3852
- qualifiedByTests(p) {
3873
+ /**
3874
+ * 이 사람이 속한 등급들이 **요구하는 시험 목록** — 판정은 계약이 한다(`capabilityOf`).
3875
+ *
3876
+ * 상속을 타고 닫은 등급 전부의 요구를 모은다: 상위 등급이 요구하는 시험도 자격의 조건이다.
3877
+ */
3878
+ requiredTestsOf(p) {
3853
3879
  const defs = this.classDefs.personnel;
3854
- if (!defs?.length) return true;
3880
+ if (!defs?.length) return [];
3855
3881
  const closure = classClosure(p.personnelClassIds, defs, this.now());
3856
3882
  const required = [];
3857
3883
  for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
3858
- return meetsTests(required, p.testResults, this.now());
3884
+ return required;
3859
3885
  }
3860
3886
  claimPersonnel(t) {
3861
3887
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
@@ -3865,19 +3891,20 @@ var FlowEngine = class {
3865
3891
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
3866
3892
  if (!want) continue;
3867
3893
  const avail = [...this.persons.values()].filter(
3868
- (p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && !this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
3869
- /* 자격은 **상속을 타고 닫아** 판정한다 사람은 여러 등급에 속할 수 있고(표준), 등급은
3870
- 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 만족해야 한다.
3871
- 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
3872
- (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)) && /*
3873
- * **요구된 시험을 만족하나** — 등급이 요구하고(`testSpecificationIds`) 사람이 결과를 든다
3874
- * (`testResults`). 만료·불합격이면 그 자격은 성립하지 않는다.
3894
+ (p) => !picked.includes(p.id) && /*
3895
+ * **판정은 벌이다**(`capabilityOf`). 예전에는 자리에 조건을 늘어놓았고 화면은 같은
3896
+ * 판정을 자기 코드로 다시 만들었다 규칙이 벌이면 갈라지고, 배정은 막는데 화면은
3897
+ * "가용" 이라 말하는 순간이 온다( 어긋남은 조용하다).
3875
3898
  *
3876
- * 결과가 **아예 없으면 막지 않는다**: 없는 것으로 막으면 자격자가 전부 사라져 라인이 영구히
3877
- * 굶고, 원본이 결과를 주지 않는 현장에서는 그 제약이 선언되지 않은 것이다("선언한 것만
3878
- * 제약이 된다" — 이 커널의 규율).
3899
+ * 요구된 시험은 등급에서 모아 넘긴다 등급 정의를 아는 것은 이쪽이다.
3879
3900
  */
3880
- this.qualifiedByTests(p)
3901
+ capabilityOf(p, {
3902
+ at: this.now(),
3903
+ utcOffsetMinutes: this.boardDef?.utcOffsetMinutes,
3904
+ requiredTests: this.requiredTestsOf(p)
3905
+ }).available && /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
3906
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다. */
3907
+ (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass))
3881
3908
  );
3882
3909
  if (avail.length < want) return null;
3883
3910
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -5159,6 +5186,7 @@ function retiredVocabularyIn(line) {
5159
5186
  axisInfo,
5160
5187
  axisSource,
5161
5188
  capabilitiesForType,
5189
+ capabilityOf,
5162
5190
  classClosure,
5163
5191
  compareStates,
5164
5192
  computeOee,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
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": {