{
  "openapi": "3.1.0",
  "info": {
    "title": "관악수내과의원 예약 API (Gwanak Soo Internal Medicine Clinic — Booking API)",
    "version": "2026-11-02",
    "summary": "환자 본인의 검사 예약을 잡고·옮기고·취소하는 공개 동작. 본인 확인은 환자 휴대폰 문자 인증번호.",
    "description": "홈페이지 예약 화면(https://www.gwsooclinic.com/reservation)이 쓰는 Google Apps Script 웹앱을 그대로 공개한 것입니다. 사람·에이전트용 설명서: https://www.gwsooclinic.com/booking-api.md\n\n- 주소는 하나(`/exec`). 읽기는 `GET ?표=1`, 쓰기는 모두 `POST` 본문 JSON 이며 `동작` 필드가 무엇을 할지 정합니다.\n- 요청 헤더 `Content-Type: text/plain;charset=utf-8` 로 JSON 을 보냅니다(홈페이지와 같은 방식, CORS preflight 없음). 응답은 JSON.\n- 서버는 302 로 `script.googleusercontent.com` 에 결과를 둡니다 — 리다이렉트를 따라가야 합니다.\n- ⚠️ curl 로 쓰기 동작을 보낼 때 `-X POST` 를 붙이지 말고 `-L -d '…'` 만 씁니다. `-X POST` 면 302 뒤에도 POST 를 보내 HTTP 405 가 나는데, 그때는 이미 첫 요청이 처리된 뒤라 인증번호 문자는 갔는데 `인증id` 를 받지 못합니다.\n- ⚠️ 읽기의 `표` 키는 반드시 URL 인코딩(`?%ED%91%9C=1`). 인코딩하지 않은 `?표=1` 은 구글 앞단에서 HTTP 400 HTML 이 옵니다.\n- 알 수 없는 `동작`(오타·영어 `action`)이나 `동작` 이 빠진 본문은 운영용 자리로 떨어져 `{ok:false, err:\"키 틀림\"}` 이 옵니다 — 공개 동작에는 키가 필요 없으니 `동작` 값과 한글 필드 이름을 다시 확인합니다.\n- 다시 보내도 한 번만 처리하려면 본문 맨 위에 `작업id`(16~32자 영문·숫자, 선택)를 넣습니다. 같은 `작업id` 로 다시 보내면 서버가 처음 결과를 그대로 돌려줍니다(24시간).\n- 시행 의사는 예약에서 지정하지 않습니다(여의사를 원하면 검사 당일 접수 때 말씀). 복부초음파는 신청 전에 환자에게 8시간 금식(물은 2시간 전까지)을 알리고 확인받습니다.\n- 필드 이름은 한글 그대로입니다. 시간대 Asia/Seoul. 날짜 `YYYY-MM-DD`, 시각 `HH:MM`.\n- 인증번호(6자리)는 환자 본인 휴대폰으로만 갑니다. 에이전트는 환자가 알려 준 번호만 쓰고, 추측·재사용하지 않습니다.\n- 신청·바꾸기는 비동기입니다. 병원 처리기가 약 30초 간격으로 처리하므로 `결과`/`내예약결과` 를 2~3초 간격으로 폴링하고, 90초 안에 결과가 없으면 전화(02-871-1936) 확인을 안내합니다.\n- 운영용 동작(`빈시간`·`가져가기`·`결과넣기`·`내예약결과넣기`)은 병원 내부 키가 필요하며 이 명세에 포함하지 않습니다.",
    "contact": { "name": "관악수내과의원", "url": "https://www.gwsooclinic.com", "email": "master@gwsooclinic.com" }
  },
  "servers": [
    { "url": "https://script.google.com/macros/s/AKfycbzDIeWHIHOkIeO-5pMthBhj5KZVjh-VpmsX_27uIAsiex6-ITbbhgPTlS1ptEwJyVD2", "description": "운영 웹앱 (Google Apps Script). 재배포해도 주소는 같다." }
  ],
  "paths": {
    "/exec": {
      "get": {
        "operationId": "getAvailableSlots",
        "summary": "① 빈 시간 보기 (available slots)",
        "description": "검사별 빈 시간표. 내일부터 30일(약 4주) 분량(대장 표는 이틀 뒤부터), 기본으로 다섯 표 전체가 온다. 병원 쪽이 10분마다 새로 만들어 올린다 — `만든때` 는 보통 10분 안팎(2026-11-02 오후까지는 30분 주기라 17~34분 전 표가 보였다). 신청 직전에 다시 받아 그 시각이 아직 있는지 확인한다. 2시간 넘게 낡은 표로는 서버가 신청을 받지 않는다. 신청은 내일부터, 토요일 검사 13:00 마감. 수면·비수면 위내시경은 같은 `위내시경` 표를 쓴다.",
        "parameters": [
          { "name": "표", "in": "query", "required": true, "description": "값 `1` 고정. **반드시 URL 인코딩**: `?%ED%91%9C=1`. 인코딩하지 않은 `?표=1` 은 구글 앞단에서 HTTP 400 HTML 이 온다.", "schema": { "type": "string", "enum": ["1"]}}
        ],
        "responses": {
          "200": {
            "description": "빈 시간표. `자료` 가 null 이면 지금 표를 보여줄 수 없는 상태(전화 안내).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/빈시간응답" } } }
          },
          "302": { "description": "script.googleusercontent.com 으로 리다이렉트 — 따라가면 200 JSON." }
        }
      },
      "post": {
        "operationId": "bookingAction",
        "summary": "②~⑤ 인증요청 · 신청 · 결과 · 내예약목록 · 내예약바꾸기 · 내예약결과",
        "description": "본문 JSON 의 `동작` 값으로 분기한다. 알 수 없는 `동작` 이나 JSON 이 아닌 본문은 `{ok:false, err:…}` 로 돌아온다(운영용 동작 자리로 떨어져 `키 틀림` 이 나올 수 있다).",
        "requestBody": {
          "required": true,
          "description": "예시의 `날짜`·`시각`(2026-10 날짜)은 꼴만 보여 준다 — ① 빈 시간표에서 방금 고른 값으로 바꾼다. **`동의: true` 는 환자가 개인정보 수집·이용에 동의한 뒤에만 — 환자 확인 전 true 금지.** 초음파 `부위` 에 복부가 있으면 신청 전에 8시간 금식(물은 2시간 전까지)을 알리고 확인받는다. 선택 필드 `요청`(60자)·`작업id` 는 아래 스키마 참고.",
          "content": {
            "text/plain": {
              "schema": { "$ref": "#/components/schemas/요청" },
              "examples": {
                "인증요청": { "summary": "② 인증번호 요청 — 환자 휴대폰으로 문자", "value": { "동작": "인증요청", "휴대폰": "010-1234-5678" } },
                "신청": { "summary": "③ 예약 신청 (위내시경·수면)", "value": { "동작": "신청", "신청": { "인증id": "3f9c0a1b2c3d4e5f6a7b8c9d0e1f2a3b", "인증번호": "123456", "검사": "위내시경", "날짜": "2026-10-15", "시각": "09:20", "수면": "수면", "부위": [], "검진종류": "", "이름": "홍길동", "휴대폰": "010-1234-5678", "생년월일": "1980-01-31", "성별": "여", "동의": true } } },
                "신청_초음파": { "summary": "③ 예약 신청 (초음파 2부위)", "value": { "동작": "신청", "신청": { "인증id": "3f9c0a1b2c3d4e5f6a7b8c9d0e1f2a3b", "인증번호": "123456", "검사": "초음파", "날짜": "2026-10-15", "시각": "14:00", "수면": "", "부위": ["복부", "갑상선"], "검진종류": "", "이름": "홍길동", "휴대폰": "010-1234-5678", "생년월일": "1980-01-31", "성별": "남", "동의": true } } },
                "결과": { "summary": "③′ 신청 결과 폴링", "value": { "동작": "결과", "id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" } },
                "내예약목록": { "summary": "④ 내 예약 목록 (인증번호로)", "value": { "동작": "내예약목록", "휴대폰": "010-1234-5678", "인증id": "3f9c0a1b2c3d4e5f6a7b8c9d0e1f2a3b", "인증번호": "123456" } },
                "내예약목록_토큰": { "summary": "④ 목록 다시 보기 (살아 있는 토큰으로)", "value": { "동작": "내예약목록", "토큰": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8" } },
                "옮기기": { "summary": "⑤ 옮기기", "value": { "동작": "내예약바꾸기", "토큰": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8", "열쇠": "9a8b7c6d5e4f3a2b", "무엇": "옮기기", "새날짜": "2026-10-22", "새시각": "10:20" } },
                "취소": { "summary": "⑤ 취소", "value": { "동작": "내예약바꾸기", "토큰": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8", "열쇠": "9a8b7c6d5e4f3a2b", "무엇": "취소", "사유": "일정 변경" } },
                "내예약결과": { "summary": "④′·⑤′ 결과 폴링", "value": { "동작": "내예약결과", "id": "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "항상 HTTP 200 JSON. 성공·실패는 본문의 `ok` 로 본다. 응답 꼴은 보낸 `동작` 이 정한다 — 인증요청→인증요청응답 · 신청→신청응답 · 결과→결과응답 · 내예약목록→내예약목록응답 · 내예약바꾸기→내예약바꾸기응답 · 내예약결과→내예약결과응답, 실패는 어느 동작이든 실패응답(`코드`) 또는 서버오류응답(`err`). 결과응답과 내예약결과응답은 꼴이 겹치므로(둘 다 `ok`·`상태`) 본문만 보고 고르지 말고 보낸 `동작` 으로 고른다 — 그래서 oneOf 가 아니라 anyOf 다.",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    { "$ref": "#/components/schemas/인증요청응답" },
                    { "$ref": "#/components/schemas/신청응답" },
                    { "$ref": "#/components/schemas/결과응답" },
                    { "$ref": "#/components/schemas/내예약목록응답" },
                    { "$ref": "#/components/schemas/내예약바꾸기응답" },
                    { "$ref": "#/components/schemas/내예약결과응답" },
                    { "$ref": "#/components/schemas/실패응답" },
                    { "$ref": "#/components/schemas/서버오류응답" }
                  ]
                }
              }
            }
          },
          "302": { "description": "script.googleusercontent.com 으로 리다이렉트 — 따라가면 200 JSON." }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "휴대폰": { "type": "string", "description": "환자 본인의 휴대폰. 010 으로 시작하는 11자리, 하이픈은 있어도 없어도 된다.", "pattern": "^010-?\\d{4}-?\\d{4}$", "examples": ["010-1234-5678", "01012345678"] },
      "날짜": { "type": "string", "format": "date", "description": "YYYY-MM-DD (Asia/Seoul)", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
      "시각": { "type": "string", "description": "HH:MM 24시간", "pattern": "^\\d{2}:\\d{2}$" },
      "hex32": { "type": "string", "description": "32자 소문자 hex id", "pattern": "^[0-9a-f]{32}$" },
      "작업id": {"type": "string", "pattern": "^[0-9A-Za-z]{16,32}$", "description": "선택. 다시 보내도 한 번만 처리(같은 작업id 면 처음 결과를 그대로, 24시간). 요청마다 새 값, 다시 보낼 때만 같은 값."},
      "검사": {
        "type": "string",
        "enum": ["위내시경", "대장내시경", "위대장", "맞춤형", "초음파", "검진"],
        "description": "위내시경 gastroscopy(즉시 확정, `수면` 필수) · 대장내시경 colonoscopy(즉시 확정, `대장동의` 필수) · 위대장 gastroscopy+colonoscopy same day(즉시 확정, `대장동의` 필수) · 맞춤형 personalized comprehensive check-up(즉시 확정, `대장동의` 필수, 초음파 `부위` 정확히 2개) · 초음파 ultrasound(즉시 확정, `부위` 1~3개) · 검진 health check-up(즉시 확정, `검진종류` 필수) — 신청에는 이 값만 쓴다. 화면·④ 목록에 보이는 이름(위·대장내시경 동시 · 맞춤형 종합검진 · 건강검진 …)을 넣으면 `입력` 오류. 수면·비수면 위내시경은 같은 `위내시경` 값에 `수면` 으로 구분."
      },
      "부위": { "type": "string", "enum": ["복부", "갑상선", "경동맥", "심장", "유방"], "description": "초음파 부위 — abdomen · thyroid · carotid · heart · breast" },
      "검진종류": { "type": "string", "enum": ["국가검진", "공무원 채용", "일반 채용"], "description": "national health screening · civil-service pre-employment · general pre-employment" },
      "표이름": { "type": "string", "enum": ["위내시경", "대장", "초음파1", "초음파2", "검진"], "description": "빈 시간표 이름. 위내시경→위내시경 · 대장내시경/위대장/맞춤형→대장 · 초음파 1부위→초음파1(20분) · 초음파 2~3부위→초음파2(40분) · 검진→검진" },
      "오류코드": {
        "type": "string",
        "enum": ["입력", "인증만료", "인증틀림", "진행중", "잦음", "전화", "마감", "없음", "바쁨", "오류"],
        "description": "입력: 칸이 틀림(문구 참고) · 인증만료: 5분 지남/5회 틀림/번호 불일치/토큰 30분 지남 → 인증부터 다시 · 인증틀림: 인증번호 다름(남은 횟수가 문구에) · 진행중: 같은 번호로 처리 중인 요청 있음 · 잦음: 같은 번호 10분 3회 초과 · 전화: 한도·낡은 표·네 부위 이상·바꿀 수 없는 예약 → 02-871-1936 · 마감: 그 시각이 찼거나 오늘/지난 날짜 → 다른 시각 · 없음: 열쇠/ id 없음 · 바쁨: 서버 잠금 → 몇 초 뒤 재시도 · 오류: 서버 오류"
      },
      "요청": {
        "description": "`동작` 값으로 분기",
        "oneOf": [
          { "$ref": "#/components/schemas/인증요청" },
          { "$ref": "#/components/schemas/신청요청" },
          { "$ref": "#/components/schemas/결과요청" },
          { "$ref": "#/components/schemas/내예약목록요청" },
          { "$ref": "#/components/schemas/내예약바꾸기요청" },
          { "$ref": "#/components/schemas/내예약결과요청" }
        ],
        "discriminator": {
          "propertyName": "동작",
          "mapping": {
            "인증요청": "#/components/schemas/인증요청",
            "신청": "#/components/schemas/신청요청",
            "결과": "#/components/schemas/결과요청",
            "내예약목록": "#/components/schemas/내예약목록요청",
            "내예약바꾸기": "#/components/schemas/내예약바꾸기요청",
            "내예약결과": "#/components/schemas/내예약결과요청"
          }
        }
      },
      "인증요청": {
        "type": "object",
        "title": "② 인증번호 요청 (request SMS code)",
        "description": "환자 본인 휴대폰으로 6자리 인증번호 문자를 보낸다. 환자가 그 번호를 에이전트에게 알려 준다. 유효 300초, 1회용, 같은 번호 10분 3회·전체 10분 20회·하루 150회 한도. 같은 번호의 앞 인증은 무효가 된다.",
        "required": ["동작", "휴대폰"],
        "properties": { "동작": { "const": "인증요청" }, "휴대폰": { "$ref": "#/components/schemas/휴대폰" }, "작업id": { "$ref": "#/components/schemas/작업id" } }
      },
      "신청요청": {
        "type": "object",
        "title": "③ 예약 신청 (submit booking)",
        "required": ["동작", "신청"],
        "properties": { "동작": { "const": "신청" }, "작업id": { "$ref": "#/components/schemas/작업id" }, "신청": { "$ref": "#/components/schemas/신청" } }
      },
      "신청": {
        "type": "object",
        "required": ["인증id", "인증번호", "검사", "날짜", "시각", "이름", "휴대폰", "생년월일", "성별", "동의"],
        "properties": {
          "인증id": { "$ref": "#/components/schemas/hex32", "description": "② 응답의 인증id" },
          "인증번호": { "type": "string", "pattern": "^\\d{6}$", "description": "환자가 문자로 받은 6자리. 5회 틀리면 인증 폐기." },
          "검사": { "$ref": "#/components/schemas/검사" },
          "날짜": { "$ref": "#/components/schemas/날짜", "description": "① 표에 지금 있는 날짜. 내일부터." },
          "시각": { "$ref": "#/components/schemas/시각", "description": "① 표의 그날 배열에 있는 시각" },
          "수면": { "type": "string", "enum": ["수면", "비수면", ""], "description": "위내시경일 때만 필수(sedated / not sedated). 다른 검사는 빈 문자열 또는 생략. 대장류는 보내지 않는다 — 수면 검사가 기본이고, 비수면을 원하면 병원에 와서 말하면 된다." },
          "대장동의": { "type": "object", "required": ["장약", "항응고"], "properties": { "장약": { "const": true }, "항응고": { "const": true } }, "description": "대장내시경·위대장·맞춤형일 때 필수(bowel-prep pickup consent · anticoagulant consent). 환자에게 두 안내(① 검사 3일 전까지 — 3일 안으로 남은 예약은 검사 전날까지 — 장을 비우는 약을 받아 가지 않으면 예약이 취소될 수 있음 ② 피를 묽게 하는 약을 드시면 약 받으러 올 때 의사와 상담 뒤 검사 날짜가 바뀔 수 있음)를 읽어 주고 둘 다 동의했을 때만 true. 받은 시각은 서버가 기록한다." },
          "부위": { "type": "array", "items": { "$ref": "#/components/schemas/부위" }, "uniqueItems": true, "maxItems": 3, "description": "초음파 1~3개(4개 이상은 전화) · 맞춤형 정확히 2개 · 그 밖은 []. 복부가 들어가면 신청 전에 8시간 금식(물은 2시간 전까지)을 알리고 확인받는다(보내는 칸은 없음)." },
          "검진종류": { "oneOf": [ { "$ref": "#/components/schemas/검진종류" }, { "const": "" } ], "description": "검진일 때만 필수" },
          "이름": { "type": "string", "minLength": 2, "maxLength": 20, "pattern": "^[가-힣A-Za-z ]{2,20}$", "description": "환자 이름(한글 또는 영문 2~20자)" },
          "휴대폰": { "$ref": "#/components/schemas/휴대폰", "description": "②에서 인증받은 그 번호와 같아야 한다" },
          "생년월일": { "$ref": "#/components/schemas/날짜", "description": "date of birth, YYYY-MM-DD" },
          "성별": { "type": "string", "enum": ["남", "여"], "description": "male / female" },
          "요청": {"type": "string", "maxLength": 60, "description": "선택. 요청사항(홈페이지 화면의 「요청사항 (선택)」과 같음) 60자까지 — 넘으면 서버가 자른다. 생년월일·주민번호·전화번호 꼴은 서버가 지운다. 예: 「국가검진도 같이 받고 싶어요」. 시행 의사 지정은 받지 않는다."},
          "동의": { "type": "boolean", "const": true, "description": "개인정보 수집·이용 동의 — 환자가 동의한 경우에만 true(환자 확인 전 true 금지). 환자에게 읽어 줄 안내: 「모으는 것: 이름, 휴대폰 번호, 생년월일, 성별, 적어 주신 요청사항. 쓰는 곳: 진료 예약과 예약 안내 연락. 보관: 예약 처리 후 바로 삭제(진료기록은 의료법에 따라 보관). 동의하지 않으셔도 됩니다 — 그때는 전화로 예약.」" }
        },
        "allOf": [
          {"$comment": "위내시경은 수면 필수", "if": {"required": ["검사"], "properties": {"검사": {"const": "위내시경"}}}, "then": {"required": ["수면"], "properties": {"수면": {"enum": ["수면", "비수면"]}}}},
          {"$comment": "대장류는 대장동의 필수", "if": {"required": ["검사"], "properties": {"검사": {"enum": ["대장내시경", "위대장", "맞춤형"]}}}, "then": {"required": ["대장동의"]}},
          {"$comment": "초음파는 부위 1~3개", "if": {"required": ["검사"], "properties": {"검사": {"const": "초음파"}}}, "then": {"required": ["부위"], "properties": {"부위": {"minItems": 1, "maxItems": 3}}}},
          {"$comment": "맞춤형은 부위 정확히 2개", "if": {"required": ["검사"], "properties": {"검사": {"const": "맞춤형"}}}, "then": {"required": ["부위"], "properties": {"부위": {"minItems": 2, "maxItems": 2}}}},
          {"$comment": "검진은 검진종류 필수", "if": {"required": ["검사"], "properties": {"검사": {"const": "검진"}}}, "then": {"required": ["검진종류"], "properties": {"검진종류": {"$ref": "#/components/schemas/검진종류"}}}}
        ]
      },
      "결과요청": {
        "type": "object",
        "title": "③′ 신청 결과 폴링 (poll booking result)",
        "required": ["동작", "id"],
        "properties": { "동작": { "const": "결과" }, "id": { "$ref": "#/components/schemas/hex32", "description": "③ 응답의 신청id" } }
      },
      "내예약목록요청": {
        "type": "object",
        "title": "④ 내 예약 목록 (my bookings)",
        "description": "둘 중 하나: (a) 휴대폰 + 인증id + 인증번호 — 새 토큰을 받는다 · (b) 살아 있는 토큰만 — 목록을 다시 요청한다.",
        "required": ["동작"],
        "properties": {
          "동작": { "const": "내예약목록" },
          "휴대폰": { "$ref": "#/components/schemas/휴대폰" },
          "인증id": { "$ref": "#/components/schemas/hex32" },
          "인증번호": { "type": "string", "pattern": "^\\d{6}$" },
          "토큰": { "$ref": "#/components/schemas/hex32", "description": "④ 응답의 토큰(30분)" },
          "작업id": { "$ref": "#/components/schemas/작업id" }
        },
        "anyOf": [ { "required": ["휴대폰", "인증id", "인증번호"] }, { "required": ["토큰"] } ]
      },
      "내예약바꾸기요청": {
        "type": "object",
        "title": "⑤ 옮기기·취소 (reschedule / cancel)",
        "description": "`바꿈` 이 `가능` 인 예약만. 옮기기는 그 예약의 `표` 이름에 맞는 ① 빈 시간표의 시각으로(표가 빈 문자열이면 옮기기는 전화, 취소만 가능). 대장류는 새 날짜도 2일 이상 뒤여야 한다. 토큰당 5회.",
        "required": ["동작", "토큰", "열쇠", "무엇"],
        "properties": {
          "동작": { "const": "내예약바꾸기" },
          "토큰": { "$ref": "#/components/schemas/hex32" },
          "열쇠": { "type": "string", "pattern": "^[0-9a-f]{1,32}$", "description": "④′ 목록 항목의 열쇠" },
          "무엇": { "type": "string", "enum": ["옮기기", "취소"], "description": "reschedule / cancel" },
          "새날짜": { "$ref": "#/components/schemas/날짜", "description": "옮기기일 때 필수" },
          "새시각": { "$ref": "#/components/schemas/시각", "description": "옮기기일 때 필수" },
          "사유": { "type": "string", "enum": ["일정 변경", "개인 사정", "기타"], "description": "취소일 때 필수 — schedule change / personal reasons / other" },
          "작업id": { "$ref": "#/components/schemas/작업id" }
        },
        "allOf": [
          {"$comment": "옮기기는 새날짜·새시각 필수", "if": {"required": ["무엇"], "properties": {"무엇": {"const": "옮기기"}}}, "then": {"required": ["새날짜", "새시각"]}},
          {"$comment": "취소는 사유 필수", "if": {"required": ["무엇"], "properties": {"무엇": {"const": "취소"}}}, "then": {"required": ["사유"]}}
        ]
      },
      "내예약결과요청": {
        "type": "object",
        "title": "④′·⑤′ 내 예약 결과 폴링 (poll list / change result)",
        "required": ["동작", "id"],
        "properties": { "동작": { "const": "내예약결과" }, "id": { "$ref": "#/components/schemas/hex32", "description": "④ 또는 ⑤ 응답의 요청id" } }
      },

      "빈시간응답": {
        "type": "object",
        "required": ["ok", "자료"],
        "properties": {
          "ok": { "const": true },
          "자료": {
            "oneOf": [
              {
                "type": "object",
                "required": ["만든때", "기준", "표"],
                "properties": {
                  "만든때": { "type": "string", "description": "표를 만든 시각(ISO 8601, +09:00). 2시간 넘으면 신청 거절." , "examples": ["2026-10-13T08:37:24+09:00"] },
                  "기준": { "type": "string", "description": "무엇을 기준으로 만든 표인지(사람용)", "examples": ["예약판 사본 07:55"] },
                  "표": {
                    "type": "object",
                    "description": "표이름 → { 날짜 → 시작 시각 배열 | \"휴진\" }. 빈 배열은 그날 자리 없음.",
                    "propertyNames": { "$ref": "#/components/schemas/표이름" },
                    "additionalProperties": {
                      "type": "object",
                      "propertyNames": { "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
                      "additionalProperties": { "oneOf": [ { "type": "array", "items": { "$ref": "#/components/schemas/시각" } }, { "const": "휴진" } ] }
                    }
                  }
                }
              },
              { "type": "null", "description": "지금 표를 보여줄 수 없음 — 전화 안내" }
            ]
          }
        },
        "examples": [ { "ok": true, "자료": { "만든때": "2026-10-13T08:37:24+09:00", "기준": "예약판 사본 07:55", "표": { "위내시경": { "2026-10-14": ["09:20", "09:40"] }, "대장": { "2026-10-15": ["11:40"], "2026-10-17": [] }, "초음파1": { "2026-10-14": ["09:20"] }, "초음파2": { "2026-10-14": ["11:40"] }, "검진": { "2026-10-14": ["09:40", "09:50"] } } } } ]
      },
      "인증요청응답": {
        "type": "object",
        "title": "② 성공",
        "required": ["ok", "인증id", "유효초"],
        "properties": { "ok": { "const": true }, "인증id": { "$ref": "#/components/schemas/hex32" }, "유효초": { "type": "integer", "description": "인증번호 유효 시간(초), 300" } }
      },
      "신청응답": {
        "type": "object",
        "title": "③ 성공 — 아직 예약된 것이 아니다. 결과를 폴링한다.",
        "required": ["ok", "신청id"],
        "properties": { "ok": { "const": true }, "신청id": { "$ref": "#/components/schemas/hex32" } }
      },
      "결과응답": {
        "type": "object",
        "title": "③′ 신청 결과",
        "required": ["ok", "상태"],
        "properties": {
          "ok": { "type": "boolean" },
          "상태": { "type": "string", "enum": ["대기", "확정", "접수", "마감", "실패", "없음"], "description": "대기: 처리 중(2~3초 뒤 다시, 90초까지) · 확정: 예약 완료(모든 검사 — 대장류는 문구에 장정결제 받을 마감일) · 접수: 예전 값(대장류 즉시 확정 전) — 지금은 쓰지 않음 · 마감: 그 칸이 찼음 → 다른 시각 · 실패: 예약 안 됨(문구) · 없음: 그런 id 없음(24시간 지남)" },
          "문구": { "type": "string", "description": "환자에게 그대로 전할 수 있는 한국어 문장" },
          "바꾸기": { "const": true, "description": "실패 중 「같은 번호로 같은 검사 예약이 이미 있음」 — 새로 잡지 말고 ④⑤ 내 예약 바꾸기로" }
        }
      },
      "내예약목록응답": {
        "type": "object",
        "title": "④ 성공 — 목록은 ④′로 받는다",
        "required": ["ok", "토큰", "요청id", "유효초"],
        "properties": { "ok": { "const": true }, "토큰": { "$ref": "#/components/schemas/hex32" }, "요청id": { "$ref": "#/components/schemas/hex32" }, "유효초": { "type": "integer", "description": "토큰 남은 시간(초), 최대 1800" } }
      },
      "내예약바꾸기응답": {
        "type": "object",
        "title": "⑤ 성공 — 결과는 ⑤′로 받는다",
        "required": ["ok", "요청id"],
        "properties": { "ok": { "const": true }, "요청id": { "$ref": "#/components/schemas/hex32" } },
        "additionalProperties": false
      },
      "내예약결과응답": {
        "type": "object",
        "title": "④′·⑤′ 결과",
        "required": ["ok", "상태"],
        "properties": {
          "ok": { "type": "boolean" },
          "상태": { "type": "string", "enum": ["대기", "목록", "완료", "마감", "실패", "없음"], "description": "대기: 처리 중 · 목록: 예약 목록이 왔다(`목록`) · 완료: 옮기기/취소 반영됨 · 마감: 새 시각이 찼음(대장류는 2일 이상 뒤여야 함) · 실패: 반영 안 됨(문구) · 없음: 그런 id 없음" },
          "문구": { "type": "string" },
          "목록": { "type": "array", "maxItems": 20, "items": { "$ref": "#/components/schemas/내예약항목" }, "description": "상태가 목록일 때. 이 번호로 잡힌 앞으로 남은 예약." },
          "네이버": { "const": true, "description": "실패 중 「네이버 예약에서 들어온 예약」 — 네이버 예약 화면(https://booking.naver.com/booking/16/bizes/1707149)에서 바꾸도록 안내" }
        }
      },
      "내예약항목": {
        "type": "object",
        "required": ["열쇠", "날짜", "시각", "검사", "이름", "표", "바꿈", "까닭"],
        "properties": {
          "열쇠": { "type": "string", "description": "⑤에 그대로 보내는 예약 식별값. 목록을 다시 받으면 바뀔 수 있다." },
          "날짜": { "$ref": "#/components/schemas/날짜" },
          "시각": { "$ref": "#/components/schemas/시각" },
          "검사": { "type": "string", "description": "환자용 검사 이름 — 위내시경 · 대장내시경 · 위·대장내시경 · 맞춤형 종합검진 · 초음파 (부위) · 건강검진 (종류)" },
          "이름": { "type": "string", "description": "첫 글자만 보이는 이름, 예: 홍OO" },
          "표": { "oneOf": [ { "$ref": "#/components/schemas/표이름" }, { "const": "" } ], "description": "옮길 때 볼 빈 시간표 이름. 빈 문자열이면 옮기기는 전화, 취소만 가능." },
          "바꿈": { "type": "string", "enum": ["가능", "네이버", "전화", "임박"], "description": "가능: 옮기기(표가 있을 때)·취소 · 네이버: 네이버 예약 화면에서 · 전화: 옛 홈페이지(Wix)·출처 불명 → 02-871-1936 · 임박: 대장류 검사 2일 전부터 → 전화" },
          "까닭": { "type": "string", "description": "바꿈이 가능이 아닐 때 환자에게 전할 이유" }
        }
      },
      "실패응답": {
        "type": "object",
        "title": "실패 (ok: false, 코드)",
        "required": ["ok", "코드", "문구"],
        "properties": { "ok": { "const": false }, "코드": { "$ref": "#/components/schemas/오류코드" }, "문구": { "type": "string", "description": "환자에게 그대로 전할 수 있는 한국어 문장" } }
      },
      "서버오류응답": {
        "type": "object",
        "title": "본문 문제 (ok: false, err)",
        "description": "JSON 이 아니거나(읽기 실패) · 60KB 넘음(너무 큼) · 알 수 없는 동작(운영용 자리로 떨어져 「키 틀림」)",
        "required": ["ok", "err"],
        "properties": { "ok": { "const": false }, "err": { "type": "string", "enum": ["읽기 실패", "너무 큼", "키 틀림"] } }
      }
    }
  }
}
