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' import { TwinLocation } from './twin-location.js' import { TwinArea } from '../twin-space/twin-area.js' /* * TwinEquipment — 이 트윈이 반영하는 **설비**. ISA-95 `Equipment` / ISO 55000 `Asset` / EPCIS `GIAI`. * * 자리와 같은 규율을 따른다(`twin-location.ts` 머리말) — 이것도 **원본에서 온 캐시**이고, * `sourceRef`·`ingestedAt` 이 필수이며, 쓰기 경로는 인제스트 하나다. * * ── 테이블 이름을 명시한다 ────────────────────────────────────────────────── * `equipment` 는 영어에서 **불가산**이라 자동 복수화가 다른 표들과 규칙이 어긋난다 * (`twin_locations` 인데 `twin_equipment`). 들어온 것이 없는 비일관은 나중에 "왜 이것만 다르지" 를 * 만들므로 이름을 고정한다(ADR-0032-I). */ @Entity({ name: 'twin_equipment' }) @Index('ix_twin_equipment_0', (e: TwinEquipment) => [e.domain, e.instance, e.equipmentId], { unique: true }) /* 종류로 좁혀 보기 — 공정이 `resourceType` 으로 종류를 가리키므로 잦은 질의다. */ @Index('ix_twin_equipment_1', (e: TwinEquipment) => [e.domain, e.instance, e.kind]) @ObjectType({ description: 'Equipment reflected into the twin (ISA-95 Equipment / ISO 55000 Asset). Projected from the source; never authored here.' }) export class TwinEquipment { @PrimaryGeneratedColumn('uuid') @Field(type => ID) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinEquipment) => e.domain) domainId?: string @ManyToOne(type => TwinInstance, { onDelete: 'CASCADE' }) @Field(type => TwinInstance, { nullable: true, description: 'Owning twin instance.' }) instance?: TwinInstance @RelationId((e: TwinEquipment) => e.instance) instanceRowId?: string /** 커널 계약의 `TwinModelDef.equipment[].id` 그대로. */ @Column() @Field({ description: 'Equipment id as declared by the source (kernel TwinModelDef.equipment[].id).' }) equipmentId: 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 /** 커널 카탈로그의 설비 종류 키(`forklift`·`welder`…). FK 아님 — 카탈로그는 커널 SSOT. */ @Column() @Field({ description: 'Equipment kind key from the kernel catalog (not an FK — the catalog is the kernel SSOT).' }) kind: string /** * 자원 속성 — **ISA-95 `Property`**(값·데이터형·단위·하위 속성). * * 모델에는 있는데 행에 자리가 없어 투영이 흘리고 있었다. 그러면 조회 화면은 "원본이 말한 값" 이라 * 적고도 속성을 보이지 못한다 — 이름이 같은 식으로 사라졌던 것과 같은 부류다(2026-08-11). * * 소비처가 실제로 읽는 사실이다: 주행 추정기가 `speed` 로 이동 시간을 만든다. 단위가 값 옆에 붙어 * 있어야 뜻이 서므로 **모양 그대로** 담는다(문자열 값 + `uom` + `dataType`). */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Resource properties as declared by the source (ISA-95 Property: value + dataType + unit of measure, optional children).' }) properties?: any /** * 이 설비의 적격을 검증한 **시험 명세들** — 표준 `TestSpecificationID`(`maxOccurs="unbounded"`). * * 참조만 담는다(대상은 `testSpecifications` 축). 속성과 **같은 부류의 결손**이었다: 모델에는 있는데 * 행에 자리가 없어 투영이 흘렸고, 그래서 명세 쪽에서 "누가 나를 가리키나" 를 물으면 설비가 빠졌다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => [String], { nullable: true, description: 'Test specifications that verified this equipment (ISA-95 TestSpecificationID, unbounded). References only — the specifications themselves live on the testSpecifications axis.' }) testSpecificationIds?: string[] /** * 그 시험들의 **결과** — 자격이 성립하는지는 이것이 말한다. * * 참조만 있으면 "무엇으로 검증한다" 까지다. 오늘 이 저장소에서 네 번 나온 결손과 같은 부류이므로 * 행에 자리를 함께 만든다 — 모델에는 있는데 행에 없으면 화면이 알리지 않고 잃는다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Results of those tests (pass/fail with dates). Declared by the source — the twin does not produce them.' }) testResults?: any /** * 기준 자리 — **실제 FK**. 예전에는 `homeLocation` 문자열이라 끊어진 참조를 화면이 경고했고 * (`brokenHomeRefs`), 다른 경로로 들어오는 데이터는 아무도 안 봤다. 이제 DB 가 막는다. */ @ManyToOne(type => TwinLocation, { nullable: true, onDelete: 'SET NULL' }) @Field(type => TwinLocation, { nullable: true, description: 'Home location this equipment belongs to.' }) homeLocation?: TwinLocation @RelationId((e: TwinEquipment) => e.homeLocation) homeLocationRowId?: string /** * 기준 **구역** — 설비가 자리가 아니라 구역에 서 있을 때. * * ── 왜 칸이 둘인가 (2026-09-06) ──────────────────────────────────────────── * 계약이 `homeLocationKind = 'location' | 'area'` 로 그 갈림을 말한다. 실물이 있다 — * `EQ-CAM-SHOWER-01`(에어샤워)이 `LINE-CAM` 이라는 **구역**에 딸려 있고, 그것이 정상이다. * 라인 전체에 붙는 설비는 자리 하나를 고를 수 없다. * * 그동안 이 칸이 없어서 인제스트가 그 참조를 **「끊어졌다」고 경고**했다. 자리 목록에서 못 찾으면 * 없는 것으로 봤기 때문이다. * * 한 칸에 두 대상을 담지 않는다 — FK 가 서로 다른 표를 가리키고, 합치면 DB 가 못 막는다. * **둘 다 차는 일은 없다**(마스터의 `role` 이 하나를 정한다). */ @ManyToOne(type => TwinArea, { nullable: true, onDelete: 'SET NULL' }) @Field(type => TwinArea, { nullable: true, description: 'Home area this equipment belongs to, when it stands in an area rather than a location.' }) homeArea?: TwinArea @RelationId((e: TwinEquipment) => e.homeArea) homeAreaRowId?: string /* * 고장 모델 — 원본이 선언할 때만 있다. 없으면 고장 없는 설비로 동작한다(커널 계약). * **관측(가동률)은 여기 두지 않는다** — 그건 트윈이 저널에서 만든 것이라 성질이 다르고, * 이 표는 원본에서 재생성 가능해야 한다(ADR-0032-D). */ @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Declared mean time between failures in ms. Absent = no failure model.' }) mtbfMs?: number @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Declared mean time to repair in ms.' }) mttrMs?: number /* * ── **선언된 설비 정체성** (2026-08-30) ─────────────────────────────────── * * 예전 이름은 `gs1Id` 였고, 투영이 선언의 `m.gs1Id` 를 읽었다. **그런 필드가 선언에 없다** — 그래서 * 이 칸은 일곱 대 모두 영원히 비어 있었다. 자리는 있고 길이 없었다. * * 그 사이 커널에 `TwinModelDef.equipment[].identity` 를 열었으므로 어휘가 두 벌이 됐다. 하나로 * 합친다. 이름을 `identity` 로 두는 이유는 **GS1 키가 아닌 정체성이 정상**이기 때문이다 — * 실측한 값은 인버터의 하드웨어 식별자(`ES100000007d8d061d-1`)이고 GS1 키가 아니다. * * 자리(`twin_location`)의 `gs1Id` 는 그대로 둔다. 그쪽은 실제로 SGLN 이 온다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Declared global identity of this equipment (TwinModelDef.equipment[].identity). Not necessarily a GS1 key.' }) identity?: string /* ── 캐시라는 사실을 구조로 (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 }