> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.vpms.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Room cleanState / state 생애주기

> Room.cleanState/state/lastCleanedAt이 언제 누구에 의해 바뀌는지, 클라이언트에 어떻게 노출되는지, 자동청소 정책(needsAutoCleaning)이 여기에 어떻게(안) 관여하는지 정리한 core-svc 내부 참고 문서

대상 독자: **core-svc의 Room/Reservation 도메인을 수정하는 개발자.**

이 문서 하나만 읽으면 `Room.cleanState`/`Room.state`/`Room.lastCleanedAt`이 언제·누구에 의해
바뀌는지, 클라이언트에 어떻게 노출되는지, 그리고 "자동청소 정책"이 여기에 어떻게(안) 관여하는지
파악할 수 있도록 정리한다.

***

## 1. 데이터 모델

`apps/core-svc/src/domains/room/schema.prisma`:

```prisma theme={null}
model Room {
  state               RoomState  @default(NOT_USING)
  cleanState          CleanState @default(CLEAN)
  stateUpdatedAt      DateTime   @default(now())
  cleanStateUpdatedAt DateTime   @default(now())
  lastCleanedAt       DateTime?
  ...
}
```

`apps/core-svc/src/domains/base.prisma`:

```prisma theme={null}
enum RoomState {
  NOT_USING
  USING
  LEFT
  POWER_DOWN
  SELECTED
}

enum CleanState {
  DIRTY
  CLEANING
  INSPECTION
  NEED_CLEANING
  NEED_INSPECTION
  URGENT_CLEANING
  CLEAN
}
```

* `state` — 객실의 **물리적 사용 상태** (투숙 중/미사용/전원차단 등). 자동청소 정책과 무관.
* `cleanState` — 객실의 **청소 상태**. 이 문서의 핵심.
* `lastCleanedAt` — `cleanState`가 `CLEAN`으로 바뀐 마지막 시각. `CLEAN` 전환이 아니면 갱신되지 않는다.
  자동청소 정책의 idempotency("오늘 이미 청소됐는가") 판단에 쓰이는 유일한 필드.

`CalculatedState`(GraphQL enum, `room.gql`)는 DB 컬럼이 아니라 **매 조회 시점에 계산되는 파생 값**이다
(§3.2).

***

## 2. 쓰기 경로 — 전부 이벤트소싱

`cleanState`/`state`/`lastCleanedAt`은 **오직 `room.saga.ts`(ROOM aggregate)의 두 이벤트 핸들러를
통해서만** 바뀐다. 리졸버가 직접 `prisma.room.update`를 호출하는 경로는 없다.

```
resolver → eventManager.store(EVENT) → afterStore.waitSaga() → room.saga 핸들러 → prisma.room.update
```

### 2.1 `CLEAN_STATE_CHANGED` 핸들러

```ts theme={null}
// room.saga.ts
case EVENT_TYPES.ROOM.CLEAN_STATE_CHANGED: {
  const updated = await context.prisma.room.update({
    where: { id },
    data: {
      cleanState: roomData.cleanState,
      cleanStateUpdatedAt: new Date(),
      ...(roomData.cleanState === CleanState.CLEAN && { lastCleanedAt: new Date() }),
      revision,
    },
  });
  resultData = updated;
  break;
}
```

`cleanState`가 `CLEAN`으로 바뀔 때만 `lastCleanedAt`도 함께 갱신된다.

### 2.2 `STATE_CHANGED_BY_STAFF` 핸들러

```ts theme={null}
case EVENT_TYPES.ROOM.STATE_CHANGED_BY_STAFF: {
  await context.prisma.$transaction(async tx => {
    const updated = await tx.room.update({
      where: { id },
      data: {
        ...(roomData.state !== undefined && {
          state: roomData.state,
          stateUpdatedAt: new Date(),
        }),
        ...(roomData.cleanState !== undefined && {
          cleanState: roomData.cleanState,
          cleanStateUpdatedAt: new Date(),
          ...(roomData.cleanState === CleanState.CLEAN && { lastCleanedAt: new Date() }),
        }),
        revision,
      },
    });
    resultData = updated;
  });
  break;
}
```

이벤트 이름과 달리 `state`와 `cleanState`를 **각각 독립적으로**, 페이로드에 존재할 때만 갱신한다.
현재 이 이벤트를 발행하는 유일한 호출자(`setRoomStateByStaff`, §2.3)는 `cleanState`만 보내므로
실질적으로 `state` 분기는 항상 no-op이지만, 핸들러 구조상 두 필드는 서로 독립이다.

### 2.3 core-svc 안에서 이 이벤트들을 발행하는 곳 (전체 5곳)

| 위치                                                              | 트리거                                              | 쓰는 값                                                                                  |
| --------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `setRoomCleanState` mutation (`room.resolver.ts`)               | 스태프가 임의의 `CleanState`로 직접 변경                     | 지정한 값 그대로                                                                             |
| `setRoomStateByStaff` mutation (`room.resolver.ts`)             | 메이드가 버튼으로 청소 단계 진행                               | `DIRTY`/`NEED_CLEANING` → `CLEANING` → `CLEAN` (그 외 값이면 `ROOM_STATE_NOT_MATCH` throw) |
| `reservationCheckOut` (`reservation.resolver.ts`)               | 스태프가 체크아웃 처리                                     | `NEED_CLEANING`                                                                       |
| `changeReservationRoom` (`reservation.resolver.ts`)             | 재실 중(CHECKED\_IN) 예약을 다른 방으로 재배정 — **이전 방에만** 적용 | `NEED_CLEANING`                                                                       |
| `checkOutReservationByGuestSession` (`reservation.resolver.ts`) | 게스트 셀프 체크아웃(키오스크/세션)                             | `NEED_CLEANING`                                                                       |

즉 **core-svc가 직접 아는 범위**에서는 `NEED_CLEANING`이 "퇴실(혹은 방 이동으로 인한 공실화)"
시점에만 쓰인다. `CLEAN`도 마찬가지로 core-svc 안에서는 `setRoomCleanState`/`setRoomStateByStaff`,
즉 사람(스태프/메이드)의 명시적 조작으로만 쓰인다. CCU가 별도 경로로 쓰는지는 §2.4 참고.

### 2.4 saga의 부수효과 — CCU 물리 신호 전송

ROOM aggregate의 모든 `build` 호출마다 `sendRoomControlByEvent` 프로세서가 **무조건** 실행되어,
방금 반영된 `state`/`cleanState`에 대응하는 `RCU_CONTROL` 패킷을 CCU로 쏜다:

| 조건                                                               | 전송 신호               |
| ---------------------------------------------------------------- | ------------------- |
| `state === POWER_DOWN` (신규)                                      | `powerDown`         |
| 기존 `state`가 `POWER_DOWN`이었음                                      | `powerUp`           |
| 이벤트가 `CHECK_OUT`                                                 | `setCheckOut`       |
| `state === USING`                                                | `setUsing`          |
| `cleanState ∈ {DIRTY, NEED_CLEANING, URGENT_CLEANING, CLEANING}` | `setNeedClean`      |
| `cleanState ∈ {INSPECTION, NEED_INSPECTION}`                     | `setNeedInspection` |
| `cleanState === CLEAN` 또는 `state === NOT_USING`                  | `setAvailable`      |

***

## 3. 읽기 경로 — 클라이언트에 노출되는 두 계산 필드

### 3.1 `Room.cleanState` 필드 리졸버 — 카드 상태 오버라이드

```ts theme={null}
cleanState: async (parent, args, context) => {
  const cardState = await getCardState(cardDevices?.[0]);
  if (cardState === CardState.CLEAN) {
    return CleanState.CLEANING;
  }
  return parent.cleanState ?? null;
},
```

문 잠금장치에 "청소 카드"가 꽂혀 있으면(`CardState.CLEAN`), DB 값과 무관하게 무조건
`CLEANING`을 반환한다. 그 외에는 DB의 원본 값을 그대로 내려준다.

### 3.2 `Room.calculatedState` — 우선순위 캐스케이드

DB 컬럼이 아니라 매 조회 시 계산되는 값. 순서대로 먼저 매칭되는 조건이 이긴다:

1. `state === SELECTED` → `selected`
2. `state === POWER_DOWN` → `powerDown`
3. (타임라인/아웃오브세일 없음 + Redis 상 임시점유 있음) → `ephemeralOccupied`
4. 아웃오브세일/아웃오브오더 활성 → `outOfService(Suspicious)` / `outOfOrder(Suspicious)`
5. 활성 예약 타임라인 있음(현재/최근 종료분 포함):
   * `CHECKED_IN` + `state === LEFT` → `left{Lodge|Rent|LongTerm}`
   * `CHECKED_IN` + 퇴실예정시각 + `assumeCheckoutBefore` 지남 + 카드 NULL + `!defaultCheckedIn` → `expiredEst`
   * `CHECKED_IN` → `using{Lodge|Rent|LongTerm}`
   * 그 외(미입실) → `reserved{Lodge|Rent|LongTerm}`
6. 배정된 예정 예약(미입실) 있음 → `reserved{...}`
7. 타임라인 없음 + `state ∈ {USING, LEFT}`: 카드=GUEST → `usingExpired`, 카드≠NULL → `usingUnknown`, 아니면 `expired`
8. 타임라인 없음 + 카드=CLEAN(카드리더 이상) → `cleaning`
9. **여기까지 아무 것도 안 걸리면 비로소 `cleanState` 기반 매핑**:
   `DIRTY→availableDirty`, `NEED_CLEANING→needCleaning`, `URGENT_CLEANING→urgentCleaning`,
   `INSPECTION→inspection`, `NEED_INSPECTION→needInspection`, `CLEANING→cleaning`
10. 기본값 → `available`

`calculatedState`는 §3.1의 카드 오버라이드를 거치지 않은 \*\*원본 `cleanState`(parent 값)\*\*를
직접 참조한다 — 이미 자체적으로 카드 상태(4, 7, 8단계)를 더 정교하게 반영하기 때문이다.

***

## 4. 자동청소 정책(`needsAutoCleaning`) — 완전히 별개의 read-only 기능

### 4.1 무엇인가

`getReservations`는 `filter` 인자로 `needsAutoCleaning: Boolean`을 받는다(입력값이지, 응답
필드가 아니다). `getReservations(filter: { needsAutoCleaning: true })`처럼 호출하면, 결과 목록이
"지금 자동청소 정책상 청소 대상인 예약"만으로 좁혀져서 반환된다 — 다른 필터 조건(날짜 범위 등)과
동일하게 그냥 WHERE 절 하나 더 붙는 것이다.

이 필터는 `Room.cleanState`를 **절대 읽지도 쓰지도 않는다** — 완전히 무관하다. 오직 "이 예약이
지금 정책상 청소 대상인가"를 매 조회 시점에 계산할 뿐이며, 그 계산 결과로 실제 DB에 쓰기가
일어나는 곳은 어디에도 없다(순수 조회). 클라이언트는 이 필터로 좁혀진 목록을 "청소 예정" 화면에
그대로 표시하는 용도로만 쓴다.

**"오늘"의 기준**: 판정에 쓰이는 "오늘"은 매 호출 시점의 실시간 현재 시각을 업장 타임존 기준
캘린더 날짜로 변환한 값이다 — night-audit 도메인이 일마감 처리로만 전진시키는 영속화된
영업일자와는 별개다. 즉 일마감이 며칠 밀려 있어도 이 필터는 그와 무관하게 항상 실제 오늘
날짜를 기준으로 판정한다.

### 4.2 구현 (`apps/core-svc/src/domains/reservation/modules/reservation-filter.ts`)

```
getNeedsAutoCleaningWhere(accommodationId, context)
  ├─ getAutoCleaningEvalContext(accommodationId, context)
  │     → AccommodationRoomSetting(useAutoCleaning/autoCleaningMinNights/autoCleaningIntervalNights),
  │       업장 timezone, 오늘 날짜 문자열(refDateStr — 실시간 현재 시각 기준, night-audit
  │       영업일자와 무관), 오늘 하루의 시작/다음날 시작 시각
  ├─ useAutoCleaning=false 면 즉시 { id: { in: [] } } 반환
  ├─ 오늘 기준 재실 중(CHECKED_IN, useStartAt < 내일 시작, intendedUseExpireAt >= 오늘 시작)인
  │  예약들을 한 번에 조회
  ├─ 그 예약들의 roomId 로 Room.lastCleanedAt 을 한 번에 배치 조회
  ├─ 그 예약들의 ReservationNight(삭제 안 된 row) 개수를 한 번에 배치 조회 → totalNights(SSOT)
  │  ※ 레거시 Reservation.nights 컬럼은 쓰지 않는다(체크인/체크아웃만으로는 갱신 안 됨)
  └─ 예약마다 evaluateNeedsAutoCleaning() 판정 → true 인 id만 모아 { id: { in: [...] } } 반환
```

`evaluateNeedsAutoCleaning(reservation, evalContext)`:

1. 정책 꺼짐 / `CHECKED_IN` 아님 / 취소됨 / 객실 미배정 / 오늘 재실 중 아님 → `false`
2. `getCleaningDueDateStr(...)`로 "청소 예정일"을 계산 → 없으면(`null`) `false`
3. `Room.lastCleanedAt >= 그 예정일의 시작 시각` 이면 이미 충족된 것으로 보고 `false`, 아니면 `true`

`getCleaningDueDateStr` — **순수 날짜 계산 함수**, DB 접근 없음:

* `totalNights < minNights` 면 대상 아님(`null`)
* 체크인일 기준 1부터 세는 `nightNumber`가 `intervalNights` 미만이면 아직 대상 아님(`null`)
* 체크아웃 당일 이후면 대상 아님(`null`) — 체크아웃 당일 자체도 제외(엄격 경계)
* 그 외에는 **가장 최근에 도래한 `intervalNights`의 배수 날짜**를 "청소 예정일"로 반환

> **핵심 동작 — 놓친 청소는 사라지지 않는다.** 정확히 그 배수 날짜 하루만 대상으로 보는 게
> 아니라 "가장 최근 도래한 배수 날짜" 자체를 반환하므로, 예정일에 청소를 안 하면 다음 배수가
> 오기 전까지 계속 청소 대상으로 남는다. 반대로 예정일에 맞춰 실제로 `CLEAN` 전환(→
> `lastCleanedAt` 갱신)이 일어나면 다음 배수 전까지는 다시 뜨지 않는다.

### 4.3 정책 값 검증

`validateAutoCleaningPolicyInputs`(설정 변경 시점, `setRoomSettings` 뮤테이션에서 호출):

* `autoCleaningMinNights` ≥ 1
* `autoCleaningIntervalNights` ≥ 1
* **`autoCleaningIntervalNights` ≤ `autoCleaningMinNights`** — 둘 중 하나라도 이번 변경에
  포함된 경우에만 검사(관련 없는 다른 설정 변경까지 막지 않기 위해). 이 관계가 깨지면
  minNights를 충족하는 일부 예약이 체크아웃할 때까지 단 한 번도 청소 예정일에 도달하지 못하는
  모순이 생기기 때문.

### 4.4 관련 없는 값들과의 구분 (자주 헷갈리는 포인트)

| 개념                                        | 정체                        | 자동청소 정책과의 관계                                                                                                          |
| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `Room.cleanState = DIRTY`/`NEED_CLEANING` | CCU/스태프가 실제로 설정한 현재 청소 상태 | **무관.** 정책은 이 값을 보지도, 바꾸지도 않는다                                                                                        |
| `Room.lastCleanedAt`                      | 마지막 `CLEAN` 전환 시각         | 정책의 유일한 idempotency 신호                                                                                                |
| `CleaningHistory` 테이블                     | 스태프용 수기 청소 이력 로그(별도 기능)   | 아무 코드도 이 테이블에 **row 를 생성하지 않는다** — 읽기(`Room.lastCleaningHistory`)와 일괄삭제만 가능한 사실상 방치된 기능. 자동청소 idempotency와는 **전혀 별개** |

***

## 5. 알려진 데드코드 (정리 대상, 동작에 영향 없음)

`expireReservation.processor.ts`에 아래 주석 블록이 남아있다:

```ts theme={null}
if (reservationExpireDate > jobDateString) {
  return;
  // NOTE: 청소지시로 변경하는 로직은 좀더 검토가 필요하다.
  // await eventManager.store({
  //   type: EVENT_TYPES.ROOM.STATE_CHANGED,
  //   aggregate: EVENT_STORE_AGGREGATES.ROOM,
  //   streamId: reservation.roomId,
  //   data: { state: 'NEED_CLEANING', accommodationId },
  // });
}
```

`return;` 때문에 주석을 풀어도 도달 불가능한 코드이고, 애초에 `ROOM.STATE_CHANGED`/`RoomState`에
`'NEED_CLEANING'`(존재하지 않는 `RoomState` 값)을 쓰는 등 지금 구조와도 맞지 않는 훨씬 이전
아이디어의 흔적이다. §4의 현재 설계와는 무관하며 언제든 지워도 안전하다.

***

## 6. 요약

| 무엇을                                     | 어떻게                                                           | 어디서                                           |
| --------------------------------------- | ------------------------------------------------------------- | --------------------------------------------- |
| `cleanState`/`state`/`lastCleanedAt` 저장 | `CLEAN_STATE_CHANGED`/`STATE_CHANGED_BY_STAFF` 이벤트 → saga     | `room/eventstore/room.saga.ts`                |
| `NEED_CLEANING` 쓰는 곳 (core-svc 기준 3곳)   | 체크아웃, 방 재배정(이전 방), 게스트 셀프체크아웃 — CCU 자체 판단 경로는 범위 밖(§2.3)      | `reservation/graphql/reservation.resolver.ts` |
| `CLEAN` 쓰는 곳 (core-svc 기준 2곳)           | 스태프 직접 변경, 메이드 단계 진행                                          | `room/graphql/room.resolver.ts`               |
| CCU 물리 신호                               | saga build마다 무조건 실행되는 `sendRoomControlByEvent`                | `room/eventstore/room.saga.ts`                |
| `Room.cleanState` 조회 시 오버라이드            | 청소카드 꽂혀있으면 `CLEANING` 강제                                      | `room/graphql/room.resolver.ts`               |
| `Room.calculatedState`                  | cleanState는 최후순위, 그 전에 아웃오브세일/타임라인/카드 상태 우선                   | `room/graphql/room.resolver.ts`               |
| 자동청소 대상 판정                              | 순수 조회, cleanState 무관, lastCleanedAt만 참조, 놓치면 다음 배수까지 계속 대상 유지 | `reservation/modules/reservation-filter.ts`   |
| 자동청소가 쓰는 것                              | 없음 (read-only)                                                | —                                             |
