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 { TwinArea } from '../twin-space/twin-area.js' /* * TwinLocation — 이 트윈이 반영하는 **자리**. ISA-95 `OperationalLocation` / EPCIS `SGLN`. * * ── 왜 테이블인가 (ADR-0032, 2026-08-08) ──────────────────────────────────── * 지금까지 자리는 `TwinInstance.model` JSON 안에 있었다. 그래서 ⑴ 부분 갱신이 없어 쓰는 쪽이 * 빠뜨리면 삭제였고 ⑵ 라우트→공정·설비→자리 참조에 FK 를 못 걸어 **끊어진 참조를 화면이 보고만** * 했고 ⑶ 5개 드라이버에서 JSON 질의가 이식되지 않아 "설비 몇 대?" 조차 서버가 못 답했다. * * ── 이것은 **원본에서 온 캐시**다 (ADR-0032-D) ────────────────────────────── * 잘 만든 엔티티는 정본처럼 **생겼다.** 그래서 소유 침범을 부른다 — 누군가 "여기에만 있는 컬럼" 을 * 더하고, 쓰기 경로를 열고, 그러면 `ADR-0028`(트윈은 만들지 않는다)이 무너진다. * * 규율 셋을 구조로 박는다: * · `sourceRef`·`ingestedAt` 필수 — 어디서 언제 왔는지 없는 행은 만들 수 없다 * · 쓰기 경로는 **인제스트 하나** * · **전부 지우고 다시 만들 수 있다** — 원본이 낼 수 없는 값을 여기 두지 않는다 */ @Entity({ name: 'twin_locations' }) @Index('ix_twin_location_0', (e: TwinLocation) => [e.domain, e.instance, e.locationId], { unique: true }) /* 타입으로 좁혀 보기(인스펙션의 "이 타입의 자리들") — 공정이 타입을 가리키므로 잦은 질의다. */ @Index('ix_twin_location_1', (e: TwinLocation) => [e.domain, e.instance, e.type]) @ObjectType({ description: 'A location reflected into the twin (ISA-95 OperationalLocation / EPCIS SGLN). Projected from the source; never authored here.' }) export class TwinLocation { @PrimaryGeneratedColumn('uuid') @Field(type => ID) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinLocation) => e.domain) domainId?: string /* 이 자리가 속한 트윈. 트윈이 사라지면 그 구조도 함께 사라진다(투영이므로 재생성 가능). */ @ManyToOne(type => TwinInstance, { onDelete: 'CASCADE' }) @Field(type => TwinInstance, { nullable: true, description: 'Owning twin instance.' }) instance?: TwinInstance @RelationId((e: TwinLocation) => e.instance) instanceRowId?: string /** 커널 계약의 `TwinModelDef.locations[].id` **그대로**. 바인딩은 id===id 다(ADR-0014). */ @Column() @Field({ description: 'Location id as declared by the source (kernel TwinModelDef.locations[].id).' }) locationId: 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 /** * 커널 카탈로그의 자리 타입 키(`storage`·`dock`·`yard-slot`…). * * **FK 를 걸지 않는다** — 타입 카탈로그는 커널이 SSOT 이고(ADR-0018) DB 로 내리면 * **커널 어휘의 사본**이 생긴다. 문자열 + 부팅 시 검증이 맞다(ADR-0032-F). */ @Column() @Field({ description: 'Location type key from the kernel catalog (not an FK — the catalog is the kernel SSOT).' }) type: string @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Declared capacity. Absent when the source does not say.' }) capacity?: number @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'How many things can be worked on here at once, when the source declares it.' }) parallelism?: number /** * 속한 구역 — **실제 FK**. * * 커널 계약에서는 `locations[].parentId` 가 model 안에 대상이 없는 참조였다(마스터에서 구역도 * 자리와 한 목록이었다가 인제스트가 갈라 `TwinArea` 로 보내기 때문). 커널은 그것을 * `external: 'space.area'` 로 선언했고, **호스트에서는 평범한 FK 가 된다.** */ @ManyToOne(type => TwinArea, { nullable: true, onDelete: 'SET NULL' }) @Field(type => TwinArea, { nullable: true, description: 'Logical area this location belongs to.' }) area?: TwinArea @RelationId((e: TwinLocation) => e.area) areaRowId?: string /** GS1 식별자(SGLN 등) — 원본이 주면 싣는다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'GS1 identifier supplied by the source (e.g. SGLN).' }) gs1Id?: string /** * 이 자리의 **선언 속성** — 계약전력·요금 단가처럼 자리에 얹히는 수(ISA-95 Property). * * ── 왜 늦게 생겼나 (2026-08-18) ──────────────────────────────────────────── * 설비 행에는 이 칸이 있었는데 자리 행에는 없었다. 그래서 수전 지점에 계약전력·요금을 넣어도 **항목 * 패널의 「원본이 말한 값」에 그 줄이 아예 없었다** — 사용자는 값을 넣고도 화면에서 찾을 수 없었고, * 「이 값이 무엇을 바꾸나」는 문장도 붙을 자리가 없었다(들어온 것이 없는 누락). */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Properties declared on this location (ISA-95 Property: value + dataType + unit of measure). Carries locally-declared numbers too, with source: local.' }) properties?: any /* ── 캐시라는 사실을 구조로 (ADR-0032-D) ── */ @Column() @Field({ description: 'Which reference (source system) this row came from. Required — a row with no origin cannot exist.' }) 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 }