import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' import { ObjectType, Field, ID, Int } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' import { TwinInstance } from '../twin-instance/twin-instance.js' /* * TwinOperation — 이 트윈이 반영하는 **공정 명세**. ISA-95 `OperationsSegment`. * 이 현장에서 각 작업이 **어디서·무엇으로·얼마나 걸리나**(선언값). * * ── 왜 이 표가 인스펙션의 중심인가 ────────────────────────────────────────── * 인스펙션 화면의 존재 이유는 `선언 | 관측` 두 열이고(`plans/twin-inspection.md` §2.4), * 그 첫 열이 여기서 온다. 둘째 열은 저널에서 계산된다(`kpi-fold.workTime`). * 실적은 보이는데 기준이 안 보이면 "절단이 5분 걸렸다" 를 보고도 빠른지 느린지 알 수 없다. * * 자리·설비와 같은 규율 — **원본에서 온 캐시**, `sourceRef`·`ingestedAt` 필수, * 쓰기 경로는 인제스트 하나(`twin-location.ts` 머리말). */ @Entity({ name: 'twin_operations' }) @Index('ix_twin_operation_0', (e: TwinOperation) => [e.domain, e.instance, e.operationKey], { unique: true }) @ObjectType({ description: 'An operation spec reflected into the twin (ISA-95 OperationsSegment): where it runs, with what, and how long the source says it takes.' }) export class TwinOperation { @PrimaryGeneratedColumn('uuid') @Field(type => ID) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinOperation) => e.domain) domainId?: string @ManyToOne(type => TwinInstance, { onDelete: 'CASCADE' }) @Field(type => TwinInstance, { nullable: true, description: 'Owning twin instance.' }) instance?: TwinInstance @RelationId((e: TwinOperation) => e.instance) instanceRowId?: string /** 커널 계약의 `OperationDef.key` 그대로. 라우트의 단계가 이 키로 가리킨다. */ @Column() @Field({ description: 'Operation key as declared by the source (kernel OperationDef.key). Route steps reference this.' }) operationKey: string /** * **사람이 부르는 이름** — 프레임워크 엔티티 규약의 `name`/`description`. * * ── 왜 이것이 빠져 있었고, 무엇이 망가졌나 ──────────────────────────────────── * 투영이 마스터의 `name` 을 버리고 있었다. 그래서 행에는 id 만 남고, 화면은 이름을 만들 재료가 * 없어 `cut-station` 을 그대로 찍었다 — 그 결함이 목록·표·관계 그림마다 따로 나타나서 표면을 * 하나씩 고치게 만들었다. 표면의 문제가 아니라 **행에 이름이 없던 문제**다. * * 원본이 이름을 말하지 않으면 비운다(id 를 이름으로 베끼지 않는다 — 그러면 "이름이 있다" 는 * 거짓이 되고, 화면이 이름 없음을 알아볼 수 없게 된다). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Human name as declared by the source. Empty when the source does not name it (never copied from the id).' }) name?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Longer description when the source provides one.' }) description?: string @Column({ nullable: true }) @Field({ nullable: true, description: "Intent: 'transport' | 'process' | 'dwell'." }) intent?: string /* * 어디서·무엇으로 — 자리·설비의 **항목이 아니라 타입**을 가리킨다("절단은 cut-station 에서 수행된다"). * 그래서 FK 가 아니다. 화면은 이것을 "그 타입의 자리·설비 목록" 으로 푼다(관계 선언의 `type` 대상). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Location type this operation runs at (a catalog type key, not an item reference).' }) locationType?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Resource type that performs it (a catalog type key, not an item reference).' }) resourceType?: string /** * 소요시간 — **ISO 8601 기간 문자열이 정본**(`PT1H10M`). * * 분으로 저장하면 `PT1H10M30S` 의 초가 사라지고 그 손실은 들어온 것이 없다. 사람이 읽을 분은 * 화면이 곁들여 보여 주고, 못 읽는 값은 `—` 로 낸다(0 으로 채우면 "즉시 끝나는 공정" 이라는 거짓). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Declared duration as an ISO 8601 period (e.g. PT1H10M). The string is canonical — storing minutes would silently drop seconds.' }) duration?: string /** * 변동성·파라미터(수율·셋업)는 형이 도메인마다 달라 **원형 그대로** 싣는다. * 여기를 컬럼으로 펴려면 커널이 그 형을 먼저 고정해야 한다 — 아직 아니다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Declared variability (distribution/min/max/mode) as given by the source.' }) variability?: any @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Declared parameters (yield, setup duration…) as given by the source.' }) parameters?: any /** * **네 자원 명세 — 선언한 것만 제약이 된다.** * * ISA-95 는 한 공정이 무엇을 필요로 하는지를 넷으로 말한다: 설비(`resourceType`)·인원·자산·자재. * 커널도 그 넷을 그대로 본다 — 선언이 있으면 잡아야 작업이 시작되고, 없으면 그 자원을 보지 않는다. * * 이 셋이 투영에서 **떨어져 있었다.** 그래서 조회 화면의 "원본이 말한 값" 열에 넷 중 하나만 * 나왔고, 사람이 필요한 공정을 보고도 **사람이 필요 없다고 결론 내게** 되어 있었다. 원본이 말한 * 것을 화면이 말하지 않으면 그 화면은 원본을 반영하는 것이 아니다. * * 모양은 커널 계약 그대로 싣는다 — 인제스트 경계에서 모양을 바꾸면 그 자리에서 방언이 생긴다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Personnel this operation requires (ISA-95 PersonnelSpecification): class + quantity. Absent means the operation runs without people.' }) personnelSpecification?: any @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Physical assets this operation requires (ISA-95 PhysicalAssetSpecification). Absent means no asset constraint — an empty pallet is never the reason a job waits.' }) physicalAssetSpecification?: any @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Materials this operation consumes or produces (ISA-95 MaterialSpecification). Absent means material shortage never shows up in the forecast for this step.' }) materialSpecification?: any /** 라우트 안에서의 순서 — 라우트가 이 공정을 몇 번째로 부르나(라우트 표가 서면 그리로 옮긴다). */ @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Sequence within its route, when the source declares one.' }) seq?: number /* ── 캐시라는 사실을 구조로 (ADR-0032-D) ── */ @Column() @Field({ description: 'Which reference (source system) this row came from. Required.' }) sourceRef: string /* * **타입 이름을 적지 않는다** (2026-08-19 Postgres 부팅 실패로 고침). * * `datetime` 은 sqlite·MySQL 의 이름이고 Postgres 는 `timestamp` 만 안다 — 거꾸로 `timestamp` 는 * sqlite 가 모른다. **다섯 드라이버에 다 통하는 이름이 없다.** 그래서 이름 고르는 일을 TypeORM 에 * 맡긴다(`emitDecoratorMetadata` 가 켜져 있어 `Date` 에서 드라이버별 타입을 고른다). */ @Column() @Field({ description: 'When this row was projected from the source.' }) ingestedAt: Date @CreateDateColumn() @Field({ nullable: true }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true }) updatedAt?: Date }