import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' import { ObjectType, Field, ID } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' /* * TwinSpace — 테넌트 1급 공간(Site) 정의(ADR-0016). 트윈보다 먼저 세우는 몰입의 무대. * 공간이 보이는 방식(레이아웃·배경·영역)은 정규화됨: TwinSpaceRepresentation(map|image|model — * 이미지 배경은 attachment-base 참조 `config.imageUrl`) + TwinSpaceArea. 인스턴스는 model.spaceId 로 바인딩. * - geo = 사이트 지오레퍼런스, primaryRepresentationId = 홈/모니터 대표 표현. * - content(simple-json) 는 정규화 이전 레거시 단일 blob(@deprecated, P6 제거) — 신규 저작은 representation/area 로. * (CLAUDE.md: 모든 @ObjectType/@Field 영문 description 필수.) */ @Entity() @Index('ix_twin_space_0', (e: TwinSpace) => [e.domain, e.spaceId], { unique: true }) @ObjectType({ description: 'A tenant-level twin Space (Site) — the shared spatial stage set up before instances; its layout, backdrop and areas are normalized into representations (image backdrops via attachment-base) and areas.' }) export class TwinSpace { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the space record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinSpace) => e.domain) domainId?: string @Column() @Field({ description: 'Space id (unique within a domain).' }) spaceId: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Human-readable space name.' }) name?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Space description.' }) description?: string @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Site georeference { lat, lon, rotation } — pins the design frame to the earth so every representation lands in the same place. `rotation` is degrees clockwise from true north (bearing convention: 0 = north, 90 = east) by which the design frame is turned.' }) geo?: any /* * 설계 좌표계의 범위 — **미터**. `geo` 와 한 쌍이다. * * 구역(TwinArea.layout)은 표현 중립 설계 좌표 `{x, y, w, h}` 로 놓인다. 그 좌표가 어디까지인지를 * 이 둘이 정하고, `geo` 가 그것을 지구에 붙인다 — 지도로 보든 도면 이미지로 보든 보드로 보든 * 구역이 같은 자리에 있어야 하므로, 범위도 지오레퍼런스도 **표현이 아니라 공간**이 갖는다. * * 설계 좌표 (x, y) ──[extentX/extentY 로 중심 이동]──▶ 미터 오프셋 ──[geo]──▶ 위경도 * * `width`/`depth` 라 부르지 않는 이유: 컴포넌트 속성의 `depth` 는 높이 축(z)이다. 같은 화면에서 * 한 낱말이 두 축을 가리키면 읽는 사람이 매번 어느 쪽인지 물어야 한다. * 단위 필드는 두지 않는다 — 미터로 고정이고, 있으면 "단위를 지원한다" 는 거짓 신호가 된다. */ @Column({ type: 'float', nullable: true }) @Field({ nullable: true, description: 'Extent of the design frame along the east-west axis, in metres. Area layouts are placed within this frame.' }) extentX?: number @Column({ type: 'float', nullable: true }) @Field({ nullable: true, description: 'Extent of the design frame along the north-south axis, in metres. Area layouts are placed within this frame.' }) extentY?: number /** * 이 현장의 시각 기준 — IANA 이름(`'America/Tijuana'`). * * **테넌트가 아니라 공간이 갖는다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고, * 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 교대가 "06시" 라고 할 때 그것이 * 어느 06시인가는 **물리 현장의 성질**이다. * * 오프셋 숫자가 아니라 이름으로 둔다: 숫자로는 일광절약시간을 표현할 수 없고, 나중에 절대 구간 * (표준 `StartDateTime`)으로 옮길 때도 이름이 있어야 계산할 수 있다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'IANA time zone of this physical site (e.g. America/Tijuana) — the base for reading shift times. Belongs to the space, not the tenant, because one tenant can run plants in different zones.' }) timezone?: string /** * **이 현장이 언제 도나** — 표준 `WorkCalendarEntry`(복수). 휴일 · 주말 · 정기보수가 여기 든다. * * ── 왜 공간에 있나 (2026-09-07) ────────────────────────────────────────── * 이 칸이 없던 동안 설비와 사람만 달력을 가질 수 있었다. 그래서 **현장이 언제 쉬는지가 어디에도 * 없었고**, 계획 조업 시간이 「재기 시작한 뒤 흐른 시간 전부」였다 — 주말도 야간도 휴일도 계획 조업으로 * 세었다. [산술] 8시간 × 주5일이면 168시간 중 40시간이 조업이니 그 수가 4.2배 크다. * * 시간대와 같은 축이라 같은 자리에 둔다. 「이 현장의 06:00 이 언제인가」와 「이 현장이 언제 도나」는 * 둘 다 현장이 갖는 값이고, 테넌트가 아니다 — 한 테넌트가 다른 시간대·다른 조업일의 공장을 함께 가진다. * * `withSpaceTimeBase` 가 이 값을 모델에 얹고, 거기서 OEE 누적기가 계획 정지를 낸다. 얹는 자리를 * 시간대와 하나로 둔 이유는 **한쪽만 채워진 상태**를 만들지 않기 위해서다. * * 없으면 아무것도 가정하지 않는다 — 종일 가동으로 보는 것은 「제약이 없다」가 아니라 「선언되지 * 않았다」이고, 그 사실은 세어져야 한다(안 그러면 사람이 수를 되살리려고 아무 달력이나 채운다). */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'When this site runs — standard WorkCalendarEntry list (holidays, weekends, planned maintenance). Absent means undeclared, not unrestricted: planned busy time then counts the whole window.' }) workCalendar?: any /** * 현장의 **우편 주소** — 사람이 읽고 서류에 적는 값. * * ── 왜 좌표와 따로 두나 (2026-09-07) ──────────────────────────────────── * `geo` 는 도면을 지구에 붙이는 값이고 화면이 쓴다. 주소는 **사람과 서류의 값**이다 — 납품·통관· * 감사에 그 문자열이 그대로 들어가고, 좌표에서 되돌려 만들 수 없다(같은 좌표에 여러 주소가 있고 * 동·호가 좌표에 없다). * * 고객이 준 것을 그대로 적는다. 우편번호·동·호를 우리가 지어내거나 좌표에서 유추하지 않는다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Postal address of this site as the customer states it. Kept apart from `geo`: a floor or unit number has no coordinate, and paperwork carries this string verbatim.' }) address?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Id of the primary representation shown on the home/monitor.' }) primaryRepresentationId?: string /** * 이 **현장**의 로컬 선언 겹 — 요금처럼 **트윈이 아니라 현장이 정하는 수**. * * ── 왜 트윈에 두면 안 되나 (2026-08-18) ──────────────────────────────────── * 요금 단가·통화는 그 현장의 계약이므로, 같은 수전을 보는 EMS 트윈이 둘이면 **같은 값을 두 번** * 선언해야 했다. 두 번 적는 값은 반드시 갈라진다(하나만 고치는 날이 온다) — 그때 두 트윈이 다른 * 금액을 말하고, 어느 쪽이 맞는지 화면으로는 알 수 없다. * * 적용 순서는 **현장 먼저, 트윈이 나중**이다. 더 좁은 선언이 이긴다 — 한 현장 안에서 특정 트윈만 * 다른 값으로 실험하는 일이 있고(예: 인상된 단가로 what-if), 그때 현장 값을 고치게 만들면 나머지 * 트윈이 함께 흔들린다. * * 모양은 트윈 겹과 같다(`LocalDeclaration`) — 같은 규칙(검사·기억·철회)을 두 벌로 만들지 않는다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: "Locally-declared values that belong to this facility rather than to one twin (tariff, currency). Applied to every twin in the space before the twin's own declarations, which win when both name the same property." }) localDeclarations?: any @CreateDateColumn() @Field({ nullable: true, description: 'Timestamp when the space was created.' }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true, description: 'Timestamp when the space was last updated.' }) updatedAt?: Date }