import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; import { Check, Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique, } from "typeorm"; import { JobTitle } from "../../job-titles/entities/job-title.entity"; /** * The headcount / vacancy layer over `iam.positions`. * * Stores NOTHING IAM already holds — not the unit, not the organization. Both * come from `iam.positions` and are read by joining. A copy here would be a * second answer that goes stale the moment a position is moved. * * It EXTENDS a position, never replaces it: `positionId` is a soft reference to * the IAM slot, unique here so a position cannot acquire two competing headcount * records. IAM keeps owning who holds the position; HR adds what it is graded as, * how many bodies it is budgeted for, and whether it is open for recruitment * (which 3.5 reads when creating a job opening). */ @Entity({ schema: "hr", name: "job_positions" }) @Unique("uq_job_positions_position_id", ["positionId"]) @Check("ck_job_positions_budgeted_count", `"budgeted_count" >= 0`) @Check("ck_job_positions_current_count", `"current_count" >= 0`) export class JobPosition extends SoftDeleteAudit { @PrimaryGeneratedColumn("uuid") id!: string; /** Soft reference → `iam.positions.id`. */ @Column({ type: "uuid", name: "position_id" }) positionId!: string; @Column({ type: "uuid", name: "job_title_id", nullable: true }) jobTitleId?: string | null; @ManyToOne(() => JobTitle, { nullable: true, onDelete: "SET NULL" }) @JoinColumn({ name: "job_title_id" }) jobTitle?: JobTitle | null; @Column({ type: "int", name: "budgeted_count", default: 1 }) budgetedCount!: number; /** * Cache of the current holder count in IAM. Refreshed from * `IamDirectoryService.countCurrentPositionHolders` whenever the position is * read or written through HR — IAM stays the source of truth, this column just * makes "which posts are under-filled" answerable in one query. */ @Column({ type: "int", name: "current_count", default: 0 }) currentCount!: number; /** Open for recruitment. Independent of vacancy: a post can be under-filled * and still frozen, which is exactly what a hiring freeze is. */ @Column({ type: "boolean", name: "is_open", default: false }) isOpen!: boolean; @Column({ type: "uuid", name: "created_by", nullable: true }) createdBy?: string | null; }