🚪

TypeScript + WebSocketでリアルタイム客室管理システム

に公開

はじめに

ホテルの客室管理では、清掃状況やチェックイン/アウトの状態をリアルタイムで把握することが運営効率に直結します。Angular + WebSocket + NgRxで、複数スタッフが同時に使用できる客室管理システムを実装します。

客室状態の定義とモデル設計

// models/room.model.ts
export enum RoomStatus {
  VACANT_CLEAN = 'vacant_clean',      // 空室・清掃済
  VACANT_DIRTY = 'vacant_dirty',      // 空室・要清掃
  OCCUPIED_CLEAN = 'occupied_clean',  // 在室・清掃済
  OCCUPIED_DIRTY = 'occupied_dirty',  // 在室・要清掃
  CLEANING = 'cleaning',               // 清掃中
  INSPECTION = 'inspection',           // 点検中
  OUT_OF_ORDER = 'out_of_order'       // 使用不可
}

export interface Room {
  id: string;
  roomNumber: string;
  roomType: string;
  floor: number;
  status: RoomStatus;
  currentGuest?: Guest;
  nextGuest?: Guest;
  cleaningStaff?: Staff;
  lastStatusChange: Date;
  estimatedCleaningTime?: number;  // 分単位
  notes?: string;
}

export interface RoomEvent {
  type: 'STATUS_CHANGED' | 'GUEST_CHECKED_IN' | 'GUEST_CHECKED_OUT' | 'CLEANING_STARTED' | 'CLEANING_COMPLETED';
  roomId: string;
  payload: any;
  timestamp: Date;
  userId: string;
}

WebSocketサービスの実装

// services/room-websocket.service.ts
import { Injectable } from '@angular/core';
import { Observable, Subject, timer } from 'rxjs';
import { retryWhen, tap, delayWhen } from 'rxjs/operators';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';

@Injectable({
  providedIn: 'root'
})
export class RoomWebSocketService {
  private socket$: WebSocketSubject<any>;
  private messagesSubject$ = new Subject<RoomEvent>();
  public messages$ = this.messagesSubject$.asObservable();
  private reconnectInterval = 5000;
  private reconnectAttempts = 10;

  constructor() {
    this.connect();
  }

  private connect(): void {
    const wsUrl = `${environment.wsUrl}/rooms`;
    
    this.socket$ = webSocket({
      url: wsUrl,
      openObserver: {
        next: () => {
          console.log('WebSocket connected');
          this.sendHeartbeat();
        }
      },
      closeObserver: {
        next: () => {
          console.log('WebSocket disconnected');
          this.reconnect();
        }
      }
    });

    this.socket$.pipe(
      retryWhen(errors =>
        errors.pipe(
          tap(err => console.error('WebSocket error:', err)),
          delayWhen(_ => timer(this.reconnectInterval))
        )
      )
    ).subscribe(
      msg => this.messagesSubject$.next(msg),
      err => console.error('WebSocket error:', err),
      () => console.warn('WebSocket connection closed')
    );
  }

  private sendHeartbeat(): void {
    timer(0, 30000).subscribe(() => {
      if (this.socket$) {
        this.socket$.next({ type: 'PING' });
      }
    });
  }

  public sendMessage(event: RoomEvent): void {
    if (this.socket$) {
      this.socket$.next(event);
    }
  }

  public updateRoomStatus(roomId: string, status: RoomStatus): void {
    const event: RoomEvent = {
      type: 'STATUS_CHANGED',
      roomId,
      payload: { status },
      timestamp: new Date(),
      userId: this.getCurrentUserId()
    };
    this.sendMessage(event);
  }

  private reconnect(): void {
    setTimeout(() => this.connect(), this.reconnectInterval);
  }
}

NgRxによる状態管理

// store/room.state.ts
export interface RoomState {
  rooms: { [id: string]: Room };
  loading: boolean;
  error: string | null;
  filter: RoomFilter;
  lastUpdate: Date | null;
}

// store/room.actions.ts
import { createAction, props } from '@ngrx/store';

export const loadRooms = createAction('[Room] Load Rooms');
export const loadRoomsSuccess = createAction(
  '[Room] Load Rooms Success',
  props<{ rooms: Room[] }>()
);
export const updateRoomStatus = createAction(
  '[Room] Update Status',
  props<{ roomId: string; status: RoomStatus }>()
);
export const roomStatusUpdated = createAction(
  '[Room WS] Status Updated',
  props<{ roomId: string; status: RoomStatus; timestamp: Date }>()
);
export const startCleaning = createAction(
  '[Room] Start Cleaning',
  props<{ roomId: string; staffId: string }>()
);

// store/room.reducer.ts
export const roomReducer = createReducer(
  initialState,
  on(loadRoomsSuccess, (state, { rooms }) => ({
    ...state,
    rooms: rooms.reduce((acc, room) => ({ ...acc, [room.id]: room }), {}),
    loading: false,
    lastUpdate: new Date()
  })),
  on(roomStatusUpdated, (state, { roomId, status, timestamp }) => ({
    ...state,
    rooms: {
      ...state.rooms,
      [roomId]: {
        ...state.rooms[roomId],
        status,
        lastStatusChange: timestamp
      }
    },
    lastUpdate: timestamp
  })),
  on(startCleaning, (state, { roomId, staffId }) => ({
    ...state,
    rooms: {
      ...state.rooms,
      [roomId]: {
        ...state.rooms[roomId],
        status: RoomStatus.CLEANING,
        cleaningStaff: { id: staffId },
        lastStatusChange: new Date()
      }
    }
  }))
);

// store/room.effects.ts
@Injectable()
export class RoomEffects {
  constructor(
    private actions$: Actions,
    private roomService: RoomService,
    private wsService: RoomWebSocketService,
    private store: Store
  ) {}

  loadRooms$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadRooms),
      switchMap(() =>
        this.roomService.getRooms().pipe(
          map(rooms => loadRoomsSuccess({ rooms })),
          catchError(error => of(loadRoomsFailure({ error })))
        )
      )
    )
  );

  wsMessages$ = createEffect(() =>
    this.wsService.messages$.pipe(
      map(event => {
        switch (event.type) {
          case 'STATUS_CHANGED':
            return roomStatusUpdated({
              roomId: event.roomId,
              status: event.payload.status,
              timestamp: event.timestamp
            });
          case 'CLEANING_COMPLETED':
            return cleaningCompleted({ roomId: event.roomId });
          default:
            return { type: 'NO_ACTION' };
        }
      })
    )
  );

  updateStatus$ = createEffect(() =>
    this.actions$.pipe(
      ofType(updateRoomStatus),
      tap(({ roomId, status }) => {
        this.wsService.updateRoomStatus(roomId, status);
      })
    ), { dispatch: false }
  );
}

リアルタイムダッシュボードコンポーネント

// components/room-dashboard.component.ts
@Component({
  selector: 'app-room-dashboard',
  template: `
    <div class="room-grid">
      <div *ngFor="let room of rooms$ | async" 
           class="room-card"
           [ngClass]="getRoomStatusClass(room.status)"
           (click)="onRoomClick(room)">
        <div class="room-number">{{ room.roomNumber }}</div>
        <div class="room-status">{{ getStatusLabel(room.status) }}</div>
        <div class="room-guest" *ngIf="room.currentGuest">
          {{ room.currentGuest.name }}
        </div>
        <div class="cleaning-progress" *ngIf="room.status === 'cleaning'">
          <mat-progress-bar mode="determinate" [value]="getCleaningProgress(room)">
          </mat-progress-bar>
        </div>
        <div class="last-update">
          {{ getTimeSinceUpdate(room.lastStatusChange) }}
        </div>
      </div>
    </div>

    <div class="status-summary">
      <div *ngFor="let stat of statusSummary$ | async" class="summary-item">
        <span class="status-label">{{ stat.label }}</span>
        <span class="status-count">{{ stat.count }}</span>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RoomDashboardComponent implements OnInit, OnDestroy {
  rooms$: Observable<Room[]>;
  statusSummary$: Observable<StatusSummary[]>;
  private destroy$ = new Subject<void>();

  constructor(private store: Store<AppState>) {
    this.rooms$ = this.store.select(selectAllRooms);
    this.statusSummary$ = this.store.select(selectStatusSummary);
  }

  ngOnInit(): void {
    this.store.dispatch(loadRooms());
    
    // 30秒ごとに自動リフレッシュ
    interval(30000).pipe(
      takeUntil(this.destroy$)
    ).subscribe(() => {
      this.store.dispatch(loadRooms());
    });
  }

  onRoomClick(room: Room): void {
    const dialogRef = this.dialog.open(RoomActionDialog, {
      data: { room }
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result?.action === 'updateStatus') {
        this.store.dispatch(updateRoomStatus({
          roomId: room.id,
          status: result.status
        }));
      }
    });
  }

  getCleaningProgress(room: Room): number {
    if (!room.estimatedCleaningTime) return 0;
    const elapsed = Date.now() - room.lastStatusChange.getTime();
    return Math.min(100, (elapsed / (room.estimatedCleaningTime * 60000)) * 100);
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

まとめ

WebSocketによるリアルタイム通信とNgRxの状態管理を組み合わせることで、複数のスタッフが同時に操作しても整合性を保つ客室管理システムを実現できます。自動再接続やハートビート機能により、ネットワーク障害にも強い設計となっています。

次回は、このシステムと連携する予約確認メールの自動化について解説します。

Discussion