# 소노캄 고양 · 린넨 관리 시스템 개발 명세서

> 객실 매니저가 퇴실 객실의 타올·린넨 수거/교체/폐기/재사용을 기록하고,
> 관리자가 이를 집계·통제하는 시스템.
> 본 문서는 프로토타입(HTML) 2종을 실제 서비스로 구현하기 위한 인수 문서입니다.

- **브랜드** : 소노캄 고양 (SONO CALM GOYANG)
- **사업장 코드** : `GOYANG`
- **개발 기간** : 3주
- **별도 협의** : 내부 시스템(PMS) 연동비 50만원, 그룹별 화면 구성 옵션

---

## 1. 산출물 구조

```
prototype/
  linen-app.html      사용자(객실 매니저) 앱 — 반응형 PWA
  linen-admin.html    관리자 모드 — 반응형 대시보드
SPEC.md               본 문서
```

프로토타입은 **동작하는 UI 레퍼런스**입니다. 상태는 메모리에만 저장되며
아래 표시된 지점을 서버 API 호출로 교체하면 그대로 동작합니다.

| 파일 | 교체 지점 | 대상 API |
|---|---|---|
| linen-app.html | `touchDraft()` | `POST /api/draft` |
| linen-app.html | `saveRecord()` | `POST /api/maintenance` |
| linen-app.html | `qrVerify()` | `POST /api/attendance/verify` |
| linen-admin.html | 각 `render*()` 함수 상단 | 조회 API |

---

## 2. 기술 스택

| 구분 | 선택 | 비고 |
|---|---|---|
| 서버 | Ubuntu + Apache + PHP 8 | 자체 구축 (고객사 요구) |
| DB | MariaDB 10.x | utf8mb4_general_ci |
| 프레임워크 | Gnuboard5 기반 | 기존 운영 관례 준수 |
| 프론트 | Vanilla JS (빌드 없음) | 프로토타입 그대로 이식 |
| 인증 | 사번 + PIN 4자리 | 세션 + 로그인 유지 토큰 |
| 알림 | 텔레그램 봇 | 폐기/미인증/이상접근 |

---

## 3. 데이터 모델

### 3.1 DDL

```sql
-- 객실 타입
CREATE TABLE a_room_type (
  type_code    VARCHAR(10)  NOT NULL,
  name_ko      VARCHAR(50)  NOT NULL,
  name_en      VARCHAR(50)  NOT NULL,
  sort_no      INT          NOT NULL DEFAULT 0,
  use_yn       CHAR(1)      NOT NULL DEFAULT 'Y',
  PRIMARY KEY (type_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 객실
CREATE TABLE a_room (
  room_no      VARCHAR(10)  NOT NULL,
  floor_no     INT          NOT NULL,
  type_code    VARCHAR(10)  NOT NULL,
  use_yn       CHAR(1)      NOT NULL DEFAULT 'Y',
  PRIMARY KEY (room_no),
  KEY idx_floor (floor_no),
  KEY idx_type  (type_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 린넨 품목
CREATE TABLE a_linen (
  linen_id     VARCHAR(30)  NOT NULL,
  name_ko      VARCHAR(50)  NOT NULL,
  name_en      VARCHAR(50)  NOT NULL,
  icon_key     VARCHAR(30)  NOT NULL,   -- SVG symbol id (i-bathtowel 등)
  unit_price   INT          NOT NULL DEFAULT 0,
  wash_limit   INT          NOT NULL DEFAULT 0,  -- 세탁 가능 횟수
  sort_no      INT          NOT NULL DEFAULT 0,
  use_yn       CHAR(1)      NOT NULL DEFAULT 'Y',
  PRIMARY KEY (linen_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 객실타입 × 품목 기준 정량 (PAR)
CREATE TABLE a_room_par (
  type_code    VARCHAR(10)  NOT NULL,
  linen_id     VARCHAR(30)  NOT NULL,
  par_qty      INT          NOT NULL DEFAULT 0,
  PRIMARY KEY (type_code, linen_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 정비 타입
CREATE TABLE a_maint_type (
  mtype_id     VARCHAR(20)  NOT NULL,
  name_ko      VARCHAR(50)  NOT NULL,
  name_en      VARCHAR(50)  NOT NULL,
  default_rule VARCHAR(50)  NULL,       -- 전량 교체 / 요청분 교체 / 교체 없음
  sort_no      INT          NOT NULL DEFAULT 0,
  use_yn       CHAR(1)      NOT NULL DEFAULT 'Y',
  PRIMARY KEY (mtype_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 직원 그룹
CREATE TABLE a_staff_group (
  group_id     VARCHAR(20)  NOT NULL,
  group_name   VARCHAR(50)  NOT NULL,
  PRIMARY KEY (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 그룹별 화면 노출 (별도 비용 옵션)
CREATE TABLE a_group_menu (
  group_id     VARCHAR(20)  NOT NULL,
  menu_key     VARCHAR(30)  NOT NULL,   -- work / mylog / alllog / stats / master / staff
  allow_yn     CHAR(1)      NOT NULL DEFAULT 'N',
  PRIMARY KEY (group_id, menu_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 직원
CREATE TABLE a_staff (
  staff_id     INT UNSIGNED NOT NULL AUTO_INCREMENT,
  emp_no       VARCHAR(20)  NOT NULL,
  name_ko      VARCHAR(50)  NOT NULL,
  name_en      VARCHAR(50)  NULL,
  group_id     VARCHAR(20)  NOT NULL,
  floor_range  VARCHAR(30)  NULL,       -- '10-12F'
  pin_hash     VARCHAR(255) NOT NULL,   -- password_hash()
  lang_code    VARCHAR(5)   NOT NULL DEFAULT 'ko',
  work_yn      CHAR(1)      NOT NULL DEFAULT 'Y',
  reg_dt       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (staff_id),
  UNIQUE KEY uk_emp (emp_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 정비 기록 (헤더)
CREATE TABLE a_maint (
  maint_id     BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  maint_date   DATE         NOT NULL,
  room_no      VARCHAR(10)  NOT NULL,
  type_code    VARCHAR(10)  NOT NULL,
  mtype_id     VARCHAR(20)  NOT NULL,
  staff_id     INT UNSIGNED NOT NULL,
  qty_keep     INT          NOT NULL DEFAULT 0,
  qty_swap     INT          NOT NULL DEFAULT 0,
  qty_dispose  INT          NOT NULL DEFAULT 0,
  qty_lost     INT          NOT NULL DEFAULT 0,
  qty_collect  INT          NOT NULL DEFAULT 0,  -- swap + dispose
  memo         TEXT         NULL,
  att_code     VARCHAR(20)  NULL,       -- 출근 인증 코드
  client_ip    VARCHAR(45)  NOT NULL,
  reg_dt       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  upd_dt       DATETIME     NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (maint_id),
  KEY idx_date_room (maint_date, room_no),
  KEY idx_staff (staff_id, maint_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 정비 기록 (품목별 상세)
CREATE TABLE a_maint_item (
  maint_id     BIGINT UNSIGNED NOT NULL,
  linen_id     VARCHAR(30)  NOT NULL,
  par_qty      INT          NOT NULL,
  qty_keep     INT          NOT NULL DEFAULT 0,
  qty_swap     INT          NOT NULL DEFAULT 0,
  qty_dispose  INT          NOT NULL DEFAULT 0,
  qty_lost     INT          NOT NULL DEFAULT 0,
  dispose_rsn  VARCHAR(50)  NULL,
  photo_path   VARCHAR(255) NULL,
  PRIMARY KEY (maint_id, linen_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 임시저장 (화면 이탈 시 값 유지)
CREATE TABLE a_draft (
  staff_id     INT UNSIGNED NOT NULL,
  room_no      VARCHAR(10)  NOT NULL,
  maint_date   DATE         NOT NULL,
  payload      JSON         NOT NULL,
  upd_dt       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (staff_id, room_no, maint_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 출근 QR 일일 발급
CREATE TABLE a_qr_daily (
  issue_date   DATE         NOT NULL,
  site_code    VARCHAR(20)  NOT NULL,
  plain_text   VARCHAR(100) NOT NULL,
  token        VARCHAR(255) NOT NULL,
  display_code VARCHAR(20)  NOT NULL,
  revoked_at   DATETIME     NULL,
  reg_dt       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (issue_date, site_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 출근 인증 이력
CREATE TABLE a_attendance (
  att_id       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  staff_id     INT UNSIGNED NOT NULL,
  att_date     DATE         NOT NULL,
  att_dt       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  token        VARCHAR(255) NOT NULL,
  result_code  VARCHAR(20)  NOT NULL,  -- OK / EXPIRED / FORGED / IP_DENIED / DUP
  client_ip    VARCHAR(45)  NOT NULL,
  PRIMARY KEY (att_id),
  KEY idx_staff_date (staff_id, att_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 접속 허용 IP
CREATE TABLE a_ip_allow (
  ip_id        INT UNSIGNED NOT NULL AUTO_INCREMENT,
  ip_cidr      VARCHAR(50)  NOT NULL,
  memo         VARCHAR(100) NULL,
  use_yn       CHAR(1)      NOT NULL DEFAULT 'Y',
  PRIMARY KEY (ip_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 다국어 (항목별 자유 언어 추가)
CREATE TABLE a_i18n (
  entity_type  VARCHAR(20)  NOT NULL,  -- linen / mtype / status
  entity_id    VARCHAR(30)  NOT NULL,
  lang_code    VARCHAR(5)   NOT NULL,
  label        VARCHAR(100) NOT NULL,
  PRIMARY KEY (entity_type, entity_id, lang_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 시스템 설정
CREATE TABLE a_config (
  cfg_key      VARCHAR(50)  NOT NULL,
  cfg_value    TEXT         NULL,
  PRIMARY KEY (cfg_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 3.2 초기 데이터

**린넨 품목 10종** (icon_key = 프로토타입 SVG symbol id 그대로 사용)

| linen_id | 한글명 | 영문명 | icon_key | 단가 | 세탁한도 |
|---|---|---|---|---|---|
| bath_towel | 배스 타올 | BATH TOWEL | i-bathtowel | 900 | 120 |
| hand_towel | 핸드 타올 | HAND TOWEL | i-handtowel | 500 | 120 |
| face_towel | 페이스 타올 | FACE TOWEL | i-facetowel | 450 | 100 |
| bath_mat | 발매트 | BATH MAT | i-bathmat | 800 | 90 |
| bed_sheet | 침대 시트 | BED SHEET | i-bedsheet | 1600 | 150 |
| duvet | 듀벳 커버 | DUVET COVER | i-duvet | 2200 | 150 |
| pillow | 베개 커버 | PILLOW CASE | i-pillow | 600 | 120 |
| matt_pad | 매트리스 패드 | MATTRESS PAD | i-mattpad | 2600 | 200 |
| robe | 목욕 가운 | BATHROBE | i-robe | 2400 | 100 |
| runner | 베드 러너 | BED RUNNER | i-runner | 1800 | 180 |

**객실타입별 정량 (a_room_par)** — 실제 데이터로 교체 필요

| 품목 | STD 스탠다드 | TWN 디럭스 트윈 | STE 스위트 |
|---|---|---|---|
| bath_towel | 2 | 4 | 6 |
| hand_towel | 2 | 4 | 4 |
| face_towel | 2 | 4 | 4 |
| bath_mat | 1 | 2 | 2 |
| bed_sheet | 1 | 2 | 2 |
| duvet | 1 | 2 | 2 |
| pillow | 2 | 4 | 6 |
| matt_pad | 1 | 2 | 2 |
| robe | 0 | 2 | 2 |
| runner | 1 | 2 | 2 |

**정비 타입** : `checkout` 퇴실 정비(전량 교체) / `stay` 체류 정비(요청분 교체) / `longstay` 장기 투숙(주 2회 교체) / `inspect` 점검만(교체 없음)

**상태값 4종**

| 코드 | 표시 | 수거 포함 | 사유 입력 | 사진 필수 |
|---|---|---|---|---|
| keep | 유지 · 재사용 | X | X | X |
| swap | 교체 | O | X | X |
| dispose | 폐기 | O | O | O |
| lost | 분실 | X | O | X |

> `qty_collect = qty_swap + qty_dispose` (세탁장 수거 수량)

---

## 4. 출근 QR 인증 규격 ★핵심

### 4.1 목적

객실 QR이 아니라 **매니저가 출근 시 1일 1회 스캔하는 인증용**입니다.
사무실/팬트리에 게시하며 **매일 자정 자동 교체**됩니다.
인증하지 않으면 정비 기록 **최종 저장이 차단**됩니다. (임시저장은 허용)

### 4.2 토큰 구조

```
평문   SONO|YYYYMMDD|GOYANG|1964
       └서명  └발급일   └사업장  └일련번호

암호문 0019122B2356464C53B6AD97ADD5EED9FBE6FEFCC5EFE6CC34   ← QR에 인코딩
표시코드 0818-1964                                          ← 사람이 읽는 코드
```

일련번호 생성식 (프로토타입 기준, 서버는 난수 사용 권장)

```
serial = 0
for each char c in 'YYYYMMDD':
    serial = (serial * 31 + ord(c)) % 9973
value = 1000 + serial % 9000
```

### 4.3 검증 절차 (서버)

1. 토큰 복호화
2. `parts[0] == 'SONO'` → 아니면 `FORGED` (위조)
3. `parts[1] == 오늘 날짜` → 아니면 `EXPIRED` (어제 QR 사진 재사용 차단)
4. `parts[2] == 'GOYANG'` → 아니면 `FORGED` (타 지점 QR)
5. `a_qr_daily.revoked_at` 확인 → 재발급된 경우 `EXPIRED`
6. 접속 IP가 `a_ip_allow` 대역 내인지 확인 → 아니면 `IP_DENIED`
7. 당일 이미 인증했으면 `DUP` (정책에 따라 허용/차단)
8. 결과를 `a_attendance` 에 **성공·실패 모두** 기록

### 4.4 암호화 구현

프로토타입은 데모용 스트림 XOR입니다. **운영은 AES-256-CBC로 교체하고
비밀키는 `.env` 에만 두며, 앱에는 절대 넣지 않습니다.**
앱은 토큰을 서버로 보내고 검증 결과만 받습니다.

```php
<?php
// 발급
function qr_issue($date, $site) {
    $serial = 0;
    foreach (str_split($date) as $c) { $serial = ($serial * 31 + ord($c)) % 9973; }
    $plain = 'SONO|' . $date . '|' . $site . '|' . (1000 + $serial % 9000);
    $key   = hash('sha256', getenv('QR_SECRET'), true);
    $iv    = random_bytes(16);
    $enc   = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
    return strtoupper(bin2hex($iv . $enc));
}

// 검증
function qr_verify($token, $site) {
    $raw = @hex2bin(strtolower($token));
    if ($raw === false || strlen($raw) <= 16) return ['ok' => false, 'code' => 'FORGED'];
    $key = hash('sha256', getenv('QR_SECRET'), true);
    $iv  = substr($raw, 0, 16);
    $enc = substr($raw, 16);
    $plain = openssl_decrypt($enc, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
    if ($plain === false) return ['ok' => false, 'code' => 'FORGED'];

    $p = explode('|', $plain);
    if (count($p) < 4 || $p[0] !== 'SONO') return ['ok' => false, 'code' => 'FORGED'];
    if ($p[2] !== $site)                   return ['ok' => false, 'code' => 'FORGED'];
    if ($p[1] !== date('Ymd'))             return ['ok' => false, 'code' => 'EXPIRED', 'issued' => $p[1]];

    return ['ok' => true, 'code' => 'OK', 'date' => $p[1], 'site' => $p[2],
            'display' => substr($p[1], 4) . '-' . $p[3]];
}
```

### 4.5 프로토타입 참조 구현

`linen-app.html` 과 `linen-admin.html` 양쪽에 동일한 함수가 있습니다.
두 파일이 같은 토큰을 주고받는 것을 실행 검증했습니다.

```
qrEncrypt(plain)   평문 → HEX 토큰
qrDecrypt(hex)     HEX 토큰 → 평문
qrIssue(offset)    offset일 전 토큰 발급 (0=오늘, 1=어제)
qrVerify(token)    복호화 + 서명/날짜 검증
```

앱에 **「어제 QR로 시도해 보기」** 테스트 버튼이 있어 실패 동작을 바로 확인할 수 있습니다.

---

## 5. 화면 정의 — 사용자 앱

**대상 사용자는 고령의 하우스키핑 직원입니다. 모든 요소를 크게 유지하세요.**

### 5.1 화면 흐름

```
로그인(PIN 4자리 터치 키패드)
  └→ 출근 QR 인증 (미인증 시 강제 진입)
       └→ 홈 (객실 목록)
            └→ 정비 입력 → 완료 확인 시트 → 저장
       └→ 내역 (일자별 · 당일 수정 가능)
       └→ MY (언어 · 인증 상태 · 로그아웃)
```

### 5.2 정비 입력 규칙 ★핵심

품목 카드마다 **유지 / 교체 / 폐기 / 분실** 4개 카운터가 동일한 크기로 배치됩니다.

- 진입 시 **유지 = 정량**으로 자동 채워짐
- 교체/폐기/분실 `+` 를 누르면 **유지에서 1장씩 자동 이동**
- 따라서 `keep + swap + dispose + lost ≤ par` 가 항상 성립 → **정량 초과 불가**
- 유지가 0이고 합계가 정량이면 `+` 비활성 + 진동 + "정량 N장을 넘을 수 없습니다"
- 카드 상단에 정량 슬롯 게이지(색상 막대)로 배분 상태를 시각화
- `아직 N장 남았습니다` / `N장 모두 기록했습니다` 상태 문구 표시
- 빠른 입력 : `전량 유지` / `전량 교체` 버튼
- 폐기 1장 이상이면 폐기 사유 select 자동 노출 + 사진 1장 필수
- 전 품목 완료 시에만 `정비 완료` 버튼 활성화

### 5.3 임시저장

- 값 변경 시마다 자동 저장, 하단에 `임시저장됨 · HH:MM` 표시
- 화면을 벗어났다 돌아오면 값 유지
- 홈 상단에 `1204호 작성 중 · 8/10 품목 완료` 이어쓰기 카드 노출
- `정비 완료` 클릭 시에만 최종 저장 (`a_maint` INSERT + `a_draft` DELETE)

### 5.4 수정

- 당일 기록만 `수정` 버튼 노출, 익일부터 `마감` 표시
- 수정 시 기존 값을 드래프트로 복원 → 정비 화면 진입 → 재저장 시 UPDATE

### 5.5 UI 사이즈 기준 (고령 사용자 대응)

| 요소 | 크기 |
|---|---|
| 기본 글자 | 17px |
| 품목명 | 23px |
| 린넨 아이콘 | 46px (박스 74px) |
| ± 버튼 | 60×60px (숫자 32px) |
| 호실 번호 | 28px (정비 화면 46px) |
| 완료 버튼 | 높이 72px / 21px |
| 탭바 아이콘 | 30px |
| 테두리 | 2px |

360px 이하 기기에서는 ± 버튼 52px로 자동 축소.

---

## 6. 화면 정의 — 관리자

| 메뉴 | 내용 |
|---|---|
| 대시보드 | KPI 4종(완료/전체 + 진행률 바), 일자별 스택 차트, 품목별 교체 순위, 층별 진행률, 실시간 피드 |
| 정비 내역 | 기간·층·매니저·정비타입 필터, 상세 조회, CSV 내보내기 |
| 통계 분석 | 품목×객실타입 히트맵, 매니저별 실적, 상태 구성비, 층별 폐기율, 비용 환산 |
| 객실 · 정량 | 정량 매트릭스 편집(저장 시 앱 상한 즉시 반영), 객실 목록 |
| 린넨 품목 | 아이콘·영문명·단가·세탁한도·노출 토글 |
| 정비 타입 | 타입 CRUD, 상태값 정의 |
| 직원 · 그룹 | 직원 CRUD, 그룹별 화면 노출 매트릭스 |
| 언어 관리 | 항목×언어 표, 언어코드 자유 추가 |
| 출근 QR · 보안 | 오늘의 QR, 암호문/복호화 검증, 출근 인증 현황, 정책 토글, IP 허용목록, 코드 이력 |
| 연동 설정 | PMS 연동, 세탁장 발주, SSO, 알림 채널 |

### 6.1 대시보드 KPI 계산식

```
완료 객실  = COUNT(DISTINCT room_no) WHERE maint_date = 오늘
전체 객실  = COUNT(*) FROM a_room WHERE use_yn = 'Y'
진행률     = 완료 / 전체 × 100

처리 린넨  = SUM(qty_keep + qty_swap + qty_dispose) 오늘
교체 비중  = qty_swap / 처리 린넨
폐기율     = qty_dispose / (qty_swap + qty_dispose)
재사용률   = qty_keep / 처리 린넨
세탁비 절감 = SUM(qty_keep × unit_price)
```

> 폐기 진행률 바는 실제 비율이 1~2%대라 시각적으로 6배 스케일로 그립니다. 숫자는 실수치.

---

## 7. API 명세

모든 응답 : `{ "ok": true|false, "data": {...}, "msg": "" }`

| Method | Path | 설명 |
|---|---|---|
| POST | `/api/auth/login` | `emp_no`, `pin`, `keep_login` |
| POST | `/api/attendance/verify` | `token` → 복호화·검증·이력 기록 |
| GET | `/api/attendance/today` | 본인 당일 인증 상태 |
| GET | `/api/rooms?floor=` | 배정 객실 목록 + 당일 상태 |
| GET | `/api/rooms/{room_no}/par` | 객실 정량 + 품목 마스터 |
| GET | `/api/draft/{room_no}` | 임시저장 복원 |
| POST | `/api/draft` | 임시저장 upsert |
| POST | `/api/maintenance` | 최종 저장 (헤더+상세 트랜잭션) |
| PUT | `/api/maintenance/{id}` | 당일 기록 수정 |
| GET | `/api/maintenance/mine?date=` | 내 정비 내역 |
| GET | `/api/admin/dashboard?range=` | KPI + 차트 |
| GET | `/api/admin/records` | 내역 조회(필터) |
| GET | `/api/admin/stats` | 통계 집계 |
| GET/POST | `/api/admin/master/*` | 기준정보 CRUD |
| GET | `/api/admin/qr/today` | 오늘 QR 토큰·표시코드 |
| POST | `/api/admin/qr/revoke` | 즉시 재발급 |

### 7.1 최종 저장 요청 예시

```json
POST /api/maintenance
{
  "room_no": "1204",
  "mtype_id": "checkout",
  "memo": "욕실 타올 1장 변색",
  "items": [
    { "linen_id": "bath_towel", "par": 4,
      "keep": 0, "swap": 3, "dispose": 1, "lost": 0,
      "reason": "오염 (얼룩·변색)" },
    { "linen_id": "bed_sheet", "par": 2,
      "keep": 0, "swap": 2, "dispose": 0, "lost": 0 }
  ]
}
```

서버는 반드시 **정량 재검증**을 수행합니다.
`keep + swap + dispose + lost == par` 가 아니면 400 반환. (클라이언트 우회 차단)

---

## 8. 어뷰징 방지 정책

| 항목 | 기본값 | 동작 |
|---|---|---|
| QR 촬영 필수 | ON | 미인증 시 최종 저장 차단 |
| 사내망 IP 제한 | ON | 허용 대역 밖 저장 차단 |
| 위치 반경 확인 | OFF | 호텔 좌표 200m 이내 |
| 중복 제출 차단 | ON | 동일 호실 10분 내 재저장 시 승인 필요 |
| 근무시간 외 저장 차단 | OFF | 23:00~06:00 저장 시 슈퍼바이저 알림 |

- 인증 실패(만료·위조·IP거부)도 `a_attendance` 에 전부 기록하고 관리자 화면에 노출
- QR 재발급 시 이전 토큰 즉시 무효화 (`revoked_at` 기준)

---

## 9. 다국어

- 기본 4개 언어 : `ko` / `en` / `ja` / `zh`
- 관리자에서 **언어코드 자유 추가** (예: `vi`, `th`)
- 대상 : 린넨 품목명, 정비 타입명, 상태값
- 미입력 시 `ko` 로 fallback
- 직원별 표시 언어는 `a_staff.lang_code` 에 저장

---

## 10. 디자인 토큰

```css
--ink:      #0e2429   /* 딥 틸 (헤더·주요 버튼) */
--surface:  #e6ecea   /* 배경 */
--paper:    #ffffff   /* 카드 */
--line:     #c6d5d2   /* 테두리 2px */
--keep:     #2f7d86   /* 유지 */
--swap:     #b8791a   /* 교체 */
--dispose:  #a83d2f   /* 폐기 */
--lost:     #7b8b8d   /* 분실 */
--brass:    #b78c2e   /* 강조 */
```

- 본문 : Pretendard Variable
- 숫자·영문 : IBM Plex Mono (`font-variant-numeric: tabular-nums`)
- 린넨 아이콘 10종은 전용 SVG symbol (이모지 사용 금지)

---

## 11. 코드 작업 규칙

1. **슬라이더**는 Swiper.js v11만 사용 (자체 transform 구현 금지). 카드 그리드 가로 스크롤은 예외 — `touch-action: pan-x pan-y` + native `overflow-x: auto`
2. **input/textarea 자동 `.focus()` 금지** — 모바일 키보드 즉시 노출 방지. 사용자 명시 액션 후에만 허용
3. PHP는 `$db = getDB()` 사용, 테이블 `a_` 접두
4. `_ok.php` 는 DB INSERT 직후 텔레그램 알림 호출 (민감정보 미포함)
5. JS 문자열은 단일 라인 (`\n` 이스케이프)
6. 파일 생성 후 `os.chmod(path, 0o644)`
7. 배포 전 문법 검증 (`py_compile` / `node --check`)
8. 지도 마커 재로드는 idx 기준 diff 패턴

---

## 12. 개발 순서 (3주)

**1주차 — 기반**
- DB 스키마 생성 + 초기 데이터 적재
- 로그인/세션/로그인 유지
- 출근 QR 발급·검증 (AES-256-CBC) + IP 검증
- 기준정보 CRUD (객실/타입/정량/품목/정비타입)

**2주차 — 사용자 앱**
- 홈 객실 목록, 정비 입력 화면 (4카운터 + 정량 제어)
- 임시저장/복원, 최종 저장, 당일 수정
- 내역·MY, 다국어 적용, PWA 매니페스트
- 폐기 사진 업로드

**3주차 — 관리자 + 마감**
- 대시보드/통계/내역 + CSV
- 직원·그룹·언어 관리, 보안 정책 화면
- 텔레그램 알림, 실기기 테스트, 배포

---

## 13. 결정 필요 사항

| # | 항목 | 비고 |
|---|---|---|
| 1 | 실제 객실타입 구성 | 현재 3종. 온돌형 등 추가 시 정량 매트릭스 컬럼 확장 |
| 2 | 실제 층·호실 데이터 | 현재 10~12층 24실 샘플 |
| 3 | 진행률 분모 정의 | 전체 객실 vs 당일 퇴실 배정 객실 |
| 4 | 동일 호실 재정비 집계 | 현재 호실 1회로 카운트 |
| 5 | 출근 인증 1일 1회 제한 | 재인증 허용 여부 |
| 6 | PMS 연동 범위 | 연동비 50만원 별도, 3주 내 협의 |
| 7 | 그룹별 화면 구성 | 별도 비용 옵션 |
| 8 | 사진 보관 기간·용량 | 폐기 증빙 |
| 9 | 소노 CI 컬러 적용 | 현재 딥 틸 + 브라스 골드 |
| 10 | 도메인 확정 | API 주소·PWA `start_url` |

---

## 14. Codex 작업 지시 예시

```
prototype/linen-app.html 의 정비 입력 화면을 PHP + MariaDB 로 이식한다.
- SPEC.md 3장 DDL 을 그대로 사용
- 4카운터 로직(유지에서 자동 이동)은 서버에서도 재검증할 것
- 임시저장은 a_draft 에 JSON payload 로 upsert
- UI 사이즈(5.5절)와 색상 토큰(10장)은 변경하지 말 것
- input 자동 focus 금지
```

---

## 15. 2026-08 운영 확장 요구사항 — 데모 우선

이번 단계는 실제 ERP/PMS 연동보다 **업무 설명이 가능한 인터랙티브 데모**를 우선한다.
사용자 앱과 관리자 화면은 같은 오더·재고 상황을 서로 다른 역할에서 확인할 수 있어야 한다.

### 15.1 ERP + QR 보안 로그인

```
자체 ERP 계정 확인(사번·재직·근무조)
  → 근무지 일일 QR 촬영
  → 별도 관리자 승인 대기 없이 즉시 로그인 승인
  → 기본 8시간 세션 유지
  → 네트워크/브라우저 문제로 세션이 풀리면 30분 내 PIN 간편 재로그인
```

- `로그인 유효시간`과 `세션 복구 유예시간`은 관리자 정책값으로 변경 가능해야 한다.
- 자발적 로그아웃, 계정 정지, 근무 종료, QR 폐기 시에는 복구 유예를 적용하지 않는다.
- 복구 시 ERP 재직 상태와 기존 QR 인증의 유효성은 서버에서 다시 확인한다.
- 관리자 보안 화면에는 ERP 수락, QR 승인, 세션 만료, 세션 복구 이력을 구분해 표시한다.
- 프로토타입 기본값: 로그인 8시간 / 세션 복구 30분.

### 15.2 정비 요청 · 고객 오더 흐름

```
요청 접수
  → 출근자 선택 및 실시간 알림
  → 담당 매니저 수락
  → 객실로 출동
  → 현장 처리 시작
  → 완료 보고(메모·사진)
  → 매니저 완료 확인
  → 고객 확인 및 통계 반영
```

필수 상태값은 `접수 / 알림 발송 / 수락 / 출동 / 처리 중 / 완료 확인 / 마감`이다.
오더에는 객실·위치, 요청 출처, 분류, 우선순위, 목표시간(SLA), 담당자, 품목별 수량,
고객 원문, 내부 메모, 첨부, 상태 변경 이력을 저장한다.

- 미수락 또는 목표시간 임박 오더는 관리자 대시보드에 경고한다.
- 출근 중인 동일 그룹 전체 알림과 특정 담당자 지정 알림을 모두 지원한다.
- 전달 오더는 한 오더에 여러 품목과 수량을 담을 수 있어야 한다.
- 현장 직원은 모바일에서 `수락 → 출동 → 처리 시작 → 완료 보고`를 순차 처리한다.
- 관리자는 완료 보고를 확인하고 최종 마감한다.
- 완료 후 필요 시 고객 만족 확인과 재오픈 이력을 남긴다.

### 15.3 고객 요청 자유 오더와 데이터화

전화, 대면, 채팅 등으로 접수한 요청은 기존 정비 기록과 별개인 고객 오더로 즉시 등록할 수 있다.
초기에는 자유 입력을 허용하되 객실, 품목, 수량, 우선순위, 담당자 등 핵심 필드는 별도로 수집한다.

화면에는 다음 운영 문구를 계속 노출한다.

> 현재는 자유 입력도 함께 지원합니다. 정확한 데이터 통계를 위해 반복 요청 문구를 항목화·표준화하는 데이터화 작업을 지속적으로 지원해야 합니다.

자유 입력 데이터가 쌓이면 동의어 병합, 요청 분류 추가, 필수 필드 조정, SLA 기준 보정 작업을
운영 업데이트 범위로 계속 수행한다.

### 15.4 메이드카 수량

메이드카마다 다음 정보를 표시한다.

- 카트 번호, 담당자, 운영 층, 출고 시각과 상태
- 품목별 최초 적재 수량
- 객실 사용·추가 지급·더티·로스 수량
- 현재 잔량과 보충 필요 여부
- 층 린넨실에서 카트로 출고한 이력과 반납 이력

잔량이 기준치 이하이면 관리자와 담당 직원 모두에게 보충 필요 상태를 표시한다.

### 15.5 층별 린넨실 수불과 일일 오차

각 층 린넨실은 `기초 / 입고 / 메이드카 출고 / 추가 지급 / 더티 회수 / 로스 / 이론재고 / 실사재고 / 오차`를 관리한다.
일일 마감 시 이론재고와 실사재고의 차이를 확인하고, 오차가 있으면 원인과 확인자를 기록한다.

```
이론재고 = 기초재고 + 입고 - 출고 - 추가 - 더티 - 로스 ± 조정
오차     = 실사재고 - 이론재고
```

### 15.6 더티 · 추가 · 로스

- **더티**: 객실에서 회수해 세탁장으로 이동하는 수량
- **추가**: 정량 외 고객 요청 또는 재정비로 지급한 수량
- **로스**: 분실·행방불명·수량 불일치로 확정하거나 조사 중인 수량
- **폐기**: 실물이 존재하지만 오염·파손 등으로 사용 불가한 수량

로스 발견 시 직원이 발견 객실·위치와 사유를 직접 입력할 수 있어야 한다.
초기 자유 사유를 보존하면서 이후 통계용 표준 사유 코드로 연결한다.

핵심 목적은 메이드카 → 객실 → 더티 회수 → 층 린넨실 → 세탁장 흐름을 추적하여
린넨 로스율과 일일 수량 오차를 줄이는 것이다.

### 15.7 호텔 서비스 오더 운영 참고 범위

Amadeus HotSOS의 일반적인 호텔 운영 개념을 참고해 다음을 데모에 반영한다.

- 실시간 서비스 오더와 직원 알림
- 근무자 배정 및 미처리 오더 에스컬레이션
- 다품목 전달 오더와 품목별 수량
- 처리 시작·완료 상태와 변경 이력
- 사진 등 첨부와 매니저 확인
- 실시간 대시보드, 필터, 보고서와 데이터 분석
- 완료 후 고객 확인 및 반복 요청 데이터화

특정 제품 UI를 복제하지 않고 소노캄 고양의 린넨·하우스키핑 업무에 맞게 일반화한다.

### 15.8 데모 시나리오

| 상황 | 데모 표현 |
|---|---|
| ERP 정상 + 오늘 QR | 즉시 승인, 8시간 로그인 표시 |
| 세션 풀림 | 30분 내 PIN 재로그인, QR 재촬영 없음 |
| 긴급 고객 요청 | 미수락/SLA 4분 경고 |
| 담당자 수락 | 수락 → 출동 → 처리 중 상태 변화 |
| 다품목 전달 | 타올 2장 + 생수 3병 한 오더 처리 |
| 완료 확인 대기 | 현장 완료 후 매니저 확인 상태 |
| 메이드카 부족 | 품목 잔량 3~4장 보충 경고 |
| 층별 수량 오차 | 이론 97장 / 실사 92장, -5 표시 |
| 더티·추가 | 수량 이동 구분별 입력 |
| 로스 발견 | 객실·위치와 직접 입력 사유 필수 |

### 15.9 향후 서버 데이터 모델 초안

실제 구현 단계에서는 기존 3장 DDL에 다음 엔티티를 추가한다.

- `a_auth_session`, `a_auth_event`: ERP·QR·세션 발급/복구/종료 이력
- `a_service_order`, `a_service_order_item`, `a_service_order_event`: 오더 헤더·다품목·상태 이력
- `a_service_order_attachment`: 사진·첨부
- `a_maid_cart`, `a_maid_cart_load`: 카트와 일별 적재
- `a_stock_location`, `a_stock_balance`, `a_stock_tx`: 층 린넨실 재고와 이동 원장
- `a_stock_count`: 일일 실사와 오차 확인
- `a_loss_reason_map`: 자유 사유와 표준 사유 연결

데모 확정 후 실제 ERP 인증 규격, 직원 근무조 API, 알림 채널, 상태별 권한과 SLA를 별도 확정한다.
