340 lines
17 KiB
TypeScript
340 lines
17 KiB
TypeScript
import {
|
||
Column,
|
||
CreateDateColumn,
|
||
Entity,
|
||
Index,
|
||
JoinColumn,
|
||
ManyToOne,
|
||
PrimaryColumn,
|
||
PrimaryGeneratedColumn,
|
||
Unique,
|
||
UpdateDateColumn,
|
||
} from 'typeorm';
|
||
import type {
|
||
GeoJsonLineString,
|
||
GeoJsonMultiPolygon,
|
||
GeoJsonPoint,
|
||
MediaRequirement,
|
||
RoundStatus,
|
||
} from '@patrol/shared';
|
||
|
||
// TypeORM сам переводит geometry в GeoJSON при чтении и обратно при записи.
|
||
|
||
@Entity('organizations')
|
||
export class Organization {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'char', length: 12, unique: true }) bin: string;
|
||
@Column('text') name: string;
|
||
@Column({ type: 'text', name: 'legal_name', nullable: true }) legalName: string | null;
|
||
@Column({ type: 'text', name: 'legal_address', nullable: true }) legalAddress: string | null;
|
||
@Column({ type: 'text', name: 'director_name', nullable: true }) directorName: string | null;
|
||
@Column({ type: 'text', nullable: true }) phone: string | null;
|
||
@Column({ type: 'text', nullable: true }) email: string | null;
|
||
@Column({ type: 'jsonb', name: 'registry_data', nullable: true }) registryData: unknown | null;
|
||
@Column({ type: 'timestamptz', name: 'registry_synced_at', nullable: true }) registrySyncedAt: Date | null;
|
||
@Column({ type: 'text', default: 'active' }) status: 'active' | 'blocked';
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('roles')
|
||
export class Role {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||
@Column('text') code: string;
|
||
@Column('text') name: string;
|
||
@Column({ type: 'boolean', name: 'is_system', default: false }) isSystem: boolean;
|
||
@Column({ type: 'boolean', name: 'all_sites', default: false }) allSites: boolean;
|
||
@Column({ type: 'text', array: true, default: () => "'{}'" }) permissions: string[];
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('users')
|
||
export class User {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null;
|
||
// Человек из паспорта. Пароли и вход — только через паспорт / Aspans ID.
|
||
@Column({ type: 'uuid', name: 'passport_uuid', unique: true, nullable: true }) passportUuid: string | null;
|
||
// Логин паспорта (username) — для отображения.
|
||
@Column({ type: 'text', nullable: true }) login: string | null;
|
||
@Column({ type: 'text', name: 'last_name', nullable: true }) lastName: string | null;
|
||
@Column({ type: 'text', name: 'first_name', nullable: true }) firstName: string | null;
|
||
@Column({ type: 'text', name: 'middle_name', nullable: true }) middleName: string | null;
|
||
@Column({ type: 'text', name: 'full_name' }) fullName: string;
|
||
@Column({ type: 'text', nullable: true }) email: string | null;
|
||
@Column({ type: 'text', nullable: true }) phone: string | null;
|
||
@Column({ type: 'uuid', name: 'role_id', nullable: true }) roleId: string | null;
|
||
@ManyToOne(() => Role, { nullable: true })
|
||
@JoinColumn({ name: 'role_id' })
|
||
role: Role | null;
|
||
// Доступ в patrol (выключает администратор организации). Паспорт этим не управляет.
|
||
@Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean;
|
||
// is_enabled из паспорта: отключили человека в паспорте — не пускаем и сюда.
|
||
@Column({ type: 'boolean', name: 'passport_enabled', default: true }) passportEnabled: boolean;
|
||
// Убрали с платформы patrol в паспорте (или удалили из организации здесь). Строку не удаляем:
|
||
// на человека ссылаются обходы, отметки и фото в отчётах.
|
||
@Column({ type: 'timestamptz', name: 'removed_at', nullable: true }) removedAt: Date | null;
|
||
@Column({ type: 'boolean', name: 'is_platform_admin', default: false }) isPlatformAdmin: boolean;
|
||
@Column({ type: 'timestamptz', name: 'last_login_at', nullable: true }) lastLoginAt: Date | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||
}
|
||
|
||
@Entity('user_sites')
|
||
export class UserSite {
|
||
@PrimaryColumn({ type: 'uuid', name: 'user_id' }) userId: string;
|
||
@PrimaryColumn({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||
}
|
||
|
||
@Entity('user_sessions')
|
||
export class UserSession {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'user_id' }) userId: string;
|
||
@Column({ type: 'text', name: 'refresh_hash' }) refreshHash: string;
|
||
@Column({ type: 'text', name: 'user_agent', nullable: true }) userAgent: string | null;
|
||
@Column({ type: 'timestamptz', name: 'expires_at' }) expiresAt: Date;
|
||
@Column({ type: 'timestamptz', name: 'revoked_at', nullable: true }) revokedAt: Date | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('audit_log')
|
||
export class AuditLog {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null;
|
||
@Column({ type: 'uuid', name: 'user_id', nullable: true }) userId: string | null;
|
||
@Column('text') action: string;
|
||
@Column({ type: 'text', nullable: true }) entity: string | null;
|
||
@Column({ type: 'text', name: 'entity_id', nullable: true }) entityId: string | null;
|
||
@Column({ type: 'jsonb', nullable: true }) changes: unknown | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('sites')
|
||
export class Site {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||
@Column('text') name: string;
|
||
@Column({ type: 'text', nullable: true }) address: string | null;
|
||
@Column({ type: 'text', nullable: true }) description: string | null;
|
||
@Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true })
|
||
perimeter: GeoJsonMultiPolygon | null;
|
||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||
center: GeoJsonPoint | null;
|
||
@Column({ type: 'text', default: 'Asia/Almaty' }) timezone: string;
|
||
@Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||
}
|
||
|
||
@Entity('routes')
|
||
export class Route {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||
@Column('text') name: string;
|
||
@Column({ type: 'text', nullable: true }) description: string | null;
|
||
@Column({ type: 'geometry', spatialFeatureType: 'LineString', srid: 4326, nullable: true })
|
||
path: GeoJsonLineString | null;
|
||
@Column({ type: 'int', name: 'expected_minutes', nullable: true }) expectedMinutes: number | null;
|
||
@Column({ type: 'boolean', name: 'strict_order', default: false }) strictOrder: boolean;
|
||
@Column({ type: 'int', default: 1 }) version: number;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||
}
|
||
|
||
@Entity('checkpoints')
|
||
export class Checkpoint {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'route_id' }) routeId: string;
|
||
@Column('int') position: number;
|
||
@Column('text') name: string;
|
||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 }) location: GeoJsonPoint;
|
||
@Column({ type: 'int', name: 'radius_m', default: 30 }) radiusM: number;
|
||
@Column({ type: 'text', name: 'media_required', default: 'photo' }) mediaRequired: MediaRequirement;
|
||
@Column({ type: 'text', nullable: true }) instruction: string | null;
|
||
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||
}
|
||
|
||
// Пункт чеклиста — общий для всех чеклистов компании.
|
||
@Entity('checklist_items')
|
||
export class ChecklistItem {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||
@Column('text') text: string;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('checklists')
|
||
export class Checklist {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||
@Column('text') name: string;
|
||
@Column({ type: 'text', nullable: true }) description: string | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||
}
|
||
|
||
@Entity('checklist_entries')
|
||
export class ChecklistEntry {
|
||
@PrimaryColumn({ type: 'uuid', name: 'checklist_id' }) checklistId: string;
|
||
@PrimaryColumn({ type: 'uuid', name: 'item_id' }) itemId: string;
|
||
@Column('int') position: number;
|
||
}
|
||
|
||
@Entity('checkpoint_checklists')
|
||
export class CheckpointChecklist {
|
||
@PrimaryColumn({ type: 'uuid', name: 'checkpoint_id' }) checkpointId: string;
|
||
@PrimaryColumn({ type: 'uuid', name: 'checklist_id' }) checklistId: string;
|
||
@Column('int') position: number;
|
||
}
|
||
|
||
@Entity('patrol_schedules')
|
||
export class PatrolSchedule {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||
@Column({ type: 'uuid', name: 'route_id' }) routeId: string;
|
||
@Column('text') name: string;
|
||
// Время старта 'HH:MM' в часовом поясе объекта
|
||
@Column({ type: 'text', name: 'start_times', array: true }) startTimes: string[];
|
||
@Column({ type: 'int', name: 'window_minutes', default: 60 }) windowMinutes: number;
|
||
@Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number;
|
||
@Column({ type: 'date', name: 'valid_from' }) validFrom: string;
|
||
@Column({ type: 'date', name: 'valid_to', nullable: true }) validTo: string | null;
|
||
@Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('schedule_assignments')
|
||
@Unique(['scheduleId', 'userId', 'date'])
|
||
export class ScheduleAssignment {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'schedule_id' }) scheduleId: string;
|
||
@Column({ type: 'uuid', name: 'user_id' }) userId: string;
|
||
@Column({ type: 'date' }) date: string;
|
||
@Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
export type RouteSnapshot = {
|
||
version: number;
|
||
name: string;
|
||
strictOrder: boolean;
|
||
path: GeoJsonLineString | null;
|
||
checkpoints: {
|
||
id: string;
|
||
position: number;
|
||
name: string;
|
||
location: GeoJsonPoint;
|
||
radiusM: number;
|
||
mediaRequired: MediaRequirement;
|
||
instruction: string | null;
|
||
// В снимках до появления чеклистов поля нет — читать как [].
|
||
checklists?: SnapshotChecklist[];
|
||
}[];
|
||
};
|
||
|
||
export type SnapshotChecklist = { id: string; name: string; items: { id: string; text: string }[] };
|
||
|
||
// Ответ охранника по пункту. Текст копируем: отчёт читается и после правки пункта.
|
||
export type ChecklistAnswer = { checklistId: string; itemId: string; text: string; checked: boolean };
|
||
|
||
@Entity('rounds')
|
||
@Index(['siteId', 'plannedStart'])
|
||
export class Round {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||
@Column({ type: 'uuid', name: 'route_id' }) routeId: string;
|
||
@Column({ type: 'uuid', name: 'schedule_id', nullable: true }) scheduleId: string | null;
|
||
// Дата графика (в поясе объекта), по ней ищем дежурных
|
||
@Column({ type: 'date', name: 'duty_date', nullable: true }) dutyDate: string | null;
|
||
@Column({ type: 'uuid', name: 'performed_by', nullable: true }) performedBy: string | null;
|
||
@Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null;
|
||
@Column({ type: 'timestamptz', name: 'planned_start' }) plannedStart: Date;
|
||
@Column({ type: 'timestamptz', name: 'planned_end' }) plannedEnd: Date;
|
||
@Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number;
|
||
@Column({ type: 'timestamptz', name: 'started_at', nullable: true }) startedAt: Date | null;
|
||
@Column({ type: 'timestamptz', name: 'finished_at', nullable: true }) finishedAt: Date | null;
|
||
@Column({ type: 'text', default: 'planned' }) status: RoundStatus;
|
||
@Column({ type: 'jsonb', name: 'route_snapshot' }) routeSnapshot: RouteSnapshot;
|
||
@Column({ type: 'int', name: 'checkpoints_total', default: 0 }) checkpointsTotal: number;
|
||
@Column({ type: 'int', name: 'checkpoints_done', default: 0 }) checkpointsDone: number;
|
||
@Column({ type: 'text', nullable: true }) comment: string | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||
}
|
||
|
||
@Entity('checkpoint_visits')
|
||
export class CheckpointVisit {
|
||
// id генерирует телефон: повтор из офлайн-очереди не создаёт дубль
|
||
@PrimaryColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'round_id' }) roundId: string;
|
||
@Column({ type: 'uuid', name: 'checkpoint_id', nullable: true }) checkpointId: string | null;
|
||
@Column({ type: 'uuid', name: 'user_id' }) userId: string;
|
||
@Column({ type: 'geometry', name: 'marked_location', spatialFeatureType: 'Point', srid: 4326 })
|
||
markedLocation: GeoJsonPoint;
|
||
@Column({ type: 'geometry', name: 'device_location', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||
deviceLocation: GeoJsonPoint | null;
|
||
@Column({ type: 'real', name: 'device_accuracy_m', nullable: true }) deviceAccuracyM: number | null;
|
||
@Column({ type: 'real', name: 'distance_to_checkpoint_m', nullable: true }) distanceToCheckpointM: number | null;
|
||
@Column({ type: 'real', name: 'gps_distance_m', nullable: true }) gpsDistanceM: number | null;
|
||
@Column({ type: 'text', array: true, default: () => "'{}'" }) flags: string[];
|
||
@Column({ type: 'text', nullable: true }) note: string | null;
|
||
@Column({ type: 'boolean', name: 'is_issue', default: false }) isIssue: boolean;
|
||
@Column({ type: 'jsonb', default: () => "'[]'" }) checklist: ChecklistAnswer[];
|
||
@Column({ type: 'timestamptz', name: 'client_created_at' }) clientCreatedAt: Date;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('media')
|
||
export class Media {
|
||
// id тоже генерирует телефон
|
||
@PrimaryColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||
@Column({ type: 'uuid', name: 'uploaded_by' }) uploadedBy: string;
|
||
@Column({ type: 'uuid', name: 'visit_id', nullable: true }) visitId: string | null;
|
||
@Column('text') kind: 'photo' | 'video';
|
||
@Column('text') mime: string;
|
||
@Column({ type: 'bigint', name: 'size_bytes' }) sizeBytes: string;
|
||
@Column({ type: 'text', nullable: true }) filename: string | null;
|
||
@Column({ type: 'text', name: 'storage_key' }) storageKey: string;
|
||
@Column({ type: 'text', name: 'upload_id', nullable: true }) uploadId: string | null;
|
||
@Column({ type: 'jsonb', default: () => "'[]'" }) parts: { partNumber: number; etag: string; size: number }[];
|
||
@Column({ type: 'text', default: 'uploading' }) status: 'uploading' | 'ready';
|
||
@Column({ type: 'timestamptz', name: 'captured_at', nullable: true }) capturedAt: Date | null;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
@Entity('webhooks')
|
||
export class Webhook {
|
||
@PrimaryGeneratedColumn('uuid') id: string;
|
||
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||
@Column('text') url: string;
|
||
@Column({ type: 'text', array: true }) events: string[];
|
||
@Column('text') secret: string;
|
||
@Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean;
|
||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||
}
|
||
|
||
export const ENTITIES = [
|
||
Organization,
|
||
Role,
|
||
User,
|
||
UserSite,
|
||
UserSession,
|
||
AuditLog,
|
||
Site,
|
||
Route,
|
||
Checkpoint,
|
||
ChecklistItem,
|
||
Checklist,
|
||
ChecklistEntry,
|
||
CheckpointChecklist,
|
||
PatrolSchedule,
|
||
ScheduleAssignment,
|
||
Round,
|
||
CheckpointVisit,
|
||
Media,
|
||
Webhook,
|
||
];
|