From 547ee30c261ba888b0ff73a2c67fd77555a1212d Mon Sep 17 00:00:00 2001 From: Greem4 Date: Tue, 15 Sep 2026 22:09:14 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(update):=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20extras=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Система убивает процесс, а задачу оставляет в недавних: тап по уведомлению воссоздаёт активити со старым savedInstanceState и новым интентом, а onNewIntent при этом не приходит — экземпляр новый. Разбор extras стоял в ветке else, поэтому такой тап открывал обычный календарь: ни дня из напоминания о занятии, ни «О программе» с готовой кнопкой «Обновить». Повторный разбор того же интента при обычном пересоздании ничего не сдвигает — applyNotificationExtras поднимает версию deep link только при изменении полей. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 +++++++--- app/src/main/java/ru/greemlab/neiro/MainActivity.kt | 13 +++++++++++-- docs/updater/ARCHITECTURE.md | 10 ++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aade4cf..24cec27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ ## [Не выпущено] -Пока пусто. Сюда дописывается изменение, заметное пользователю, — тем же PR, -которым оно вносится, а не по памяти в день выпуска. Заголовки внутри: -«Добавлено», «Изменено», «Исправлено», «Убрано». +### Исправлено + +- **Уведомление снова открывает нужный экран** — если телефон успел выгрузить + приложение из памяти, нажатие на уведомление открывало обычный календарь: + ни дня из напоминания о занятии, ни «О программе» с готовой кнопкой + «Обновить». Из-за этого про новую версию приходилось узнавать заново, + вручную нажимая «Проверить обновления». ## [0.2.5] — 2026-09-01 diff --git a/app/src/main/java/ru/greemlab/neiro/MainActivity.kt b/app/src/main/java/ru/greemlab/neiro/MainActivity.kt index 51fe51f..6cc34ac 100644 --- a/app/src/main/java/ru/greemlab/neiro/MainActivity.kt +++ b/app/src/main/java/ru/greemlab/neiro/MainActivity.kt @@ -71,10 +71,19 @@ class MainActivity : ComponentActivity() { highlightSlotKey = savedInstanceState.getString(STATE_HIGHLIGHT_SLOT_KEY) notificationDeepLinkVersion = savedInstanceState.getInt(STATE_DEEP_LINK_VERSION, 0) openAbout = savedInstanceState.getBoolean(STATE_OPEN_ABOUT, false) - } else { - applyNotificationExtras(intent) } + // Extras разбираются всегда, а не только при пустом savedInstanceState. + // Система убивает процесс, а задачу оставляет в недавних: тап по + // уведомлению воссоздаёт активити со старым состоянием и новым + // intent'ом, и `onNewIntent` при этом не приходит — экземпляр новый. + // Пока разбор стоял в ветке `else`, такой тап открывал обычный + // календарь: ни дня из напоминания о занятии, ни «О программе» с + // готовой кнопкой «Обновить». Повторный разбор того же intent'а при + // обычном пересоздании ничего не сдвигает — [applyNotificationExtras] + // поднимает версию только при изменении полей. + applyNotificationExtras(intent) + setContent { val deepLinkVersion = notificationDeepLinkVersion NeiroApp( diff --git a/docs/updater/ARCHITECTURE.md b/docs/updater/ARCHITECTURE.md index f1e8049..1c54701 100644 --- a/docs/updater/ARCHITECTURE.md +++ b/docs/updater/ARCHITECTURE.md @@ -462,6 +462,15 @@ release.yml ──► POST /v1/release/notify ──► neiro-push ──► FCM Запись стирается тогда же, когда перестаёт быть правдой: проверка вернула «новее нечего», пользователь нажал «Пропустить», обновление установилось. +Вторая половина этого пути — `MainActivity`: уведомление кладёт в интент +`EXTRA_OPEN_ABOUT`, а активити обязана его прочитать **в любом случае**, а не +только когда `savedInstanceState == null`. Система убивает процесс, а задачу +оставляет в недавних, и тап по уведомлению воссоздаёт активити со старым +состоянием и новым интентом; `onNewIntent` при этом не приходит — экземпляр +новый. Пока разбор стоял в ветке `else`, такой тап открывал обычный календарь, +и человек шёл искать кнопку «Проверить обновления» руками. Тем же местом +ломался и переход в день по напоминанию о занятии. + Об одной и той же версии уведомляем один раз (`notified_version_code`). Кнопка «Пропустить» в уведомлении пишет `skipped_version_code` — молчим до следующего релиза. @@ -483,6 +492,7 @@ release.yml ──► POST /v1/release/notify ──► neiro-push ──► FCM | `NeiroApplication.kt` | Одна строка: `UpdateCheckCoordinator.initialize(this)` в существующем `appScope.launch` | | `push/NeiroFirebaseMessagingService.kt` | Ветка `"app_update"` в разборе `action`: вызов `UpdateCheckCoordinator.onUpdatePush` | | `ui/screens/CalendarScreen.kt` | `CalendarOverlay.About` рядом с прочими оверлеями и его ветка отрисовки — тем же способом, что `ProfitSettings` | +| `MainActivity.kt` | `EXTRA_OPEN_ABOUT` и его разбор: интент читается и при восстановлении из `savedInstanceState` | | `ui/settings/AppSettingsScreen.kt` | Секция «О программе» с `SettingsNavigationRow`: версия в подзаголовке, точка при доступном обновлении | | `app/src/main/res/values/strings.xml` | Строки экрана и уведомления | From de40fe5d44a63c1a4a17c45949cb411a1e396ab3 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Tue, 15 Sep 2026 22:09:31 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(calendar):=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D1=8E=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D1=83=D1=81=D0=BB=D1=83=D0=B3=D0=B8=20=D0=B2=20?= =?UTF-8?q?YClients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В журнале время занимают не только записями клиентов: слот подписывают одним именем без карточки клиента («Кац», «Сапожникова») или оставляют себе памятку («ДИАГНОСТИКИ, если нет никого…», «если Филиппов отменится — никого не ставить»). Услуги у такой записи нет, а paid_full YClients ставит в 1 — оплачивать нечего. Приложение читало это как оплату и приписывало полную ставку: 10.09.2026 показывало «Занятий 7, итог 10 500 ₽» там, где YClients начислил 9 000 ₽, а 11.09 — 1 500 ₽ в дне, за который начислено 0. Признак — услуга: деньги в YClients считаются от неё, и запись без услуги не приносит там ни рубля. Без услуги paid_full не читается, строка дня получает префикс NOT_COUNTED_PREFIX, и такая запись не входит ни в «Занятий», ни в «Проведено», ни в «Заработано», ни в «Ожидается», ни в «Потеряно на отменах». Статус при этом остаётся настоящим: «не пришёл» так и виден отменой. Заодно чиню потерю подписей, которые YClients кладёт в комментарий, а не в имя («Пирогов», «Сапожникова»): имя выходило пустым, и запись отсеивалась в collapseDuplicateRecords — в журнале слот занят, в приложении пусто. Проверено на живых данных: сентябрь — 56 занятий и 84 000 ₽ и в приложении, и в расчёте YClients. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 ++ TODO.md | 54 +++++ .../PastSessionsArchiveCollector.kt | 5 +- .../neiro/notifications/UpcomingSession.kt | 2 + .../neiro/push/PushEventCalendarApplier.kt | 5 + .../neiro/sync/YClientsCalendarSync.kt | 114 ++++++++-- .../ui/calendar/CalendarStatsCalculator.kt | 8 +- .../neiro/ui/calendar/DaySummaryStats.kt | 5 + .../neiro/ui/calendar/SessionParser.kt | 47 +++- .../neiro/ui/components/DayDetailsDialog.kt | 9 + .../neiro/sync/YClientsCalendarSyncTest.kt | 214 +++++++++++++++++- docs/yclients-integration.md | 39 ++++ version.properties | 2 + 13 files changed, 479 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24cec27..085b622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ ### Исправлено +- **Занятое время больше не считается занятием** — когда в YClients занимают + слот одним именем («Кац», «Сапожникова») или оставляют себе памятку + («ДИАГНОСТИКИ, если нет никого…», «если Филиппов отменится — никого не + ставить»), приложение считало это занятием и приписывало полную ставку: + 10 сентября выходило «Занятий 7, итог 10 500 ₽» там, где YClients начислил + 9 000 ₽. Такие записи видны в дне, как и раньше, но больше не входят ни в + одну цифру — ни в «Занятий», ни в «Проведено», ни в «Заработано», ни в + «Ожидается», ни в «Потеряно на отменах». За сентябрь это четыре приписанных + занятия и 6 000 ₽. +- **Занятое время видно целиком** — слоты, подписанные в комментарии записи + («Пирогов», «Сапожникова»), в приложении не показывались вовсе: в журнале + время занято, а в дне на этом месте была пустота. Теперь такая подпись видна + на своём месте — и, как и остальное занятое время, ни в одну цифру не + считается. - **Уведомление снова открывает нужный экран** — если телефон успел выгрузить приложение из памяти, нажатие на уведомление открывало обычный календарь: ни дня из напоминания о занятии, ни «О программе» с готовой кнопкой diff --git a/TODO.md b/TODO.md index e0a0de0..9766564 100644 --- a/TODO.md +++ b/TODO.md @@ -6,6 +6,11 @@ Ближайшая работа — [`docs/next/`](docs/next/README.md): скриншоты и крайние случаи обновления. Здесь — карта целиком. +> ⚡ **Горячее, не забыть:** [Интерфейс дня — известные проблемы](#интерфейс-дня--известные-проблемы) +> — 4 бага с боевого телефона после 0.2.5 (архив завален расхождением +> «Пришёл ↔ Оплачено», плашки с двумя людьми не нажимаются, баннер +> просвечивает записью, кривые края в диалоге отличий). + ## Сделано Крупные куски, закрытые целиком. Развёрнуто — по ссылкам, здесь только чтобы @@ -129,6 +134,55 @@ - [ ] Список того, что осталось непокрытым, — в `docs/debt.md`, чтобы дыра была видна, а не забыта +### Интерфейс дня — известные проблемы + +Замечено 01.09.2026 на боевом телефоне после выхода 0.2.5. Все четыре — про +экран дня и диалог расхождений с архивом. + +- [ ] **Архив завален расхождением «Пришёл ↔ Оплачено».** Архив импортирован + до 0.2.5, отдельного «оплачено» в нём нет: YClients теперь отдаёт + `Оплачено`, архив помнит `Пришёл`, и `ArchiveSyncCompare.differs` + (`app/src/main/java/ru/greemlab/neiro/ui/calendar/ArchiveSyncCompare.kt`) + метит почти каждый архивный день как несовпадающий. Диалог «Обновить + архив?» в таком виде затрёт все ручные правки. Развилка, нужен выбор + владельца: + 1. Разовая миграция при загрузке архива: где в архиве `Пришёл`, а + близнец в YClients `Оплачено` и остальные поля совпадают — поднять + статус в архиве до `Оплачено`. Единый источник, но необратимо — + нужен снимок архива и откат. + 2. Не считать `Пришёл` и `Оплачено` расхождением для дней, записанных + до 0.2.5 (нужна метка версии на архивном дне). Дешевле, но два + представления одного факта живут рядом. + 3. Сравнивать только «проведён / не проведён», оплату из архивного + diff убрать вовсе. Дёшево, но настоящее расхождение по оплате в + архиве больше не увидеть. + Пересекается с бэклогом «Мастер слияния при конфликте архив ↔ YClients». +- [ ] **Плашки с двумя людьми не нажимаются.** Замена и «интенсив накрыл + занятие» (`ExpandableReplacementSlot`, `ExpandableIntensiveCoverSlot` в + `app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/ScheduleSlotItem.kt`) + получают только `onContentClick`, а `onStudentStatusChange` из + `DayScheduleTimeline` в них не проброшен вовсе — быстрый тап «пришёл / + оплатил» по «!» для таких слотов мёртв даже в архиве. Плюс + горизонтальный `pointerInput` на всей плашке и `indication = null` — + нажатие без отклика, ощущается как неинтерактивное. Пробросить статус в + свёрнутую карточку и в раскрытые дочерние, дать отклик на тап. +- [ ] **Предупреждение о расхождении просвечивает первой записью.** Шапка + диалога дня (`DayDetailsContent` в + `app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt`) + залита только `panelScrim` (плотно сверху, прозрачно снизу), а лента + `DayScheduleTimeline` край не гасит — `fadingEdges` на её прокрутку не + наложен. Слот, уехавший под шапку, виден сквозь баннер и иконку «!». + Наложить `fadingEdges(top = headerHeight, bottom = footerHeight)` на + прокрутку ленты — как уже сделано в диалогах статистики дня. +- [ ] **Кривые края и нет полосы прокрутки в «Отличия архива от YClients».** + `ArchiveMismatchDetailsDialog` (тот же `DayDetailsDialog.kt`) гасит верх + и низ безусловно (`fadingEdges(top, bottom)`), поэтому первая и + последняя строки полупрозрачны даже когда список докручен до упора — + читается как обрезанный текст. Взять `fadingScrollEdges(scrollState)` + (уже есть в `app/src/main/java/ru/greemlab/neiro/ui/util/fadingEdges.kt`) + и добавить тонкую полосу прокрутки, чтобы было видно, что список длинный + и насколько прокручен. + ### Бэклог - [ ] Виджет «Расписание на сегодня» diff --git a/app/src/main/java/ru/greemlab/neiro/notifications/PastSessionsArchiveCollector.kt b/app/src/main/java/ru/greemlab/neiro/notifications/PastSessionsArchiveCollector.kt index c10fd7c..1f045b9 100644 --- a/app/src/main/java/ru/greemlab/neiro/notifications/PastSessionsArchiveCollector.kt +++ b/app/src/main/java/ru/greemlab/neiro/notifications/PastSessionsArchiveCollector.kt @@ -53,6 +53,9 @@ object PastSessionsArchiveCollector { fun sessionCount(entries: List): Int = entries.count { raw -> val session = SessionParser.parse(raw) - !session.isEffectivelyDeleted() && session.status != AttendanceStatus.CANCELLED + // День, в котором осталось только занятое время, не архивируем. + !session.isNotCounted && + !session.isEffectivelyDeleted() && + session.status != AttendanceStatus.CANCELLED } } diff --git a/app/src/main/java/ru/greemlab/neiro/notifications/UpcomingSession.kt b/app/src/main/java/ru/greemlab/neiro/notifications/UpcomingSession.kt index 6fa2ec6..9b14027 100644 --- a/app/src/main/java/ru/greemlab/neiro/notifications/UpcomingSession.kt +++ b/app/src/main/java/ru/greemlab/neiro/notifications/UpcomingSession.kt @@ -61,6 +61,8 @@ object UpcomingSessionsCollector { for (raw in entries) { val session = SessionParser.parse(raw) + // Запись без услуги — не занятие: напоминать не о чем. + if (session.isNotCounted) continue // Если сессия удалена целиком (или все дети в интенсиве), пропускаем. if (session.isEffectivelyDeleted()) continue if (session.status == AttendanceStatus.CANCELLED) continue diff --git a/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt b/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt index f93da10..0665eeb 100644 --- a/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt +++ b/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt @@ -91,6 +91,11 @@ object PushEventCalendarApplier { // Интенсивы правит только полный синк — слияние по детям нетривиально (§5.6). if (SessionParser.isIntensive(raw)) return dayData + // Запись без услуги занятием не становится: сервер про услуги ничего + // не знает, и его событие вернуло бы её в счётчики. Признак ставит + // только разбор записи при синхронизации. + if (SessionParser.parse(raw).isNotCounted) return dayData + // Событие «пришёл» не отменяет уже известную оплату: про `paid_full` // сервер не знает, и понижение статуса вернуло бы деньги дня в // ожидаемые до следующего полного синка (01.09.2026). diff --git a/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt b/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt index 3b08d89..976bd16 100644 --- a/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt +++ b/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt @@ -784,32 +784,16 @@ class YClientsCalendarSync( val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") LocalDate.parse(dateString, formatter) } - } catch (e: Exception) { + } catch (_: Exception) { try { LocalDate.parse(dateString.take(10)) - } catch (e2: Exception) { + } catch (_: Exception) { null } } } - private fun extractClientName(record: RecordData): String { - val client = record.client ?: return "" - - return when { - !client.displayName.isNullOrBlank() -> client.displayName - !client.name.isNullOrBlank() -> { - buildString { - append(client.name) - if (!client.surname.isNullOrBlank()) { - append(" ") - append(client.surname) - } - } - } - else -> "" - }.trim() - } + private fun extractClientName(record: RecordData): String = recordDisplayName(record) private fun createEntryFromRecord(record: RecordData, userProfile: UserProfile): String { val clientName = extractClientName(record) @@ -832,7 +816,7 @@ class YClientsCalendarSync( val time = formatRecordTime(record) val phone = record.client?.phone.orEmpty() - val comment = record.comment.orEmpty() + val comment = recordComment(record) return SessionFormat.serializeStudentExtended( name = clientName, @@ -840,6 +824,7 @@ class YClientsCalendarSync( time = time, phone = phone, comment = comment, + notCounted = !hasPayableService(record), ) } @@ -879,7 +864,7 @@ class YClientsCalendarSync( val time = formatRecordTime(record) val phone = record.client?.phone.orEmpty() - val comment = record.comment.orEmpty() + val comment = recordComment(record) return SessionFormat.serializeStudentExtended( name = session.name, @@ -887,11 +872,12 @@ class YClientsCalendarSync( time = time, phone = phone, comment = comment, + notCounted = !hasPayableService(record), ) } private fun mapAttendanceStatus(record: RecordData): AttendanceStatus = - AttendanceStatus.resolveFromRecord(record.attendance, record.visitAttendance, record.paidFull) + attendanceStatusOf(record) private fun formatRecordTime(record: RecordData, intensive: Boolean = false): String { val datetime = record.datetime ?: return "" @@ -915,7 +901,7 @@ class YClientsCalendarSync( val endTime = startTime.plusMinutes(durationMinutes.toLong()) "${startTime.format(TIME_FORMAT)}-${endTime.format(TIME_FORMAT)}" - } catch (e: Exception) { + } catch (_: Exception) { "" } } @@ -923,6 +909,88 @@ class YClientsCalendarSync( companion object { private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") + /** + * Имя записи для экрана дня. + * + * Обычно это клиент. Но время в журнале занимают и без карточки + * клиента — тогда подпись слота YClients кладёт либо в имя + * (`client_fictive_name`: «Кац», «ДИАГНОСТИКИ…»), либо в комментарий + * записи («Пирогов», «Сапожникова», «Савостьянов», «Чудаев»). + * + * Второй случай до 15.09.2026 терялся целиком: имя выходило пустым, а + * запись с пустым именем отсеивалась в [collapseDuplicateRecords] и в + * день не попадала вовсе — в журнале слот занят, в приложении пусто. + * + * Берётся первая непустая строка комментария: YClients хранит его с + * переносом на конце («Сапожникова\n»). + */ + fun recordDisplayName(record: RecordData): String { + val client = record.client + val fromClient = when { + client == null -> "" + !client.displayName.isNullOrBlank() -> client.displayName + !client.name.isNullOrBlank() -> buildString { + append(client.name) + if (!client.surname.isNullOrBlank()) { + append(" ") + append(client.surname) + } + } + else -> "" + }.trim() + if (fromClient.isNotBlank()) return fromClient + + return record.comment.orEmpty() + .lineSequence() + .firstOrNull { it.isNotBlank() } + ?.trim() + .orEmpty() + } + + /** + * Комментарий записи. Пустой, если он уже ушёл в имя + * ([recordDisplayName]) — иначе одна и та же подпись показывалась бы в + * карточке дважды. + */ + fun recordComment(record: RecordData): String { + val client = record.client + val hasClientName = client != null && + (!client.displayName.isNullOrBlank() || !client.name.isNullOrBlank()) + return if (hasClientName) record.comment.orEmpty() else "" + } + + /** + * Статус записи YClients: оплату признаём только там, где есть услуга. + * + * Без услуги `paid_full` ничего не доказывает — YClients ставит + * «оплачено полностью» всему, где платить нечего. Сам статус при этом + * настоящий: «не пришёл» так и остаётся «не пришёл». + */ + fun attendanceStatusOf(record: RecordData): AttendanceStatus = + AttendanceStatus.resolveFromRecord( + attendance = record.attendance, + visitAttendance = record.visitAttendance, + paidFull = if (hasPayableService(record)) record.paidFull else null, + ) + + /** + * Есть ли у записи услуга — то, за что в YClients берут деньги. + * + * Нет услуги — нет и занятия: так в журнале занимают время. Слот + * подписывают одним именем, без карточки клиента («Кац», + * «Сапожникова»), или оставляют себе памятку — «ДИАГНОСТИКИ (если нет + * никого…)», «если Филиппов отменится, никого не ставить». YClients за + * такие записи не начисляет ничего, и приложение обязано считать так же: + * запись видна в дне, но ни в один счётчик не входит + * ([Session.isNotCounted]). + * + * Пока этой проверки не было, запись без услуги приезжала оплаченным + * занятием на полную ставку: 10.09.2026 приложение показывало + * «Занятий 7, итог 10 500 ₽» там, где YClients начислил 9 000 ₽. + */ + fun hasPayableService(record: RecordData): Boolean = + !record.services.isNullOrEmpty() + /** * Можно ли доверять ответу API и удалять локальные записи без пары. * diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt index 9b27ace..4ebc64c 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt @@ -90,6 +90,10 @@ internal fun computeMonthStats( for (raw in sessions) { val session = SessionParser.parse(raw) + // Запись без услуги занятием не является — ни в заработок, ни в + // ожидаемое, ни в счётчик проведённых (Session.isNotCounted). + if (session.isNotCounted) continue + if (session.isEffectivelyDeleted()) continue when (session) { @@ -192,12 +196,12 @@ fun getMonthName(month: YearMonth): String = .replaceFirstChar { it.uppercase(RU_LOCALE) } /** Короткое название месяца для сетки выбора (например, «Янв»). */ -fun getShortMonthName(month: java.time.Month): String = +fun getShortMonthName(month: Month): String = month.getDisplayName(TextStyle.SHORT_STANDALONE, RU_LOCALE) .replaceFirstChar { it.uppercase(RU_LOCALE) } /** Сокращения месяцев для графиков: «Янв», «Фев», … без точки. */ -fun getChartMonthAbbreviation(month: java.time.Month): String = +fun getChartMonthAbbreviation(month: Month): String = CHART_MONTH_ABBREVIATIONS[month.ordinal] private val CHART_MONTH_ABBREVIATIONS = listOf( diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt index 428d645..2aa0c2f 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt @@ -141,6 +141,11 @@ internal fun computeDayStats( val intensiveChildrenByTime = buildIntensiveChildrenByTime(parsed) for (session in parsed) { + // Запись без услуги в YClients — не занятие: ни в «Заработано», ни в + // «Ожидается», ни в «Потеряно на отменах». Проверка стоит до + // отменённых: «Кац» отмечен «не пришёл», но терять там нечего. + if (session.isNotCounted) continue + if (session.isEffectivelyDeleted()) { if (session is Session.Intensive) hasIntensive = true val price = when (session) { diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt index 3e1b982..5a234be 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt @@ -128,9 +128,21 @@ sealed interface Session { name.startsWith("–") || name.startsWith("−") } + /** + * Запись, которая в счётчики не входит: в YClients у неё нет услуги, а + * значит и денег за неё там не начисляют. + * + * Так выглядит занятое время — слот, подписанный одним именем («Кац»), и + * памятка в расписании («ДИАГНОСТИКИ, если нет никого…»). Показываем её как + * обычную запись, со статусом из YClients, но ни в «Занятий», ни в + * «Проведено», ни в «Заработано», ни в «Ожидается», ни в «Потеряно на + * отменах» она не попадает. + */ + val isNotCounted: Boolean get() = (this as? Student)?.notCounted == true + /** Учитывается в заработке: только оплаченное, без отменённых записей. */ fun countsTowardEarnings(): Boolean = - !isEffectivelyDeleted() && status.countsTowardEarnings + !isNotCounted && !isEffectivelyDeleted() && status.countsTowardEarnings /** * Занятие состоялось: клиент пришёл — заплатил он уже или ещё нет. @@ -138,7 +150,8 @@ sealed interface Session { * Отдельно от [countsTowardEarnings]: «проведено 4 из 8» — про работу, а * деньги идут за оплаченным (01.09.2026). */ - fun countsAsAttended(): Boolean = !isEffectivelyDeleted() && status.hasArrived + fun countsAsAttended(): Boolean = + !isNotCounted && !isEffectivelyDeleted() && status.hasArrived @Immutable data class Student( @@ -148,6 +161,8 @@ sealed interface Session { val phone: String = "", // Телефон клиента val comment: String = "", // Комментарий к записи override val status: AttendanceStatus = AttendanceStatus.fromBoolean(attended), + /** Запись без услуги в YClients — показывается, но не считается. */ + val notCounted: Boolean = false, ) : Session @Immutable @@ -230,6 +245,12 @@ object SessionParser { raw.startsWith(SessionFormat.DIAGNOSTICS_PREFIX) -> parseExtra(raw, SessionFormat.DIAGNOSTICS_PREFIX.length, intensive = false) + // Занятое время без услуги: дальше обычная строка ученика, отличается + // только тем, что в счётчики не идёт. + raw.startsWith(SessionFormat.NOT_COUNTED_PREFIX) -> + parseStudent(raw.substring(SessionFormat.NOT_COUNTED_PREFIX.length)) + .copy(notCounted = true) + else -> parseStudent(raw) } @@ -272,6 +293,7 @@ object SessionParser { */ fun countsAsCalendarLesson(raw: String): Boolean { val session = parse(raw) + if (session.isNotCounted) return false if (session.isEffectivelyDeleted()) return false return session is Session.Student || session is Session.Diagnostics } @@ -283,7 +305,8 @@ object SessionParser { return parsed.count { session -> when (session) { is Session.Student -> - !session.isEffectivelyDeleted() && + !session.isNotCounted && + !session.isEffectivelyDeleted() && !isStudentCoveredByIntensive(session, intensiveChildrenByTime) is Session.Diagnostics -> !session.isEffectivelyDeleted() else -> false @@ -305,7 +328,8 @@ object SessionParser { var pending = 0 parsed.forEach { session -> val isLesson = when (session) { - is Session.Student -> !session.isEffectivelyDeleted() && + is Session.Student -> !session.isNotCounted && + !session.isEffectivelyDeleted() && !isStudentCoveredByIntensive(session, intensiveChildrenByTime) is Session.Diagnostics -> !session.isEffectivelyDeleted() else -> false @@ -336,6 +360,7 @@ object SessionParser { is Session.Student -> SessionFormat.serializeStudentExtended( name = session.name, status = status, + notCounted = session.notCounted, time = session.time, phone = session.phone, comment = session.comment, @@ -522,6 +547,14 @@ object SessionFormat { /** Старый формат (для обратной совместимости). */ fun serializeStudent(name: String, attended: Boolean): String = "$name|$attended" + /** + * Занятое время без услуги: та же строка ученика, но с этим префиксом. + * Префиксом, а не кодом статуса, — чтобы статус остался настоящим + * («не пришёл» у «Каца» виден как отмена), а признак «не считать» ехал + * отдельно и не путался со статусами старых записей. + */ + const val NOT_COUNTED_PREFIX = "__NOSVC__:" + /** Расширенный формат: `name|statusCode|time|phone|comment`. */ fun serializeStudentExtended( name: String, @@ -529,7 +562,11 @@ object SessionFormat { time: String = "", phone: String = "", comment: String = "", - ): String = "$name|${status.code}|$time|$phone|$comment" + notCounted: Boolean = false, + ): String { + val base = "$name|${status.code}|$time|$phone|$comment" + return if (notCounted) "$NOT_COUNTED_PREFIX$base" else base + } fun serializeIntensive(price: String, name: String, attended: Boolean, time: String = ""): String = serializeIntensive(price, name, AttendanceStatus.fromBoolean(attended), time) diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt index ae1af6d..40c796b 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt @@ -118,6 +118,8 @@ private data class ScheduleEntry( val extraAmount: Double = 0.0, val intensiveChildren: List = emptyList(), val coveredEntries: List = emptyList(), + /** Запись без услуги в YClients: показываем, но ни во что не считаем. */ + val notCounted: Boolean = false, val sourceIndex: Int, ) @@ -867,6 +869,7 @@ private fun parseEntries( time = normalizeSessionTime(session.time), comment = session.comment, status = if (isDeleted) AttendanceStatus.CANCELLED else session.status, + notCounted = session.notCounted, sourceIndex = index, ) coveredByIntensiveTime @@ -880,6 +883,7 @@ private fun parseEntries( time = normalizeSessionTime(session.time), comment = session.comment, status = if (isDeleted) AttendanceStatus.CANCELLED else session.status, + notCounted = session.notCounted, sourceIndex = index, ), ) @@ -945,6 +949,11 @@ private fun calculateStats( var money = 0.0 for (entry in entries) { + // Занятое время без услуги: в шапке дня «Занятий» и «Итог» его нет — + // в YClients за него не начисляют (10.09.2026: было 7 и 10 500 ₽, + // стало 6 и 9 000 ₽, ровно как в начислении). + if (entry.notCounted) continue + val isIntensive = entry.isExtra && entry.extraType == "Интенсив" when (entry.status) { diff --git a/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt b/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt index ebc99bc..eff6bd6 100644 --- a/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt @@ -4,8 +4,12 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import ru.greemlab.neiro.domain.models.EarningsContext +import ru.greemlab.neiro.ui.calendar.AttendanceStatus import ru.greemlab.neiro.ui.calendar.Session import ru.greemlab.neiro.ui.calendar.SessionFormat +import ru.greemlab.neiro.ui.calendar.SessionParser +import ru.greemlab.neiro.ui.calendar.computeDayStats import java.time.LocalDate import java.time.YearMonth @@ -111,7 +115,7 @@ class YClientsCalendarSyncTest { val intensive = SessionFormat.serializeIntensive( price = "5000", name = "Летний лагерь", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.EXPECTED, + status = AttendanceStatus.EXPECTED, time = "10:00-12:00", ) assertTrue( @@ -131,7 +135,7 @@ class YClientsCalendarSyncTest { val intensive = SessionFormat.serializeIntensive( price = "5000", name = "Интенсив", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.EXPECTED, + status = AttendanceStatus.EXPECTED, time = "", ) val dayData = mapOf( @@ -248,14 +252,14 @@ class YClientsCalendarSyncTest { val manual = SessionFormat.serializeIntensive( price = "5600", name = "Интенсив", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + status = AttendanceStatus.ARRIVED, time = "19:00-19:50", amountFixed = true, ) val apiSlot = SessionFormat.serializeIntensive( price = "2800", name = "Интенсив", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.EXPECTED, + status = AttendanceStatus.EXPECTED, time = "18:00-18:50", ) val retained = YClientsCalendarSync.unmatchedLocalIntensives( @@ -269,13 +273,13 @@ class YClientsCalendarSyncTest { fun `full API answer drops local entries without a pair`() { val student = SessionFormat.serializeStudentExtended( name = "Иванов", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + status = AttendanceStatus.ARRIVED, time = "10:00-10:50", ) val intensive = SessionFormat.serializeIntensive( price = "5600", name = "Интенсив", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + status = AttendanceStatus.ARRIVED, time = "19:00-19:50", amountFixed = true, ) @@ -293,12 +297,12 @@ class YClientsCalendarSyncTest { val untouched = listOf( SessionFormat.serializeStudentExtended( name = "Иванов", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + status = AttendanceStatus.ARRIVED, time = "10:00-10:50", ), SessionFormat.serializeStudentExtended( name = "Петров", - status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.EXPECTED, + status = AttendanceStatus.EXPECTED, time = "11:00-11:50", ), ) @@ -309,6 +313,200 @@ class YClientsCalendarSyncTest { assertEquals(untouched, survived) } + // ─── Занятое время без услуги ─────────────────────────────────────────── + // + // В журнале так занимают время: слот подписывают одним именем («Кац», + // «Сапожникова») или оставляют памятку («ДИАГНОСТИКИ, если нет никого…»). + // Услуги у такой записи нет, а `paid_full` YClients ставит в 1 — платить + // нечего. Пока приложение читало это как оплату, 10.09.2026 выходило + // «Занятий 7, итог 10 500 ₽» там, где YClients начислил 9 000 ₽. + + @Test + fun `услуга — признак того, что оплате есть чем быть`() { + assertFalse(YClientsCalendarSync.hasPayableService(fakeRecord().copy(services = null))) + assertFalse(YClientsCalendarSync.hasPayableService(fakeRecord().copy(services = emptyList()))) + assertTrue( + YClientsCalendarSync.hasPayableService( + fakeRecord().copy(services = listOf(fakeService())), + ), + ) + } + + @Test + fun `без услуги оплата не читается, статус остаётся настоящим`() { + val waiting = fakeRecord().copy(services = emptyList(), attendance = 0, paidFull = 1) + val absent = fakeRecord().copy(services = emptyList(), attendance = -1, paidFull = 1) + assertEquals(AttendanceStatus.EXPECTED, YClientsCalendarSync.attendanceStatusOf(waiting)) + assertEquals(AttendanceStatus.CANCELLED, YClientsCalendarSync.attendanceStatusOf(absent)) + } + + @Test + fun `настоящее занятие читается как раньше`() { + val paid = fakeRecord().copy(services = listOf(fakeService()), attendance = 1, paidFull = 1) + val arrived = fakeRecord().copy(services = listOf(fakeService()), attendance = 1, paidFull = 0) + assertEquals(AttendanceStatus.PAID, YClientsCalendarSync.attendanceStatusOf(paid)) + assertEquals(AttendanceStatus.ARRIVED, YClientsCalendarSync.attendanceStatusOf(arrived)) + } + + @Test + fun `признак не-занятия переживает сериализацию и смену статуса`() { + val raw = SessionFormat.serializeStudentExtended( + name = "Кац", + status = AttendanceStatus.CANCELLED, + time = "17:00-17:50", + notCounted = true, + ) + val session = SessionParser.parse(raw) as Session.Student + assertTrue(session.notCounted) + assertTrue(session.isNotCounted) + // Статус настоящий: «не пришёл» так и остаётся «не пришёл». + assertEquals(AttendanceStatus.CANCELLED, session.status) + + val changed = SessionParser.parse(SessionParser.withStatus(raw, AttendanceStatus.PAID)) + assertTrue(changed.isNotCounted) + assertFalse(changed.countsTowardEarnings()) + } + + @Test + fun `день 10 сентября 2026 — шесть занятий и 9000, как в YClients`() { + // Живой день: шесть оплаченных занятий, отменённый «Моторнов» с услугой, + // «Кац» — слот, занятый одним именем, и памятка «ДИАГНОСТИКИ» в 18:00. + // YClients начислил 9 000 ₽; приложение показывало 10 500 ₽. + val paid = (1..6).map { index -> + fakeRecord().copy(id = index.toLong(), services = listOf(fakeService()), attendance = 1, paidFull = 1) + } + val motornov = fakeRecord().copy(id = 7L, services = listOf(fakeService()), attendance = -1, paidFull = 0) + val kats = fakeRecord().copy(id = 8L, services = emptyList(), attendance = -1, paidFull = 1) + val note = fakeRecord().copy(id = 9L, services = emptyList(), attendance = 0, paidFull = 1) + + val stats = dayStats(paid + motornov + kats + note) + + assertEquals(6, stats.totalLessons) // «Занятий 6», а не 7 + assertEquals(6, stats.attendedLessons) + assertEquals(9000.0, stats.earned, 0.0) // столько же, сколько в YClients + assertEquals(0.0, stats.expected, 0.0) + // В «Потеряно на отменах» — только настоящая отмена с услугой. + assertEquals(1500.0, stats.lost, 0.0) + } + + @Test + fun `день 11 сентября 2026 — все цифры по нулям`() { + // Шесть записей «не пришёл» с услугой, «Пирогов» без услуги и памятка + // «Если Филиппов отменится…». YClients начислил 0. + val cancelled = (1..6).map { index -> + fakeRecord().copy(id = index.toLong(), services = listOf(fakeService()), attendance = -1, paidFull = 0) + } + val pirogov = fakeRecord().copy(id = 7L, services = emptyList(), attendance = -1, paidFull = 1) + val note = fakeRecord().copy(id = 8L, services = emptyList(), attendance = 0, paidFull = 1) + + val stats = dayStats(cancelled + pirogov + note) + + assertEquals(0, stats.totalLessons) + assertEquals(0, stats.attendedLessons) + assertEquals(0.0, stats.earned, 0.0) + assertEquals(0.0, stats.expected, 0.0) + assertEquals(6 * 1500.0, stats.lost, 0.0) + } + + // ─── Имя записи: клиент, а если его нет — комментарий ──────────────────── + + @Test + fun `имя берётся у клиента, комментарий остаётся комментарием`() { + val record = fakeRecord().copy( + client = fakeClient("Кожин Роман, 1,8 года"), + comment = "принесут договор", + ) + assertEquals("Кожин Роман, 1,8 года", YClientsCalendarSync.recordDisplayName(record)) + assertEquals("принесут договор", YClientsCalendarSync.recordComment(record)) + } + + @Test + fun `без клиента имя берётся из комментария`() { + // «Пирогов», «Сапожникова», «Савостьянов», «Чудаев» — слоты, занятые + // подписью в комментарии. Раньше имя выходило пустым, и запись + // отсеивалась до попадания в день: в журнале слот занят, в приложении + // пусто. + val record = fakeRecord().copy(client = null, comment = "Сапожникова\n") + assertEquals("Сапожникова", YClientsCalendarSync.recordDisplayName(record)) + // Второй раз та же подпись в комментарий не уходит. + assertEquals("", YClientsCalendarSync.recordComment(record)) + } + + @Test + fun `без клиента берётся первая непустая строка комментария`() { + val record = fakeRecord().copy(client = null, comment = "\n Пирогов \nвторая строка") + assertEquals("Пирогов", YClientsCalendarSync.recordDisplayName(record)) + } + + @Test + fun `без клиента и без комментария имени нет`() { + val record = fakeRecord().copy(client = null, comment = " ") + assertEquals("", YClientsCalendarSync.recordDisplayName(record)) + } + + @Test + fun `подпись из комментария видна, но в счётчики не идёт`() { + val pirogov = fakeRecord().copy( + client = null, + comment = "Пирогов", + services = emptyList(), + attendance = -1, + paidFull = 1, + ) + val raw = SessionFormat.serializeStudentExtended( + name = YClientsCalendarSync.recordDisplayName(pirogov), + status = YClientsCalendarSync.attendanceStatusOf(pirogov), + time = "16:00-17:00", + comment = YClientsCalendarSync.recordComment(pirogov), + notCounted = !YClientsCalendarSync.hasPayableService(pirogov), + ) + val session = SessionParser.parse(raw) as Session.Student + + assertEquals("Пирогов", session.name) // в дне видна + assertEquals(AttendanceStatus.CANCELLED, session.status) + assertTrue(session.isNotCounted) // но ни во что не считается + assertFalse(session.countsTowardEarnings()) + assertFalse(session.countsAsAttended()) + assertFalse(SessionParser.countsAsCalendarLesson(raw)) + } + + private fun fakeClient(name: String) = ru.greemlab.neiro.data.network.ClientData( + id = 1L, + name = name, + surname = null, + patronymic = null, + displayName = null, + phone = null, + email = null, + successVisitsCount = null, + failVisitsCount = null, + ) + + /** День из записей API — теми же правилами, что и синхронизация. */ + private fun dayStats(records: List) = + computeDayStats( + records.mapIndexed { index, record -> + SessionFormat.serializeStudentExtended( + name = "Запись $index", + status = YClientsCalendarSync.attendanceStatusOf(record), + time = "1$index:00-1$index:50", + notCounted = !YClientsCalendarSync.hasPayableService(record), + ) + }, + rates = EarningsContext(pricePerSession = 1500.0), + ) + + private fun fakeService() = ru.greemlab.neiro.data.network.ServiceData( + id = 1L, + title = "Нейрокоррекция", + cost = 1500.0, + costToPay = 1500.0, + firstCost = 1500.0, + costPerUnit = 1500.0, + discount = 0.0, + amount = 1, + ) + private fun fakeRecord() = ru.greemlab.neiro.data.network.RecordData( id = 1L, companyId = 1, diff --git a/docs/yclients-integration.md b/docs/yclients-integration.md index db70b4d..ab47569 100644 --- a/docs/yclients-integration.md +++ b/docs/yclients-integration.md @@ -75,6 +75,45 @@ NEIRO_PUSH_API_KEY= `company_id` и `staff_id` подставляет сервер — в запросах их нет. Полная спецификация — [API.md](neiro-push/API.md). +## Не всякая запись — занятие + +В журнале YClients время занимают не только записями клиентов. Слот +подписывают одним именем, без карточки клиента и без услуги («Кац», +«Сапожникова», «Пирогов»), или оставляют себе памятку — «ДИАГНОСТИКИ (если нет +никого…)», «если Филиппов отменится, никого не ставить». + +Признак у всех один: **нет услуги**. Денег YClients за такую запись не +начисляет, и приложение считает так же: + +- `YClientsCalendarSync.hasPayableService` — есть ли услуга; +- без услуги `paid_full` не читается (YClients ставит там `1`: платить + нечего), статус берётся из посещаемости и остаётся настоящим — «не пришёл» + так и виден как отмена; +- строка дня получает префикс `SessionFormat.NOT_COUNTED_PREFIX`, а разобранная + запись — признак `Session.isNotCounted`. + +Такая запись видна на экране дня, как и в журнале, но не входит ни в «Занятий», +ни в «Проведено», ни в «Заработано», ни в «Ожидается», ни в «Потеряно на +отменах». Напоминаний и дайджестов по ней нет, пуш-события её не трогают, и +день, в котором осталось только занятое время, не предлагается в архив. + +Признак едет префиксом, а не кодом статуса, намеренно: статус обязан остаться +настоящим, иначе «Кац» с отметкой «не пришёл» перестал бы выглядеть отменой. + +Подпись такого слота YClients кладёт в одно из двух мест: в имя +(`client_fictive_name` — «Кац», «ДИАГНОСТИКИ…») или в комментарий записи +(«Пирогов», «Сапожникова»). `YClientsCalendarSync.recordDisplayName` берёт +второе, когда клиента нет, — иначе имя выходило пустым и запись отсеивалась в +`collapseDuplicateRecords`, то есть в журнале слот занят, а в приложении пусто. +В комментарий та же подпись второй раз не уходит +(`YClientsCalendarSync.recordComment`). + +**Сентябрь 2026 для примера.** Из 140 записей без услуги — 11. Семь отмечены +«не пришёл» (Савостьянов, Чудаев ×2, Рассказов, Кац, Пирогов, Сапожникова), +четыре не отмечены никак и приписывали по 1500 ₽ (три «ДИАГНОСТИКИ» и «если +Филиппов отменится…»). Ровно на эти четыре приложение и расходилось с YClients: +60 занятий против 56. + ## Лимиты | Что | Лимит | diff --git a/version.properties b/version.properties index 32d8c5a..5c09f0a 100644 --- a/version.properties +++ b/version.properties @@ -2,3 +2,5 @@ # versionCode считается из этих трёх чисел (см. app/build.gradle.kts), # руками его не задают. VERSION=0.2.5 + +