import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' import { ObjectType, Field, ID, Float } from 'type-graphql' import { Domain } from '@things-factory/shell' import { User } from '@things-factory/auth-base' /* * TwinTarget — **운영 목표.** "이 구간의 처리량이 목표에 닿았나" 를 판단하는 기준선. * * ── 왜 트윈이 갖나 (사용자 결정 2026-08-02) ───────────────────────────────── * `@things-factory/kpi` 와 **경계를 나눈다**: * 트윈(여기) 이 구간·이 교대·이 공간의 **즉시 판단**. 현장 관리자가 트윈 화면에서 고친다. * 값은 저널 폴드에서 그때그때 계산된다(저장된 값을 읽지 않는다). * packages/kpi **기간 평가**(조직·분기·점수·가중치·알림). 값이 저장돼 있어야 도는 체계다. * * 지키는 규칙 하나: **트윈은 경영 목표를 복제하지 않는다.** 조직 단위 평가가 필요하면 트윈의 사실을 * 내보내 그쪽이 소비하고, 그쪽 목표를 여기로 베껴 오지 않는다 — 베끼는 순간 두 개의 진실이 된다. * * ── 범위 (scope) ─────────────────────────────────────────────────────────── * `spaceId` 로 현장 전체에, `instanceId` 로 트윈 하나에 건다. 둘 다 있으면 **인스턴스가 이긴다** * (좁은 선언이 넓은 선언을 덮는다 — 예외를 표현할 수 있어야 한다). * * 관점 축별 목표(구역마다 다른 목표)는 **아직 아니다** — 축 교차는 목표가 화면에서 쓰이기 시작한 뒤의 * 일이고, 처음부터 넣으면 쓰이지 않는 구조가 먼저 굳는다(performance.md §5.4-5). * * (CLAUDE.md: 모든 @ObjectType/@Field 영문 description 필수.) */ @Entity() @Index('ix_twin_target_0', (e: TwinTarget) => [e.domain, e.spaceId, e.instanceId, e.metric, e.axis, e.axisKey], { unique: true }) @ObjectType({ description: 'An operational performance target for a twin metric — the line that turns a measured number into a judgement. Scoped to a space or to a single instance (instance wins over space, so an exception can be expressed). Distinct from @things-factory/kpi targets, which evaluate organizations over periods; twin targets are for immediate, on-site judgement and are never copied from the management KPI system.' }) export class TwinTarget { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the target record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinTarget) => e.domain) domainId?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Space this target applies to (site-wide). Empty when the target is bound to a single instance.' }) spaceId?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Instance this target applies to. When both space and instance match, the instance target wins so an exception can be expressed.' }) instanceId?: string /** * 관점 축과 그 값 — **"이 구역만", "이 교대만" 목표가 다르다.** * * 둘 다 비어 있으면 **전체 목표**다(창 전체의 집계를 판정한다). 축 목표는 전체 목표를 덮지 않는다 — * 다른 질문이기 때문이다: "현장 전체가 100건" 과 "용접 구역이 30건" 은 동시에 참일 수 있다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Perspective axis this target applies to (area, shift, resource, taskKind, location, order). Empty means the target judges the window total. An axis target does not override the total — "the site does 100" and "the weld area does 30" are both true at once.' }) axis?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'The value on that axis (an area id, a shift name…). Required when axis is set.' }) axisKey?: string @Column() @Field({ description: 'Metric id from the twin metric vocabulary (throughput.tasks, workTime.p50Ms, …). Names are fixed in one place so target and fact never drift into two dialects.' }) metric: string @Column({ type: 'float' }) @Field(type => Float, { description: 'The target value, in the metric\'s own unit (count, milliseconds, ratio). Whether higher or lower is better is a property of the metric, not of this record — so a target can never be declared in the wrong direction.' }) target: number @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true, description: 'User who created this target.' }) creator?: User @RelationId((e: TwinTarget) => e.creator) creatorId?: string @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true, description: 'User who last updated this target.' }) updater?: User @RelationId((e: TwinTarget) => e.updater) updaterId?: string @CreateDateColumn() @Field({ nullable: true, description: 'Timestamp when the target was created.' }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true, description: 'Timestamp when the target was last updated.' }) updatedAt?: Date }