/* * ★ 생산 능력 질의 — **트윈 하나를 두고 "지금 더 받을 수 있나" 를 물을 길.** * * 커널이 계산과 표준 등식을 지킨다(kernel/test/operations-capability). 이 검사가 지키는 것은 그 앞의 * 배선이다 — 그리고 여기서 지켜야 할 규율은 `twinCapacity` 와 같다: * * · **대상은 등록부가 정한다** — 떠 있는 런타임만 훑으면 "안 떠 있어서 안 보이는 것" 과 "없는 것" 이 * 구별되지 않는다. 화면은 그 둘을 다르게 말해야 한다. * · **공정을 선언하지 않은 트윈은 판정 대상이 아니다** — 0 으로 채우면 "야드가 병목" 이라는 없는 * 사실이 생긴다. * · **구간은 부르는 쪽이 말한다** — 기본값을 두면 화면마다 다른 구간을 보면서 같은 값으로 읽는다. */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { TwinJournalQuery } = req('../dist-server/service/twin-journal/twin-journal-query.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string let q: any let ctx: any const MES = 'cap-mes' const WMS = 'cap-wms' const WINDOW = { fromTime: '2026-01-05T00:00:00.000Z', toTime: '2026-01-06T00:00:00.000Z' } /** 절단 하나 — 설비 2대·사람 2명, 사이클 1시간. 24시간 창이면 상한 48대. */ const mesMaster = (): any => ({ source: MES, system: 'mes', siteName: '능력 공장', space: { spaceId: 'cap-site', width: 60, depth: 40, unit: 'm' }, locations: [ { id: 'raw-store', type: 'raw-store', role: 'location', name: '자재 보관', capacity: 100 }, { id: 'cut-station', type: 'cut-station', role: 'location', name: '절단', capacity: 10 }, { id: 'fg-store', type: 'fg-store', role: 'location', name: '완제품', capacity: 100 } ], equipment: [ { id: 'cutter-1', kind: 'cutter', homeLocationId: 'cut-station' }, { id: 'cutter-2', kind: 'cutter', homeLocationId: 'cut-station' } ], personnelClasses: [{ id: 'cutter-op', description: '절단 작업자' }], persons: [ { id: 'c-1', personnelClassIds: ['cutter-op'], homeLocationId: 'cut-station' }, { id: 'c-2', personnelClassIds: ['cutter-op'], homeLocationId: 'cut-station' } ], operations: [ { key: 'cut', label: '절단', intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT1H', equipmentSpecification: [{ equipmentClass: 'cutter', quantity: 1 }], personnelSpecification: [{ personnelClass: 'cutter-op', quantity: 1 }] } ], productionSpec: { /* 레시피 생산은 자재 키를 GS1 로 풀 수 있어야 한다 — 커널이 없으면 기동을 거절한다(정확한 거절이다). */ companyPrefix: '0614141', binding: { steel: '077101', frame: '078101' }, definition: { materials: [{ id: 'steel' }, { id: 'frame' }], routes: [{ key: 'r', steps: ['cut'] }], recipes: [{ key: 'rc', label: '프레임', route: 'r', inputs: [{ material: 'steel', qty: 1 }], outputs: [{ material: 'frame', qty: 1 }] }] } } }) /** 공정을 선언하지 않는 창고 — 판정 대상이 아님을 확인하는 대조군. */ const wmsMaster = (): any => ({ source: WMS, system: 'wms', siteName: '능력 창고', space: { spaceId: 'cap-wh', width: 40, depth: 30, unit: 'm' }, locations: [ { id: 'dock', type: 'dock', role: 'location', name: '도크', capacity: 50 }, { id: 'bin-1', type: 'storage', role: 'location', name: '빈1', capacity: 20 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }] }) before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const d = await ds.getRepository(Domain).save({ name: 'cap-test', subdomain: 'cap-test' } as any) domainId = d.id q = new TwinJournalQuery() ctx = { state: { domain: { id: domainId } } } await TwinEngine.ingestMaster(domainId, mesMaster(), 'reset') await TwinEngine.ingestMaster(domainId, wmsMaster(), 'reset') }) after(async () => { for (const id of [MES, WMS]) await TwinEngine.stop(domainId, id).catch(() => {}) await ds?.destroy() }) const ask = (instanceId: string) => q.twinOperationsCapability(instanceId, undefined, 24, WINDOW.fromTime, WINDOW.toTime, WINDOW.fromTime, ctx) test('실행 중이 아니면 그 사실을 알린다 — "없는 것" 과 구별된다', async () => { const rows = await ask(MES) assert.equal(rows.length, 1, '등록부에 있으면 목록에 선다(떠 있지 않아도)') assert.equal(rows[0].reason, 'not-running') assert.equal(rows[0].report, undefined, '값을 지어내지 않는다') }) test('돌고 있으면 구간의 양을 종류로 갈라 낸다 — 표준 등식이 성립한다', async () => { await TwinEngine.startFromRegistry(domainId, MES, 'reset') const rows = await ask(MES) assert.equal(rows[0].reason, undefined) const report = rows[0].report assert.equal(report.operationsType, 'Production', '표준 열거값을 그대로 낸다') assert.equal(report.window.fromTime, WINDOW.fromTime) assert.equal(report.window.workingHours, 24, '캘린더 선언이 없으면 종일 가동으로 본다') const cut = report.perOperation.find((o: any) => o.operation === 'cut') assert.ok(cut, '선언한 공정이 보고서에 있어야 한다') assert.equal(cut.total, 48, '설비 2대·사람 2명·사이클 1시간 → 하루 48대') assert.equal(cut.total, cut.committed + cut.available + cut.unattainable, 'Total = Committed + Available + Unattainable') }) test('공정을 선언하지 않은 트윈은 판정 대상이 아니다 — 0 으로 채우면 없는 병목이 생긴다', async () => { await TwinEngine.startFromRegistry(domainId, WMS, 'reset') const rows = await q.twinOperationsCapability(WMS, undefined, 24, WINDOW.fromTime, WINDOW.toTime, WINDOW.fromTime, ctx) assert.equal(rows[0].reason, 'no-operations') assert.equal(rows[0].report, undefined) }) test('구간은 호출하는 쪽이 지정한다 — 뒤집힌 구간·잘못된 시각은 거절한다', async () => { await assert.rejects( () => q.twinOperationsCapability(MES, undefined, 24, WINDOW.toTime, WINDOW.fromTime, undefined, ctx), /inverted/ ) await assert.rejects( () => q.twinOperationsCapability(MES, undefined, 24, 'not-a-time', WINDOW.toTime, undefined, ctx), /valid ISO time/ ) await assert.rejects(() => q.twinOperationsCapability(MES, undefined, 0, WINDOW.fromTime, WINDOW.toTime, undefined, ctx)) }) test('남의 테넌트 트윈은 열리지 않는다 — 능력은 현장의 사실이다', async () => { const other = { state: { domain: { id: 'no-such-domain' } } } await assert.rejects( () => q.twinOperationsCapability(MES, undefined, 24, WINDOW.fromTime, WINDOW.toTime, undefined, other), /not found in this tenant/ ) })