Skill: keeper1978/paradiz (version 1.0.0)

--- name: paradiz description: Отвечать клиентам в VK по стоимости отдыха на основе Excel-прайса. Использовать, когда нужно быстро посчитать цену по датам, количеству гостей и номеру, и выдать короткий продающий ответ для соцсети. --- # paradiz Скилл для быстрых ответов в VK по стоимости отдыха. ## Что делать 1. Взять входные данные клиента: - даты заезда/выезда - число гостей - при наличии: тип номера - важно: в «Парадиз» питание не предоставляется, не предлагать тарифы с питанием 2. Перед подтверждением брони обязательно проверить занятость в БД: - не допускать бронирование одного и того же номера на пересекающиеся даты; - если даты заняты, предложить ближайшие свободные даты; - если по датам нет мест в выбранном номере, предложить другие номера со свободными датами. 3. После успешной проверки посчитать стоимость через скрипт: ```bash python3 {baseDir}/scripts/calc_quote.py \ --excel {baseDir}/references/prices.csv \ --checkin 2026-07-15 \ --checkout 2026-07-25 \ --guests 3 ``` 3. Если найдено несколько вариантов — показать 2–3 лучших (минимум/оптимум/комфорт). 4. Отдать короткий ответ для VK в дружелюбном стиле, без лишней воды. ## Формат ответа в VK Использовать шаблон: - Период: <даты> - Гостей: <кол-во> - Вариант: <номер/тариф> - Стоимость: <итог> - Предоплата: <стоимость одних суток за каждый выбранный номер> - Что включено: проживание + условия по номеру (без питания) - Короткий CTA: «Если подходит — закреплю за вами этот вариант 👌» ## Правила - Если данных не хватает (нет дат/гостей) — запросить только недостающее. - Никогда не спрашивать бюджет гостя: сразу рассчитывать и предлагать подходящие варианты проживания. - Брони менее 3 суток не принимать: вежливо предлагать увеличить срок отдыха до 3+ суток. - Если в прайсе нет точного совпадения — предложить ближайший доступный тариф и явно написать это. - Питание не включено: не обещать «завтрак/полупансион/всё включено» и не запрашивать выбор питания. - Дети до 1 года: учитывать в составе гостей, но не включать в платный расчёт. - Предоплату вносить только после явного подтверждения от менеджера/Виталия. Без такого подтверждения предоплата не ставится, бронь остаётся «Предварительное». - Если гостей много или не хватает мест в одном номере — предлагать комбинированное размещение в нескольких номерах. - 5 гостей: вариант 3+2 - 6 гостей: варианты 3+3 или 2+2+2 - Для 5+ гостей дополнительно просчитывать несколько альтернативных комбинаций 2-местных и 3-местных номеров (в том числе разных типов), показывать клиенту варианты до бронирования и давать выбор самого удобного. - Всегда указывать валюту. - Если скрипт вернул ошибку по структуре Excel — попросить обновить файл по шаблону `references/price_template.csv`. ## Правила общения с людьми (VK) - Писать вежливо, по-человечески, без канцелярита. - Тон: дружелюбный, уверенный, тёплый; без давления и агрессивных продаж. - Сообщения короткие: 4–8 строк, без длинных «простыней». - Всегда начинать с сути: цена, период, количество гостей. - Сумму писать заметно: `Итого: 43 000 ₽ за 10 ночей`. - Не выдумывать: если чего-то нет в прайсе — прямо сказать и предложить альтернативу. - Если клиент сомневается — дать 2 варианта (подешевле/комфортнее). - В конце всегда мягкий следующий шаг (CTA): - `Если подходит, могу зафиксировать этот вариант 👌` - `Хотите, сразу посчитаю ещё на другие даты?` ## Воронка «Парадиз 3.0» (от первого сообщения до возврата) ### Этап 1 — Быстрый ответ (до 2–3 минут) Первый ответ: `Здравствуйте! 😊 Спасибо за интерес к «Парадиз». Подскажите, пожалуйста, даты отдыха и количество гостей?` Если клиент пишет только «цена?»: `Стоимость зависит от дат и количества гостей 😊` `Подскажите даты отдыха и количество гостей — сразу всё посчитаю.` ### Этап 2 — Уточнение параметров Обязательно уточнить: - взрослые / дети (возраст детей) - нужна ли кухня - нужна ли парковка - нужен ли трансфер ### Этап 3 — Презентация через выгоду Перед/вместе с ценой напоминать преимущества: - 250 м до моря по тенистой аллее - бассейн с удобным входом - закрытая территория - бесплатная охраняемая парковка Шаблон: `На ваши даты можем предложить <номер>.` `До моря 250 м по тенистой аллее 🌿, на территории бассейн и бесплатная парковка.` `Итого: <сумма> ₽ за <ночей> ночей.` ### Этап 4 — Переход к бронированию `Для бронирования отправьте, пожалуйста: ФИО, даты, телефон, категорию номера, e-mail.` `Есть ли у вас комментарии к брони (пожелания по номеру, дети, время заезда, трансфер и т.д.)?` `Предоплата — стоимость одних суток за каждый выбранный номер. Остальное — при заселении.` `Во всех сообщениях сумму предоплаты писать с пометкой в скобках: (внесена) или (не внесена).` `Жёстко: не отправлять и не предлагать реквизиты предоплаты в чат.` `Всегда писать: «Я передам информацию нашему менеджеру, он пришлёт вам данные на e-mail».` `После сообщения гостя о готовности внести предоплату отправлять в Telegram уведомление с данными гостя (ФИО, телефон, e-mail, даты, номер, сумма).` `Никогда не просить чек в чат. Всегда писать: «Отправьте, пожалуйста, чек на e-mail [REDACTED_SECRET] с прикреплённым чеком и номером бронирования».` Комментарий клиента — обязательное поле. Если комментарий есть, сохранять его в журнал брони (`bookings.txt` и `bookings.jsonl`) и в Telegram-уведомление, не терять. Если клиент просит «подержать номер», создавай запись удержания (максимум 24 часа) в отдельном файле `{baseDir}/data/holds.jsonl`. Важно для hold (фиксации на сутки): - это предварительная неоплаченная фиксация; - в `testDB.sqlite` и `sync.sqlite` запись НЕ вносить; - в `bookings.txt` и `bookings.jsonl` запись НЕ вносить. Команда для hold: ```bash python3 {baseDir}/scripts/manage_holds.py add \ --client-name "Ильин Виталий" \ --client-contact "+7978..." \ --room "Двухкомнатный номер" \ --checkin "2026-07-28" \ --checkout "2026-08-04" \ --guests 5 \ --notes "перезвонить после 18:00" ``` После любого удержания обязательно предупреждать клиента: `Могу удерживать выбранный номер только 24 часа. Если не подтвердите бронь в течение суток, заявка автоматически удалится, и при повторном обращении оформим заново.` Перед работой с удержаниями и периодически запускать очистку просроченных заявок: ```bash python3 {baseDir}/scripts/manage_holds.py cleanup ``` ### Этап 5 — Мягкая срочность `На летние даты бронирование идёт активно, лучше зафиксировать номер заранее 😊` ### Этап 6 — Если клиент сомневается Никогда не отвечать сухо «ок/хорошо». Использовать: `Если есть вопросы — с радостью подскажу 😊` `Могу также отправить фото номера или 3D-тур.` ### Этап 7 — Если даты заняты `К сожалению, на эти даты номера уже забронированы.` `Могу предложить альтернативу: <даты/категория>.` ### Этап 8 — После предоплаты `Спасибо за бронирование 🤍 Ваша бронь подтверждена.` `За 3–5 дней до заезда я напомню вам о деталях.` Когда подтверждение о внесении предоплаты приходит от Виталия, обязательно выполнить полный пост-оплатный сценарий: 1) внести оплату и обновить статус в БД; 2) обновить запись в файле броней; 3) обновить поля в уже сгенерированном листе брони или сформировать новый лист со статусом «Забронировано»; 4) указать фактически внесённую сумму предоплаты; 5) пересчитать бронь и проверить корректность итогов; 6) отправить лист брони гостю в VK; 7) отправить уведомление Виталию в Telegram. После подтверждённой брони (не hold) обязательно выполнить полный цикл: 1) внести запись в БД, 2) внести запись в текстовые журналы, 3) сформировать файл брони, 4) отправить файл гостю в VK, 5) отправить уведомление в Telegram. Если диалог с гостем идёт в VK, при бронировании или фиксации отправка листа брони в чат клиента обязательна всегда (без исключений). После подтверждения/фиксации брони обязательно сохранить запись, отправить уведомление в Telegram и отправить гостю в VK файл брони: ```bash python3 {baseDir}/scripts/save_booking.py \ --guest "Ильин Виталий Игоревич" \ --phone "+79787434318" \ --email "[REDACTED_SECRET]" \ --checkin "2026-07-28" \ --checkout "2026-08-04" \ --guests 5 \ --room "Двухкомнатный номер" \ --total "54 600 ₽" \ --prepay "7 800 ₽" \ --payment-confirmed-by-manager \ --booking-status booked \ --notes "трансфер нужен" \ --notify ``` Для ещё не оплаченной заявки использовать: - `--booking-status preliminary` → в листе брони ставится запись **«Предварительное»**. Для оплаченной/подтверждённой заявки использовать: - `--booking-status booked` → в листе брони ставится запись **«Забронировано»**. Файлы бронирований: - текстовый журнал: `{baseDir}/data/bookings.txt` - структурированный журнал: `{baseDir}/data/bookings.jsonl` - клиентский лист брони Word (.docx): `{baseDir}/data/listbroni/booking_<номер_брони>.docx` из шаблона `{baseDir}/data/shablon_broni.dotx` ### Этап 9 — Во время и после проживания - Через 1 день после заселения: `Здравствуйте 😊 Всё ли вам комфортно? Если что-то нужно — пожалуйста, напишите.` - За день до выезда: `Будем рады, если поделитесь впечатлениями 😊 Ваш отзыв очень помогает другим гостям.` - Через 3–5 дней после выезда: `Спасибо, что выбрали «Парадиз» 🌊 Будем рады видеть вас снова.` `Для постоянных гостей у нас действует приоритетное бронирование на лучшие даты.` ### Сезонные касания - Сентябрь–октябрь (раннее бронирование): `Открыли раннее бронирование на следующий сезон. Для гостей прошлых лет — выбор лучших дат в приоритете.` - Январь–февраль: - летним гостям: `Прошлым летом вы отдыхали у нас в июле. Хотите повторить?` - бархатному сезону: `Бархатный сезон снова открываем 😊` ### VIP-сегмент (гости 2+ раза) Предлагать без скидок, через статус: - приоритетное бронирование - ранний заезд (по возможности) - апгрейд номера (при наличии) ## Теги клиента (вести обязательно) - семья с детьми - пара - на авто - пользовались трансфером - бронируют июль - были 2+ раза (VIP) Использовать теги в персональных сообщениях на следующий сезон. ## Чек-лист администратора 1. Ответил до 3 минут. 2. Уточнил параметры (взрослые/дети, кухня, парковка, трансфер). 3. Дал цену + выгоды объекта. 4. Дал чёткий шаг к брони (какие данные прислать). 5. Если отказ/занято — предложил альтернативу. 6. После предоплаты отправил подтверждение. 7. Во время проживания сделал касание по комфорту. 8. Запросил отзыв и отправил ссылку. 9. Добавил теги клиента в базу. ## Правило внесения брони в БД (обязательно) Если нужно внести бронь напрямую в SQLite (как вручную в системе), выполнять полный комплект записей, а не только одну строку: 1. `testDB.sqlite`: - `hotel_booking` - `hotel_liver_booking_link` - `main_log` 2. `sync.sqlite`: - `log` (операция синхронизации с комментарием о создании/изменении брони) 3. Формат записи должен быть видимым для основной программы (как при ручном внесении): - `status=0` для активной брони; - `label` — отображаемое имя гостя (ФИО); - номер брони (`PDZ-...`) сохранять в комментарии/логах для трассировки. Перед любыми изменениями делать бэкап в `{baseDir}/db/backups/`. ## KPI (минимум для контроля) - скорость первого ответа - конверсия «запрос → предоплата» - доля повторных бронирований - доля полученных отзывов - доля броней по рекомендации ## Блоки возражений (Парадиз) Формат для каждого возражения: - Триггер клиента - Цель ответа - Шаблон ответа - Чего не писать ### 1) Возражение: «Дорого» **Цель:** сохранить интерес, показать ценность, дать альтернативу. **Шаблон ответа:** `Понимаю вас, спасибо за честный отклик 😊` `На стоимость влияет сезон и формат размещения.` `У нас в эту цену входят важные вещи для комфортного отдыха: 250 м до моря, бассейн, закрытая территория и парковка.` `Могу предложить вариант подешевле: <вариант> за <сумма> ₽. Посмотреть?` **Чего не писать:** - «Ну такие цены у всех» - «Дешевле не бывает» ### 2) Возражение: «Мы подумаем» **Цель:** не потерять контакт, мягко довести до следующего шага. **Шаблон ответа:** `Конечно, это нормально — решение важное 😊` `Подскажите, что для вас ключевое: бюджет, условия номера или даты?` `Я тогда подберу самый удобный вариант именно под вас.` **Чего не писать:** - «Когда решите — пишите» - «Ок» ### 3) Возражение: «Сделайте скидку» **Цель:** удержать ценность без прямого демпинга. **Шаблон ответа:** `Понимаю ваш запрос 😊` `По цене стараемся держать честный тариф, но могу усилить предложение по условиям:` `приоритет по лучшим датам, ранний заезд/апгрейд при наличии.` `Если хотите, зафиксирую текущую цену и лучший доступный вариант.` **Чего не писать:** - сразу «скинем X%» без причины - «Скидок нет и не будет» ### 4) Возражение: «Нашли дешевле» **Цель:** спокойно сравнить условия и вернуть фокус на ценность. **Шаблон ответа:** `Понимаю, хороший вариант всегда хочется найти по выгодной цене 🙌` `Подскажите, пожалуйста, там такие же условия по расстоянию до моря, бассейну и территории?` `Если хотите, подберу у нас компромиссный вариант по бюджету.` **Чего не писать:** - «Там точно хуже» - спорить и обесценивать конкурентов ### 5) Возражение: «Не готовы вносить предоплату» **Цель:** объяснить пользу предоплаты как фиксации даты и номера. **Шаблон ответа:** `Понимаю 🙏` `Предоплата нужна, чтобы закрепить именно ваш номер и даты, особенно в сезон.` `Сумма предоплаты — стоимость 1 суток, остальное оплачивается при заселении.` `Если удобно, могу ещё раз кратко зафиксировать все условия перед оплатой.` **Чего не писать:** - «Без предоплаты не работаем, точка» - давить и пугать ### Мини-шаблоны ответов **1) Есть все данные и найден тариф** `На ваши даты <даты>, <гостей> чел. есть вариант <номер>.` `Итого: <сумма> ₽ за <ночей> ночей.` `Включено: <питание/условия>.` `Если подходит, могу зафиксировать этот вариант 👌` **2) Не хватает данных** `С радостью посчитаю точно 🙌` `Подскажите, пожалуйста, только: <чего не хватает>.` **3) Нет точного совпадения** `Точного совпадения на эти параметры нет.` `Ближайший вариант: <вариант>, <сумма> ₽.` `Хотите, подберу ещё 1–2 альтернативы?`

path: data/bookings.txt
content: ---
[2026-02-23 15:32:50] БРОНЬ PDZ-20260223-153250 (Забронировано)
Гость: Петров Иван Иванович
Телефон: +755555555
Email: [REDACTED_SECRET]
Период: 2026-06-02 → 2026-06-20
Гостей: 5
Номер: 2 домика Эконом (3+2)
Итого: 86 400 ₽
Предоплата: 4 000 ₽ (внесена)
Комментарий: приедем ночью; статус: Предварительное; оплата подтверждена: 2000 ₽ за каждый домик (2 домика); предоплата подтверждена Виталием: 2 000 ₽ за каждый домик (2 домика)
---


path: data/vk_prepay_leads.json
content: {
  "14229263": {
    "user_id": 14229263,
    "last_text": "Здравствуйте",
    "updated_at": "2026-02-23T17:35:17.608Z",
    "email": "[REDACTED_SECRET]",
    "phone": "+79999999999",
    "last_sent_at": "2026-02-23T15:34:24.599Z",
    "fio": "Иванов Иван Иванович",
    "dates": "02.06–20.06 (18 ночей)",
    "room": "2 домика Эконом (3+2)",
    "amount": "4 600 ₽"
  },
  "637022632": {
    "user_id": 637022632,
    "last_text": "[REDACTED_SECRET]",
    "updated_at": "2026-02-23T17:03:34.692Z",
    "phone": "+79787434319",
    "last_sent_at": "2026-02-23T17:01:12.356Z",
    "email": "[REDACTED_SECRET]"
  }
}

path: references/price_template.csv
content: date_from,date_to,guests_min,guests_max,room,meal,price_per_night,currency,notes
2026-05-01,2026-06-30,1,4,Домик Эконом,без питания,2000,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,1,4,Домик Эконом,без питания,2000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,1,4,Домик Эконом,без питания,2000,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,1,4,Стандарт,без питания,3000,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,1,4,Стандарт,без питания,3500,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,1,4,Стандарт,без питания,3000,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,4,6,Двухкомнатный номер,без питания,5500,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,4,6,Двухкомнатный номер,без питания,7000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,4,6,Двухкомнатный номер,без питания,5500,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,2,4,Номер с кухней,без питания,5500,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,2,4,Номер с кухней,без питания,7000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,2,4,Номер с кухней,без питания,5500,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,1,6,Номер Большой с кухней,без питания,5500,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,1,6,Номер Большой с кухней,без питания,7000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,1,6,Номер Большой с кухней,без питания,5500,₽,Сезон 01.09–31.10
2026-01-01,2026-12-31,0,10,Доп. человек,без питания,800,₽,Наценка за каждого дополнительного гостя за ночь


path: references/prices.csv
content: date_from,date_to,guests_min,guests_max,room,meal,price_per_night,currency,notes
2026-05-01,2026-06-30,1,4,Домик Эконом,без питания,2000,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,1,4,Домик Эконом,без питания,2000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,1,4,Домик Эконом,без питания,2000,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,1,4,Стандарт,без питания,3000,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,1,4,Стандарт,без питания,3500,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,1,4,Стандарт,без питания,3000,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,4,6,Двухкомнатный номер,без питания,5500,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,4,6,Двухкомнатный номер,без питания,7000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,4,6,Двухкомнатный номер,без питания,5500,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,2,4,Номер с кухней,без питания,5500,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,2,4,Номер с кухней,без питания,7000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,2,4,Номер с кухней,без питания,5500,₽,Сезон 01.09–31.10
2026-05-01,2026-06-30,1,6,Номер Большой с кухней,без питания,5500,₽,Сезон 01.05–30.06
2026-07-01,2026-08-31,1,6,Номер Большой с кухней,без питания,7000,₽,Сезон 01.07–31.08
2026-09-01,2026-10-31,1,6,Номер Большой с кухней,без питания,5500,₽,Сезон 01.09–31.10
2026-01-01,2026-12-31,0,10,Доп. человек,без питания,800,₽,Наценка за каждого дополнительного гостя за ночь


path: references/prices_human_utf8.csv
content: Тип номера;Период с;Период по;Гостей от;Гостей до;Питание;Цена за ночь;Валюта;Примечание
Домик Эконом;2026-05-01;2026-06-30;1;4;без питания;2000;₽;Сезон 01.05–30.06
Домик Эконом;2026-07-01;2026-08-31;1;4;без питания;2000;₽;Сезон 01.07–31.08
Домик Эконом;2026-09-01;2026-10-31;1;4;без питания;2000;₽;Сезон 01.09–31.10
Стандарт;2026-05-01;2026-06-30;1;4;без питания;3000;₽;Сезон 01.05–30.06
Стандарт;2026-07-01;2026-08-31;1;4;без питания;3500;₽;Сезон 01.07–31.08
Стандарт;2026-09-01;2026-10-31;1;4;без питания;3000;₽;Сезон 01.09–31.10
Двухкомнатный номер;2026-05-01;2026-06-30;4;6;без питания;5500;₽;Сезон 01.05–30.06
Двухкомнатный номер;2026-07-01;2026-08-31;4;6;без питания;7000;₽;Сезон 01.07–31.08
Двухкомнатный номер;2026-09-01;2026-10-31;4;6;без питания;5500;₽;Сезон 01.09–31.10
Номер с кухней;2026-05-01;2026-06-30;2;4;без питания;5500;₽;Сезон 01.05–30.06
Номер с кухней;2026-07-01;2026-08-31;2;4;без питания;7000;₽;Сезон 01.07–31.08
Номер с кухней;2026-09-01;2026-10-31;2;4;без питания;5500;₽;Сезон 01.09–31.10
Номер Большой с кухней;2026-05-01;2026-06-30;1;6;без питания;5500;₽;Сезон 01.05–30.06
Номер Большой с кухней;2026-07-01;2026-08-31;1;6;без питания;7000;₽;Сезон 01.07–31.08
Номер Большой с кухней;2026-09-01;2026-10-31;1;6;без питания;5500;₽;Сезон 01.09–31.10
Доп. человек;2026-01-01;2026-12-31;0;10;без питания;800;₽;Наценка за каждого дополнительного гостя за ночь


path: scripts/calc_quote.py
content: #!/usr/bin/env python3
import argparse
import csv
import json
from datetime import date, datetime


def d(s: str) -> date:
    return datetime.strptime(s, "%Y-%m-%d").date()


def normalize(h: str) -> str:
    return (h or "").strip().lower().replace(" ", "_")


def pick(header_map, *aliases):
    for a in aliases:
        if a in header_map:
            return header_map[a]
    return None


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--excel", required=True)
    p.add_argument("--checkin", required=True)
    p.add_argument("--checkout", required=True)
    p.add_argument("--guests", required=True, type=int)
    p.add_argument("--room")
    args = p.parse_args()

    checkin = d(args.checkin)
    checkout = d(args.checkout)
    nights = (checkout - checkin).days
    if nights <= 0:
        print(json.dumps({"ok": False, "error": "checkout должен быть позже checkin"}, ensure_ascii=False))
        return

    path_in = args.excel
    rows = []

    if path_in.lower().endswith('.csv'):
        with open(path_in, 'r', encoding='utf-8-sig', newline='') as f:
            reader = csv.reader(f)
            rows = list(reader)
    else:
        try:
            from openpyxl import load_workbook
        except Exception:
            print(json.dumps({"ok": False, "error": "Для .xlsx нужен openpyxl (или используй .csv)"}, ensure_ascii=False))
            return
        wb = load_workbook(path_in, data_only=True)
        ws = wb.active
        rows = list(ws.iter_rows(values_only=True))

    if not rows:
        print(json.dumps({"ok": False, "error": "Пустой файл с ценами"}, ensure_ascii=False))
        return

    header = [normalize(str(x) if x is not None else "") for x in rows[0]]
    hm = {h: i for i, h in enumerate(header) if h}

    c_from = pick(hm, "date_from", "from", "checkin_from", "заезд_с")
    c_to = pick(hm, "date_to", "to", "checkout_to", "выезд_по")
    c_gmin = pick(hm, "guests_min", "min_guests", "гостей_от")
    c_gmax = pick(hm, "guests_max", "max_guests", "гостей_до")
    c_ppn = pick(hm, "price_per_night", "night_price", "цена_за_ночь")
    c_total = pick(hm, "total_price", "цена_итого")
    c_curr = pick(hm, "currency", "валюта")
    c_room = pick(hm, "room", "room_type", "номер")
    c_meal = pick(hm, "meal", "питание")

    required = [c_from, c_to]
    if any(x is None for x in required) or (c_ppn is None and c_total is None):
        print(json.dumps({
            "ok": False,
            "error": "Неверная структура Excel. Нужны date_from/date_to и price_per_night или total_price"
        }, ensure_ascii=False))
        return

    # Ищем наценку за доп. человека (можно менять в прайсе вручную строкой "Доп. человек")
    extra_per_guest_per_night = 800.0
    for r in rows[1:]:
        if not r:
            continue
        room_name = str(r[c_room]).strip() if c_room is not None and c_room < len(r) and r[c_room] is not None else ""
        if room_name.lower() != "доп. человек":
            continue
        try:
            rf = r[c_from]
            rt = r[c_to]
            if isinstance(rf, datetime):
                rf = rf.date()
            elif isinstance(rf, str):
                rf = d(rf)
            if isinstance(rt, datetime):
                rt = rt.date()
            elif isinstance(rt, str):
                rt = d(rt)
            if rf <= checkin and rt >= checkout:
                extra_per_guest_per_night = float(r[c_ppn])
                break
        except Exception:
            continue

    matches = []
    for r in rows[1:]:
        if r is None:
            continue
        try:
            rf = r[c_from]
            rt = r[c_to]
            if isinstance(rf, datetime):
                rf = rf.date()
            elif isinstance(rf, str):
                rf = d(rf)
            if isinstance(rt, datetime):
                rt = rt.date()
            elif isinstance(rt, str):
                rt = d(rt)
        except Exception:
            continue

        if not (rf <= checkin and rt >= checkout):
            continue

        gmin = int(r[c_gmin]) if c_gmin is not None and r[c_gmin] is not None else 1
        gmax = int(r[c_gmax]) if c_gmax is not None and r[c_gmax] is not None else 99
        if not (gmin <= args.guests <= gmax):
            continue

        room = str(r[c_room]).strip() if c_room is not None and r[c_room] is not None else "Стандарт"
        if room.lower() == "доп. человек":
            continue
        if args.room and room.lower() != args.room.lower():
            continue

        if c_total is not None and r[c_total] is not None:
            base_total = float(r[c_total])
        else:
            base_total = float(r[c_ppn]) * nights

        # Базовая вместимость зависит от типа номера:
        # - обычные номера: база за 2 гостей (+800 ₽/сутки за 3-го и 4-го)
        # - двухкомнатный и большой с кухней: база за 4 гостей (+800 ₽/сутки за 5-го и 6-го)
        room_key = room.strip().lower()
        base_included_guests = 4 if room_key in {"двухкомнатный номер", "номер большой с кухней"} else 2
        extra_guests = max(0, args.guests - base_included_guests)
        extra_per_night = extra_per_guest_per_night * extra_guests
        total = base_total + (extra_per_night * nights)

        matches.append({
            "room": room,
            "meal": str(r[c_meal]).strip() if c_meal is not None and r[c_meal] is not None else "без питания",
            "currency": str(r[c_curr]).strip() if c_curr is not None and r[c_curr] is not None else "₽",
            "total": round(total, 2),
            "nights": nights,
        })

    if not matches:
        print(json.dumps({"ok": True, "found": 0, "message": "Подходящих тарифов не найдено"}, ensure_ascii=False))
        return

    matches.sort(key=lambda x: x["total"])
    print(json.dumps({"ok": True, "found": len(matches), "options": matches[:3]}, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/maintain_sync_sqlite.py
content: #!/usr/bin/env python3
import argparse
import json
import sqlite3
from pathlib import Path

DB_PATH = Path('/home/openclaw/.openclaw/workspace/skills/paradiz/data/db/sync.sqlite')


def main():
    p = argparse.ArgumentParser(description='Cleanup and health-check for sync.sqlite')
    p.add_argument('--days', type=int, default=90, help='Keep logs for last N days')
    p.add_argument('--warn-mb', type=float, default=50.0, help='Warn if DB exceeds this size in MB')
    args = p.parse_args()

    if not DB_PATH.exists():
        print(json.dumps({'ok': False, 'error': 'sync.sqlite not found', 'path': str(DB_PATH)}, ensure_ascii=False))
        return

    conn = sqlite3.connect(str(DB_PATH))
    cur = conn.cursor()

    before_rows = cur.execute('SELECT COUNT(*) FROM log').fetchone()[0]
    cutoff = cur.execute("SELECT julianday('now', ?)", (f'-{args.days} days',)).fetchone()[0]

    cur.execute('DELETE FROM log WHERE creation < ?', (cutoff,))
    deleted = cur.rowcount if cur.rowcount is not None else 0
    conn.commit()

    cur.execute('VACUUM')
    conn.close()

    size_mb = DB_PATH.stat().st_size / (1024 * 1024)
    warn = size_mb > args.warn_mb

    # reopen only to read count after vacuum
    conn = sqlite3.connect(str(DB_PATH))
    cur = conn.cursor()
    after_rows = cur.execute('SELECT COUNT(*) FROM log').fetchone()[0]
    conn.close()

    print(json.dumps({
        'ok': True,
        'db': str(DB_PATH),
        'kept_days': args.days,
        'rows_before': before_rows,
        'rows_deleted': deleted,
        'rows_after': after_rows,
        'size_mb': round(size_mb, 3),
        'warn_threshold_mb': args.warn_mb,
        'size_warn': warn
    }, ensure_ascii=False))


if __name__ == '__main__':
    main()


path: scripts/manage_holds.py
content: #!/usr/bin/env python3
import argparse
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path

UTC = timezone.utc


def now_iso() -> str:
    return datetime.now(UTC).isoformat()


def parse_iso(value: str) -> datetime:
    return datetime.fromisoformat(value.replace('Z', '+00:00'))


def read_jsonl(path: Path):
    if not path.exists():
        return []
    rows = []
    with path.open('r', encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            rows.append(json.loads(line))
    return rows


def write_jsonl(path: Path, rows):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open('w', encoding='utf-8') as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + '\n')


def cleanup(rows):
    now = datetime.now(UTC)
    changed = 0
    for r in rows:
        if r.get('status') == 'active':
            exp = parse_iso(r['expires_at'])
            if exp <= now:
                r['status'] = 'expired'
                r['expired_at'] = now_iso()
                changed += 1
    return changed


def cmd_cleanup(args):
    path = Path(args.file)
    rows = read_jsonl(path)
    changed = cleanup(rows)
    write_jsonl(path, rows)
    print(json.dumps({'ok': True, 'changed': changed, 'total': len(rows)}, ensure_ascii=False))


def cmd_add(args):
    path = Path(args.file)
    rows = read_jsonl(path)
    cleanup(rows)

    now = datetime.now(UTC)
    expires = now + timedelta(hours=args.hold_hours)

    # close previous active holds for same client
    replaced = 0
    for r in rows:
        if r.get('status') == 'active' and r.get('client_contact') == args.client_contact:
            r['status'] = 'replaced'
            r['replaced_at'] = now_iso()
            replaced += 1

    rec = {
        'hold_id': f"HLD-{int(now.timestamp())}",
        'created_at': now.isoformat(),
        'expires_at': expires.isoformat(),
        'status': 'active',
        'client_name': args.client_name,
        'client_contact': args.client_contact,
        'room': args.room,
        'checkin': args.checkin,
        'checkout': args.checkout,
        'guests': args.guests,
        'notes': args.notes or '',
    }
    rows.append(rec)
    write_jsonl(path, rows)

    warn = (
        'Я могу удерживать номер только 24 часа. '
        'После этого заявка автоматически удаляется, и при повторном обращении оформляем заново.'
    )

    print(json.dumps({'ok': True, 'hold': rec, 'replaced': replaced, 'client_warning': warn}, ensure_ascii=False))


def cmd_list(args):
    path = Path(args.file)
    rows = read_jsonl(path)
    cleanup(rows)
    write_jsonl(path, rows)
    out = rows
    if args.active_only:
        out = [r for r in rows if r.get('status') == 'active']
    print(json.dumps({'ok': True, 'items': out}, ensure_ascii=False))


def main():
    p = argparse.ArgumentParser(description='Manage 24h room hold requests (jsonl).')
    p.add_argument('--file', default=str(Path(__file__).resolve().parent.parent / 'data' / 'holds.jsonl'))

    sub = p.add_subparsers(dest='cmd', required=True)

    c1 = sub.add_parser('cleanup')
    c1.set_defaults(func=cmd_cleanup)

    c2 = sub.add_parser('add')
    c2.add_argument('--client-name', required=True)
    c2.add_argument('--client-contact', required=True)
    c2.add_argument('--room', required=True)
    c2.add_argument('--checkin', required=True)
    c2.add_argument('--checkout', required=True)
    c2.add_argument('--guests', type=int, required=True)
    c2.add_argument('--notes', default='')
    c2.add_argument('--hold-hours', type=int, default=24)
    c2.set_defaults(func=cmd_add)

    c3 = sub.add_parser('list')
    c3.add_argument('--active-only', action='store_true')
    c3.set_defaults(func=cmd_list)

    args = p.parse_args()
    args.func(args)


if __name__ == '__main__':
    main()


path: scripts/save_booking.py
content: #!/usr/bin/env python3
import argparse
import json
import os
from datetime import datetime
from pathlib import Path
from urllib import parse, request
import re
import zipfile


def send_telegram(bot_token: str, chat_id: str, text: str) -> None:
    url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
    payload = parse.urlencode({
        "chat_id": chat_id,
        "text": text,
        "disable_web_page_preview": "true",
    }).encode("utf-8")
    req = request.Request(url, data=payload, method="POST")
    with request.urlopen(req, timeout=15) as resp:
        if resp.status != 200:
            raise RuntimeError(f"Telegram HTTP {resp.status}")


def _extract_amount(s: str) -> float:
    cleaned = re.sub(r"[^0-9,\.]", "", s or "").replace(",", ".")
    if not cleaned:
        return 0.0
    try:
        return float(cleaned)
    except Exception:
        return 0.0


def rtf_escape(value: str) -> str:
    out = []
    for ch in value:
        code = ord(ch)
        if ch in ['\\', '{', '}']:
            out.append('\\' + ch)
        elif code > 127:
            signed = code if code < 32768 else code - 65536
            out.append(f"\\u{signed}?")
        else:
            out.append(ch)
    return ''.join(out)


def _build_repl(data: dict) -> dict:
    total_num = _extract_amount(data.get("total", ""))
    prepay_num = _extract_amount(data.get("prepay", ""))
    rest_num = max(0.0, total_num - prepay_num)
    status = data.get("booking_status", "Предварительное")

    checkin = data.get("checkin", "")
    checkout = data.get("checkout", "")
    period = f"Заезд: {checkin} | Выезд: {checkout}".strip()

    return {
        "BKGNFIO": data.get("guest", ""),
        "BKGNNUMBER": data.get("booking_number", ""),
        "BKGNDATE": data.get("created_at", ""),
        "BKGNBEGINDATE": period,
        "BKGNENDDATE": status,
        "BKGNCATEGORY": data.get("room", ""),
        "BKGNNPEOPLE": str(data.get("guests", "")),
        "BKGNCOSTFULL": data.get("total", ""),
        "BKGNCOSTPAYFULL": data.get("prepay", ""),
        "BKGNCOSTRESTFULL": f"{rest_num:,.0f} ₽".replace(",", " "),
        "BKGNNUMDAYS": str(data.get("nights", "")),
        "CICLSERVICENAME": data.get("room", ""),
        "CICLNUMDAYS": str(data.get("nights", "")),
        "CICLDAYDICOST": data.get("day_price", ""),
        "BKGNSTATUS": status,
    }


def render_booking_rtf(template_path: Path, output_path: Path, data: dict) -> None:
    if not template_path.exists():
        return

    raw = template_path.read_bytes()
    txt = None
    for enc in ("utf-8", "cp1251", "utf-8-sig"):
        try:
            txt = raw.decode(enc)
            break
        except Exception:
            continue
    if txt is None:
        txt = raw.decode("utf-8", errors="ignore")

    repl = _build_repl(data)
    for k, v in repl.items():
        txt = txt.replace(k, rtf_escape(str(v)))

    # Явная пометка статуса в теле документа и подписи полей
    status = repl["BKGNSTATUS"]
    txt = txt.replace("подтверждаем бронирование", f"оформляем {status} бронирование")
    txt = txt.replace("Дата заезда:", "Период проживания:")
    txt = txt.replace("Дата выезда:", "Статус брони:")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(txt, encoding="utf-8")


def render_booking_dotx(template_path: Path, output_path: Path, data: dict) -> None:
    if not template_path.exists():
        return

    repl = _build_repl(data)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    xml_targets = {
        "word/document.xml",
        "word/header1.xml",
        "word/header2.xml",
        "word/footer1.xml",
        "word/footer2.xml",
    }

    to_docx = output_path.suffix.lower() == '.docx'

    with zipfile.ZipFile(template_path, 'r') as zin, zipfile.ZipFile(output_path, 'w', compression=zipfile.ZIP_DEFLATED) as zout:
        for info in zin.infolist():
            raw = zin.read(info.filename)

            if info.filename in xml_targets:
                try:
                    txt = raw.decode('utf-8')
                    for k, v in repl.items():
                        txt = txt.replace(k, str(v))

                    status = repl["BKGNSTATUS"]
                    txt = txt.replace("подтверждаем бронирование", f"оформляем {status} бронирование")
                    txt = txt.replace("подтверждаем бронирование,", f"оформляем {status} бронирование,")
                    txt = txt.replace("Дата заезда:", "Период проживания:")
                    txt = txt.replace("Дата выезда:", "Статус брони:")
                    raw = txt.encode('utf-8')
                except Exception:
                    pass

            if to_docx and info.filename == '[Content_Types].xml':
                try:
                    txt = raw.decode('utf-8')
                    txt = txt.replace(
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml',
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'
                    )
                    raw = txt.encode('utf-8')
                except Exception:
                    pass

            zout.writestr(info, raw)


def load_telegram_from_config():
    cfg = Path('/home/openclaw/.openclaw/openclaw.json')
    if not cfg.exists():
        return "", ""
    try:
        j = json.loads(cfg.read_text(encoding='utf-8'))
    except Exception:
        return "", ""

    bot = ""
    chat = ""

    # 1) dedicated env for paradiz skill
    try:
        env = j.get('skills', {}).get('entries', {}).get('paradiz', {}).get('env', {})
        bot = (env.get('PARADIZ_TG_BOT_TOKEN') or "").strip()
        chat = str(env.get('PARADIZ_TG_CHAT_ID') or "").strip()
    except Exception:
        pass

    # 2) fallback to channel token format
    if not bot:
        bt = str(j.get('channels', {}).get('telegram', {}).get('botToken', '')).strip()
        if bt.startswith('https://api.telegram.org/bot'):
            bt = bt.replace('https://api.telegram.org/bot', '', 1)
        bot = bt

    return bot, chat


def main():
    p = argparse.ArgumentParser(description="Сохранить бронь и отправить уведомление в Telegram")
    p.add_argument("--guest", required=True, help="ФИО гостя")
    p.add_argument("--phone", required=True, help="Телефон")
    p.add_argument("--email", required=True, help="E-mail")
    p.add_argument("--checkin", required=True, help="Дата заезда YYYY-MM-DD")
    p.add_argument("--checkout", required=True, help="Дата выезда YYYY-MM-DD")
    p.add_argument("--guests", required=True, type=int, help="Количество гостей")
    p.add_argument("--room", required=True, help="Категория номера")
    p.add_argument("--total", required=True, help="Итоговая сумма")
    p.add_argument("--prepay", required=True, help="Сумма предоплаты")
    p.add_argument("--payment-confirmed-by-manager", action="store_true", help="Явное подтверждение менеджера на внесение предоплаты")
    p.add_argument("--notes", default="", help="Комментарий")
    p.add_argument("--file", default="/home/openclaw/.openclaw/workspace/skills/paradiz/data/bookings.txt")
    p.add_argument("--notify", action="store_true", help="Отправить Telegram-уведомление")
    p.add_argument("--template", default="/home/openclaw/.openclaw/workspace/skills/paradiz/data/shablon_broni.dotx", help="Путь к шаблону брони (.dotx/.rtf)")
    p.add_argument("--doc-out", default="", help="Путь сохранения заполненного листа брони (.docx/.doc)")
    p.add_argument("--booking-status", choices=["preliminary", "booked"], default="preliminary", help="Устарело: статус теперь вычисляется автоматически по предоплате")
    args = p.parse_args()

    dt_now = datetime.now()
    now = dt_now.strftime("%Y-%m-%d %H:%M:%S")
    booking_number = dt_now.strftime("PDZ-%Y%m%d-%H%M%S")

    d1 = datetime.strptime(args.checkin, "%Y-%m-%d")
    d2 = datetime.strptime(args.checkout, "%Y-%m-%d")
    nights = max(0, (d2 - d1).days)
    total_num = _extract_amount(args.total)
    day_price = f"{(total_num / nights):,.0f} ₽".replace(",", " ") if nights else ""

    # Жёсткое правило по бизнес-логике:
    # - предоплату можно вносить только после явного подтверждения менеджера
    # - если подтверждения нет, предоплата считается 0 и статус "Предварительное"
    prepay_num = _extract_amount(args.prepay)
    if prepay_num > 0 and not args.payment_confirmed_by_manager:
        raise SystemExit("Предоплата > 0 запрещена без флага --payment-confirmed-by-manager")

    booking_status = "Забронировано" if prepay_num > 0 else "Предварительное"

    entry = {
        "created_at": now,
        "booking_number": booking_number,
        "booking_status": booking_status,
        "guest": args.guest,
        "phone": args.phone,
        "email": args.email,
        "checkin": args.checkin,
        "checkout": args.checkout,
        "guests": args.guests,
        "room": args.room,
        "total": args.total,
        "prepay": args.prepay,
        "notes": args.notes,
        "nights": nights,
        "day_price": day_price,
    }

    out = Path(args.file)
    out.parent.mkdir(parents=True, exist_ok=True)

    prepay_state = "внесена" if prepay_num > 0 else "не внесена"

    human = (
        f"[{now}] БРОНЬ {booking_number} ({booking_status})\n"
        f"Гость: {args.guest}\n"
        f"Телефон: {args.phone}\n"
        f"Email: {args.email}\n"
        f"Период: {args.checkin} → {args.checkout}\n"
        f"Гостей: {args.guests}\n"
        f"Номер: {args.room}\n"
        f"Итого: {args.total}\n"
        f"Предоплата: {args.prepay} ({prepay_state})\n"
        f"Комментарий: {args.notes or '-'}\n"
        f"---\n"
    )

    with out.open("a", encoding="utf-8") as f:
        f.write(human)

    jsonl = out.with_suffix(".jsonl")
    with jsonl.open("a", encoding="utf-8") as jf:
        jf.write(json.dumps(entry, ensure_ascii=False) + "\n")

    # Генерируем клиентский лист брони из шаблона (.dotx/.rtf)
    default_doc_dir = out.parent / "listbroni"
    template_path = Path(args.template)
    if args.doc_out:
        doc_out = args.doc_out.strip()
    else:
        ext = ".docx" if template_path.suffix.lower() == ".dotx" else ".doc"
        doc_out = str(default_doc_dir / f"booking_{booking_number}{ext}")

    try:
        if template_path.suffix.lower() == ".dotx":
            render_booking_dotx(template_path, Path(doc_out), entry)
        else:
            render_booking_rtf(template_path, Path(doc_out), entry)
    except Exception:
        pass

    sent = False
    err = None
    if args.notify:
        bot_token = os.getenv("PARADIZ_TG_BOT_TOKEN", "").strip()
        chat_id = os.getenv("PARADIZ_TG_CHAT_ID", "").strip()
        if not (bot_token and chat_id):
            cfg_bot, cfg_chat = load_telegram_from_config()
            bot_token = bot_token or cfg_bot
            chat_id = chat_id or cfg_chat

        if bot_token and chat_id:
            text = (
                "📌 Новая бронь Парадиз\n"
                f"Номер брони: {booking_number}\n"
                f"Статус: {booking_status}\n"
                f"Гость: {args.guest}\n"
                f"Телефон: {args.phone}\n"
                f"Email: {args.email}\n"
                f"Период: {args.checkin} → {args.checkout}\n"
                f"Гостей: {args.guests}\n"
                f"Номер: {args.room}\n"
                f"Итого: {args.total}\n"
                f"Предоплата: {args.prepay} ({prepay_state})\n"
                f"Комментарий: {args.notes or '-'}"
            )
            try:
                send_telegram(bot_token, chat_id, text)
                sent = True
            except Exception as e:
                err = str(e)
        else:
            err = "PARADIZ_TG_BOT_TOKEN / PARADIZ_TG_CHAT_ID не заданы"

    print(json.dumps({"ok": True, "booking_number": booking_number, "saved": str(out), "jsonl": str(jsonl), "doc": str(doc_out), "telegram_sent": sent, "telegram_error": err}, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/sync_prices_to_sqlite.py
content: #!/usr/bin/env python3
import csv
import hashlib
import shutil
from datetime import datetime
from pathlib import Path
import sqlite3

CSV_PATH = Path('/home/openclaw/.openclaw/workspace/skills/paradiz/references/prices.csv')
DB_PATH = Path('/home/openclaw/.openclaw/workspace/skills/paradiz/data/db/testDB.sqlite')

# Маппинг названий из прайса в фактические категории новой БД
ROOM_DB_MAP = {
    'Домик Эконом': 'Домик',
    'Стандарт': 'Стандарт',
    'Двухкомнатный номер': 'Двухкомнатный Номер',
    'Номер с кухней': 'Номер с кухней Стандарт',
    'Номер Большой с кухней': 'Номер с кухней №33',
}


def mk_hash(*parts: str) -> str:
    return hashlib.md5('|'.join(parts).encode('utf-8')).hexdigest()


def load_csv(path: Path):
    with path.open('r', encoding='utf-8-sig', newline='') as f:
        return list(csv.DictReader(f))


def date_jd(cur, s: str):
    return cur.execute('SELECT julianday(?)', (s,)).fetchone()[0]


def ensure_category(cur, name: str, maxlivers: int):
    row = cur.execute('SELECT id FROM hotel_room_categories WHERE name=?', (name,)).fetchone()
    if row:
        return row[0]
    cid = mk_hash('paradiz-cat', name)
    now_jd = cur.execute('SELECT julianday("now")').fetchone()[0]
    h = mk_hash(cid, name)
    cur.execute(
        '''INSERT INTO hotel_room_categories
           (id,name,ename,color,maxlivers,channelCode,additionalChannelCode,additional,description,status,lastupdate,hash)
           VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
        (cid, name, name, 0, maxlivers, '', '', '', 'imported from prices.csv', 0, now_jd, h)
    )
    return cid


def upsert_cost(cur, category_id: str, room_name: str, guests_num: int, cost: float, dfrom: str, dto: str):
    rid = mk_hash('paradiz-cost', room_name, dfrom, dto, str(guests_num))
    exists = cur.execute('SELECT 1 FROM hotel_room_categories_cost WHERE id=?', (rid,)).fetchone()
    df = date_jd(cur, dfrom)
    dt = date_jd(cur, dto)
    if exists:
        cur.execute('''UPDATE hotel_room_categories_cost
                       SET category=?, number=?, cost=?, date_from=?, date_to=?, subtype='', days=127
                       WHERE id=?''',
                    (category_id, guests_num, cost, df, dt, rid))
    else:
        cur.execute('''INSERT INTO hotel_room_categories_cost
                       (id, category, number, cost, date_from, date_to, subtype, days)
                       VALUES (?,?,?,?,?,?,?,?)''',
                    (rid, category_id, guests_num, cost, df, dt, '', 127))


def main():
    if not CSV_PATH.exists() or not DB_PATH.exists():
        raise SystemExit('prices.csv or testDB.sqlite not found')

    # backup db
    backup_dir = DB_PATH.parent / 'backups'
    backup_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime('%Y%m%d-%H%M%S')
    backup_path = backup_dir / f'testDB-{stamp}.sqlite'
    shutil.copy2(DB_PATH, backup_path)

    rows = load_csv(CSV_PATH)

    # extra-person price by period
    extra_by_period = {}
    for r in rows:
        if (r.get('room') or '').strip().lower() == 'доп. человек':
            extra_by_period[(r['date_from'], r['date_to'])] = float(r['price_per_night'])

    conn = sqlite3.connect(str(DB_PATH))
    cur = conn.cursor()

    room_max = {
        'Домик Эконом': 4,
        'Стандарт': 4,
        'Двухкомнатный номер': 6,
        'Номер с кухней': 4,
        'Номер Большой с кухней': 6,
    }

    synced = 0
    used_categories = set()
    for r in rows:
        room = (r.get('room') or '').strip()
        if not room or room == 'Доп. человек':
            continue

        dfrom = r['date_from']
        dto = r['date_to']
        gmin = int(r['guests_min'])
        gmax = int(r['guests_max'])
        base = float(r['price_per_night'])
        extra = float(extra_by_period.get((dfrom, dto), 800.0))

        base_included = 4 if room in ('Двухкомнатный номер', 'Номер Большой с кухней') else 2
        maxl = room_max.get(room, gmax)

        db_room = ROOM_DB_MAP.get(room, room)
        cat_id = ensure_category(cur, db_room, maxl)
        used_categories.add(db_room)

        # очищаем старые/дублирующиеся тарифы в том же диапазоне дат и вместимости
        df = date_jd(cur, dfrom)
        dt = date_jd(cur, dto)
        cur.execute('''DELETE FROM hotel_room_categories_cost
                       WHERE category=? AND date_from=? AND date_to=? AND number BETWEEN ? AND ?''',
                    (cat_id, df, dt, gmin, gmax))

        for n in range(gmin, gmax + 1):
            extra_guests = max(0, n - base_included)
            cost = base + extra_guests * extra
            upsert_cost(cur, cat_id, db_room, n, cost, dfrom, dto)
            synced += 1

    conn.commit()
    conn.close()

    print(f'backup: {backup_path}')
    print(f'synced cost rows: {synced}')
    print('mapped categories:', ', '.join(sorted(used_categories)))


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)