diff --git a/app/src/main/java/ru/greemlab/neiro/data/network/SalaryModels.kt b/app/src/main/java/ru/greemlab/neiro/data/network/SalaryModels.kt index bf17d039..f63c74e1 100644 --- a/app/src/main/java/ru/greemlab/neiro/data/network/SalaryModels.kt +++ b/app/src/main/java/ru/greemlab/neiro/data/network/SalaryModels.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName +import java.time.LocalDate /** * Модели зарплатных ответов YClients (API-HOWTO 5.1 и 5.2). @@ -146,6 +147,11 @@ data class SalaryCalculationSummary( val status: String?, ) { val isConfirmed: Boolean get() = status.equals("confirmed", ignoreCase = true) + + /** Последний день периода начисления; `null`, если `date_to` не разобрался. */ + fun periodEndOrNull(): LocalDate? = dateTo?.take(10)?.let { raw -> + runCatching { LocalDate.parse(raw) }.getOrNull() + } } /** Список начислений: `data` — массив. */ @@ -219,6 +225,12 @@ data class DayFact( data class SalaryRatesFromApi( val pricePerSession: Double? = null, val pricePerDiagnostics: Double? = null, + /** + * Последний день месяца, из начисления за который взяты ставки. По нему + * видно, насколько источник отстал: за дни после этой даты ставку знает + * только посуточный расчёт. + */ + val periodEnd: LocalDate? = null, ) { val isEmpty: Boolean get() = pricePerSession == null && pricePerDiagnostics == null } diff --git a/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt b/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt index aa5150f9..842067d0 100644 --- a/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt +++ b/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt @@ -611,19 +611,29 @@ class YClientsRepository(context: Context) { when (latest) { is ApiResult.Error -> return@withContext latest is ApiResult.Success -> { - val id = latest.data ?: continue - return@withContext requestCalculationRates(id, generation) + val summary = latest.data ?: continue + val id = summary.id ?: continue + val rates = requestCalculationRates(id, generation) + // Дата конца начисления едет вместе со ставками: по ней + // видно, где кончается их правда и начинается посуточный + // расчёт текущего месяца. + return@withContext when (rates) { + is ApiResult.Error -> rates + is ApiResult.Success -> ApiResult.Success( + rates.data.copy(periodEnd = summary.periodEndOrNull()), + ) + } } } } ApiResult.Success(SalaryRatesFromApi()) } - /** id самого свежего начисления периода или `null`, если начислений нет. */ + /** Самое свежее начисление периода или `null`, если начислений нет. */ private suspend fun requestLatestCalculation( period: ClosedRange, generation: Int, - ): ApiResult { + ): ApiResult { val all = when (val result = requestCalculations(period, generation)) { is ApiResult.Success -> result.data is ApiResult.Error -> return result @@ -635,15 +645,11 @@ class YClientsRepository(context: Context) { // Свежесть — по date_to; если его нет, берём наибольший id. val latest = items.maxWithOrNull( compareBy( - { summary -> - summary.dateTo?.take(10)?.let { raw -> - runCatching { LocalDate.parse(raw) }.getOrNull() - } - }, + { summary -> summary.periodEndOrNull() }, { summary -> summary.id }, ), ) - return ApiResult.Success(latest?.id) + return ApiResult.Success(latest) } /** Начисления периода: месяц, сумма, признак «проведено». */ diff --git a/app/src/main/java/ru/greemlab/neiro/sync/DailySessionRate.kt b/app/src/main/java/ru/greemlab/neiro/sync/DailySessionRate.kt new file mode 100644 index 00000000..278aa00d --- /dev/null +++ b/app/src/main/java/ru/greemlab/neiro/sync/DailySessionRate.kt @@ -0,0 +1,90 @@ +package ru.greemlab.neiro.sync + +import ru.greemlab.neiro.data.network.DayFact +import ru.greemlab.neiro.ui.calendar.Session +import ru.greemlab.neiro.ui.calendar.SessionParser +import ru.greemlab.neiro.ui.calendar.buildIntensiveChildrenByTime +import ru.greemlab.neiro.ui.calendar.isStudentCoveredByIntensive +import java.time.LocalDate +import kotlin.math.abs +import kotlin.math.round + +/** Как глубоко назад ищем день, из которого видно сегодняшнюю ставку. */ +const val DAILY_RATE_DEPTH_DAYS = 45L + +/** Ставка занятия, выведенная из начисления за один день, и день, из которого она взята. */ +data class DailySessionRate(val date: LocalDate, val pricePerSession: Double) + +/** + * Ставка занятия из посуточного расчёта — единственный источник цены за + * текущий месяц. + * + * Начисление создаётся в конце месяца (API-HOWTO 6), поэтому у идущего месяца + * позиций со ставками нет вовсе: 20.08.2026 список начислений за август пуст, + * а последнее закрытое — июль со ставкой 1400. Ставка выросла до 1500 с + * первого августа, и увидеть её можно только в посуточном расчёте: за 20.08 + * YClients отдаёт `services_count: 6` и `salary: 9000`. + * + * Делим начисление дня на число услуг, но **только у дня, в котором одни + * обычные занятия**. Смешанный день даёт цену, которой не было ни у одного + * занятия (API-HOWTO 5.4): 5 занятий по 1500 плюс диагностика 2250 — это + * 9750 ÷ 6 = 1625. Состав дня берётся из локального календаря: интенсивы + * отсекаются ещё и по `group_services_count` из самого ответа. + * + * Идём от свежего дня к старому и берём первый подходящий: ставка меняется с + * начала месяца, и самый свежий чистый день — единственный, кто про неё знает. + */ +fun sessionRateFromDailyFacts( + facts: Map, + dayData: Map>, + today: LocalDate, + depthDays: Long = DAILY_RATE_DEPTH_DAYS, +): DailySessionRate? { + val earliest = today.minusDays(depthDays) + return facts.entries + .asSequence() + .filter { (date, _) -> !date.isAfter(today) && !date.isBefore(earliest) } + .sortedByDescending { (date, _) -> date } + .firstNotNullOfOrNull { (date, fact) -> rateFromDay(date, fact, dayData[date]) } +} + +/** + * Ставка одного дня или `null`, если по этому дню её не вывести. + * + * Отказываемся молча и часто — это нормально: пропущенный день стоит одной + * итерации, а принятый неверно уедет в профиль и в деньги месяца. + */ +private fun rateFromDay( + date: LocalDate, + fact: DayFact, + sessions: List?, +): DailySessionRate? { + if (fact.salary <= 0.0 || fact.servicesCount <= 0) return null + // Интенсив в дне: его деньги лежат в той же сумме, а в services_count его нет. + if (fact.groupServicesCount > 0) return null + // Календарь по этому дню не синхронизирован — состав услуг неизвестен. + val parsed = sessions?.map(SessionParser::parse) ?: return null + + val intensiveChildrenByTime = buildIntensiveChildrenByTime(parsed) + var students = 0 + for (session in parsed) { + if (!session.countsTowardEarnings()) continue + when (session) { + // Диагностика и интенсив идут по своим ставкам — день смешанный. + is Session.Diagnostics -> return null + is Session.Intensive -> return null + is Session.Student -> + if (!isStudentCoveredByIntensive(session, intensiveChildrenByTime)) students++ + } + } + + // Локальный календарь должен сойтись с YClients ровно: разошлись — значит + // в начислении дня есть что-то, чего в календаре не видно. + if (students == 0 || students != fact.servicesCount) return null + + val price = fact.salary / fact.servicesCount + // Ставка — целое число рублей. Копейки в частном означают, что в сумму дня + // попало что-то ещё: доплата, бонус, сдвоенное занятие. + if (abs(price - round(price)) > 0.001) return null + return DailySessionRate(date = date, pricePerSession = price) +} diff --git a/app/src/main/java/ru/greemlab/neiro/sync/ProfileRatesUpdater.kt b/app/src/main/java/ru/greemlab/neiro/sync/ProfileRatesUpdater.kt index a4522d5b..a6205963 100644 --- a/app/src/main/java/ru/greemlab/neiro/sync/ProfileRatesUpdater.kt +++ b/app/src/main/java/ru/greemlab/neiro/sync/ProfileRatesUpdater.kt @@ -3,6 +3,7 @@ package ru.greemlab.neiro.sync import ru.greemlab.neiro.data.network.SalaryRatesFromApi import ru.greemlab.neiro.domain.models.PriceOrigin import ru.greemlab.neiro.domain.models.UserProfile +import java.time.LocalDate /** * Переносит ставки из детализации начисления в профиль (FOUNDATION 6.1). @@ -38,3 +39,26 @@ fun applyApiRatesToProfile(profile: UserProfile, rates: SalaryRatesFromApi): Use return updated } + +/** + * Ставка занятия из посуточного расчёта — поверх ставки закрытого начисления. + * + * Начисление отстаёт на месяц: пока август идёт, единственное проведённое — + * июльское, и оно честно отдаёт июльскую ставку. Августовскую видно только по + * дням ([sessionRateFromDailyFacts]), поэтому свежий день главнее. + * + * Сравнение с [periodEnd] — не формальность: чистых дней в новом месяце может + * не оказаться вовсе (все с диагностиками), и тогда посуточный источник + * вернёт день из прошлого, чью ставку начисление уже уточнило. + */ +fun applyDailyRateToProfile( + profile: UserProfile, + rate: DailySessionRate?, + periodEnd: LocalDate? = null, +): UserProfile { + if (rate == null || rate.pricePerSession <= 0.0) return profile + if (profile.sessionPriceOrigin != PriceOrigin.AUTO) return profile + if (periodEnd != null && !rate.date.isAfter(periodEnd)) return profile + if (rate.pricePerSession == profile.pricePerSession) return profile + return profile.copy(pricePerSession = rate.pricePerSession) +} diff --git a/app/src/main/java/ru/greemlab/neiro/sync/SalaryHistorySync.kt b/app/src/main/java/ru/greemlab/neiro/sync/SalaryHistorySync.kt index 9272571f..7cbeca24 100644 --- a/app/src/main/java/ru/greemlab/neiro/sync/SalaryHistorySync.kt +++ b/app/src/main/java/ru/greemlab/neiro/sync/SalaryHistorySync.kt @@ -11,6 +11,7 @@ import ru.greemlab.neiro.data.SalaryLedger import ru.greemlab.neiro.data.SalaryLedgerStore import ru.greemlab.neiro.data.mergeFact import ru.greemlab.neiro.data.network.ApiResult +import ru.greemlab.neiro.data.network.SalaryRatesFromApi import ru.greemlab.neiro.data.network.YClientsRepository import ru.greemlab.neiro.domain.models.PriceOrigin import ru.greemlab.neiro.domain.models.earningsContext @@ -70,7 +71,7 @@ class SalaryHistorySync private constructor(context: Context) { suspend fun syncRecentMonths(today: LocalDate = LocalDate.now()): SalaryPullResult { val staffId = staffIdOrNull() ?: return SalaryPullResult.NoStaff ledgerStore.warmUp() - updateAutoProfileRates() + updateAutoProfileRates(today) val previousMonth = YearMonth.from(today).minusMonths(1) val previousFrozen = ledgerStore.ledger.value.month(staffId, previousMonth)?.frozen == true @@ -91,7 +92,7 @@ class SalaryHistorySync private constructor(context: Context) { ): SalaryPullResult { val staffId = staffIdOrNull() ?: return SalaryPullResult.NoStaff ledgerStore.warmUp() - updateAutoProfileRates() + updateAutoProfileRates(today) return pull(staffId = staffId, from = from, to = today, today = today) } @@ -173,21 +174,58 @@ class SalaryHistorySync private constructor(context: Context) { } /** - * АВТО-цены профиля из детализации последнего начисления (FOUNDATION 6.2). + * АВТО-цены профиля: ставки закрытого начисления (FOUNDATION 6.2) плюс + * ставка занятия из посуточного расчёта за дни после него. + * + * Двух источников не избежать. Начисление знает, чем занятие отличается от + * диагностики, но появляется только в конце месяца и весь идущий месяц + * отдаёт прошлую ставку — из-за этого 20.08.2026 в профиле стояли июльские + * 1400 при фактических 1500. Посуточный расчёт отстать не может, но видов + * работ не различает, поэтому цену занятия из него достаёт + * [sessionRateFromDailyFacts] — и только по дням, где заняты одни занятия. * * Живёт здесь, а не в календарном синке: тот вызывается и live-опросом, * который трогать нельзя. Ручные цены не переписываются никогда. */ - private suspend fun updateAutoProfileRates() { - val rates = when (val result = repository.fetchLatestSalaryRates()) { + private suspend fun updateAutoProfileRates(today: LocalDate) { + val rates = when (val result = repository.fetchLatestSalaryRates(today)) { is ApiResult.Success -> result.data is ApiResult.Error -> { Log.w(TAG, "Ставки из начисления не получены (code=${result.code})") - return + SalaryRatesFromApi() } } - if (rates.isEmpty) return - calendarRepository.updateProfile { profile -> applyApiRatesToProfile(profile, rates) } + val dailyRate = latestDailySessionRate(today) + if (rates.isEmpty && dailyRate == null) return + + // Одним обновлением: иначе профиль моргнёт прошлой ставкой, пока не + // доедет свежая. + calendarRepository.updateProfile { profile -> + applyDailyRateToProfile( + profile = applyApiRatesToProfile(profile, rates), + rate = dailyRate, + periodEnd = rates.periodEnd, + ) + } + } + + /** + * Ставка занятия из последнего дня, где YClients уже начислил, а работали + * одни занятия. Глубина [DAILY_RATE_DEPTH_DAYS] закрывает отпуск и + * новогодние каникулы, дальше смотреть смысла нет: там ставка уже своя. + */ + private suspend fun latestDailySessionRate(today: LocalDate): DailySessionRate? { + val from = today.minusDays(DAILY_RATE_DEPTH_DAYS) + val facts = when (val result = repository.fetchSalaryDaily(from, today, today)) { + is ApiResult.Success -> result.data + is ApiResult.Error -> { + Log.w(TAG, "Посуточный расчёт не получен (code=${result.code})") + return null + } + } + if (facts.isEmpty()) return null + val dayData = calendarRepository.dayDataFlow.first() + return sessionRateFromDailyFacts(facts = facts, dayData = dayData, today = today) } /** diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt index 79cfa71b..42a3da59 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt @@ -56,6 +56,7 @@ import ru.greemlab.neiro.ui.calendar.getMonthName import ru.greemlab.neiro.ui.settings.ProfitDisplaySettings import ru.greemlab.neiro.ui.util.RU_LOCALE import ru.greemlab.neiro.ui.util.fadingScrollEdges +import ru.greemlab.neiro.ui.util.pluralRu import java.time.YearMonth import java.time.format.DateTimeFormatter @@ -308,7 +309,10 @@ fun ProfitDetailsDialog( if (display.showIntensiveEarnings && stats.intensiveEarnings > 0.0) { ProfitRow( - label = "Заработано интенсив", + label = pluralRu( + stats.completedIntensivesCount, + "Интенсив", "Интенсива", "Интенсивов", + ), value = stats.intensiveEarnings, color = MaterialTheme.colorScheme.onSurface, icon = Icons.Rounded.Groups, @@ -318,7 +322,10 @@ fun ProfitDetailsDialog( if (display.showDiagnosticsEarnings && stats.diagnosticsEarnings > 0.0) { ProfitRow( - label = "Заработано диагностика", + label = pluralRu( + stats.completedDiagnosticsCount, + "Диагностика", "Диагностики", "Диагностик", + ), value = stats.diagnosticsEarnings, color = MaterialTheme.colorScheme.onSurface, icon = Icons.Rounded.School, diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt index b4c26d6f..b761a823 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt @@ -39,14 +39,11 @@ import ru.greemlab.neiro.theme.neiroSemanticColors import ru.greemlab.neiro.ui.calendar.DaySummaryStats import ru.greemlab.neiro.ui.calendar.formatIntensiveConductedLabel import ru.greemlab.neiro.ui.util.RU_LOCALE +import ru.greemlab.neiro.ui.util.formatDayMonth import ru.greemlab.neiro.ui.util.formatRubles import java.time.LocalDate -import java.time.format.DateTimeFormatter import java.time.format.TextStyle -private val ShortDateFormat: DateTimeFormatter = - DateTimeFormatter.ofPattern("d MMMM", RU_LOCALE) - /** Базовая высота слота при системном шрифте 100%. */ private val DaySummarySlotBaseHeight: Dp = 166.dp @@ -114,7 +111,7 @@ private fun DaySummaryCard( val dateLabel = remember(date) { val weekday = date.dayOfWeek.getDisplayName(TextStyle.SHORT, RU_LOCALE) .replaceFirstChar { it.uppercase(RU_LOCALE) } - "$weekday, ${date.format(ShortDateFormat)}" + "$weekday ${formatDayMonth(date)}" } val earnedText = remember(stats.earned) { formatRubles(stats.earned) } val expectedText = remember(stats.expected) { formatRubles(stats.expected) } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt index f1ee7ffd..27c83a4d 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt @@ -4,15 +4,11 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CloudOff @@ -31,7 +27,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -79,20 +74,6 @@ import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth -// КНОПКА «СЕГОДНЯ» — ВСЁ КРУТИТСЯ ЭТИМИ ЧИСЛАМИ, БОЛЬШЕ В НЕЙ НИЧЕГО НЕТ. -// Дробные значения можно, вплоть до сотых: 6.5.dp, 4.25.dp. -// -// Размер стекла. Подпись и иконка не меняются: у текста отключены служебные -// поля шрифта, поэтому стекло режется вплотную к буквам, а не к пустоте. -private val TodayGlassSideGap = 12.dp // стекло слева и справа от строки -private val TodayGlassTopGap = 7.dp // стекло сверху и снизу от строки -private val TodayIconTextGap = 8.dp // просвет между иконкой и подписью -private val TodayIconSize = 18.dp // сама иконка: она выше строки текста - -// Где висит плашка — отступ от края экрана: -private val TodayOffsetEnd = 37.dp // от правого края -private val TodayOffsetBottom = 30.dp // от низа - /** * Все типы overlay, отображающихся поверх календаря. * Хранение в одном sealed класс гарантирует, что одновременно открыт @@ -863,7 +844,7 @@ fun CalendarScreenContent( exit = fadeOut() + scaleOut(targetScale = 0.8f), modifier = Modifier .align(Alignment.BottomEnd) - .padding(bottom = TodayOffsetBottom, end = TodayOffsetEnd) + .padding(bottom = 16.dp, end = 16.dp) .navigationBarsPadding(), ) { // Со стеклом кнопка сделана из того же материала, что панели: @@ -873,53 +854,31 @@ fun CalendarScreenContent( val glass = LocalGlassEnabled.current val todayShape = RoundedCornerShape(12.dp) - // Размер плашки задают только четыре числа сверху файла. - // ExtendedFloatingActionButton для этого не годится: у него - // минимальный размер зашит внутри (56 dp в высоту, 80 в ширину) - // и полями не ужимается. - // Слои идут строго по порядку: заливка, поверх неё кромка, - // и только потом клик. Через Surface рамка уходила под его - // собственную заливку, и от неё оставались только боковые - // линии — грани получались разными. - val contentColor = if (glass) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSecondaryContainer - } - CompositionLocalProvider(LocalContentColor provides contentColor) { - Row( - modifier = Modifier - .clip(todayShape) - .background(glassControlColor(), todayShape) - .glassBorder(todayShape) - .clickable(onClick = onTodayClick) - .padding( - horizontal = TodayGlassSideGap, - vertical = TodayGlassTopGap, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(TodayIconTextGap), - ) { - Icon( - Icons.Rounded.Today, - contentDescription = null, - modifier = Modifier.size(TodayIconSize), - ) - Text( - text = stringResource(R.string.calendar_go_to_today), - // Кегль прежний. Убраны только служебные поля шрифта - // и лишняя высота строки — иначе внутри плашки - // остаётся пустая полоса, которую полями не срезать. - style = MaterialTheme.typography.labelLarge.copy( - platformStyle = PlatformTextStyle(includeFontPadding = false), - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.Both, - ), - ), + ExtendedFloatingActionButton( + onClick = onTodayClick, + modifier = Modifier + .heightIn(min = 48.dp) + .glassBorder(todayShape), + containerColor = glassControlColor(), + contentColor = if (glass) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + elevation = if (glass) { + FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, ) - } - } + } else { + FloatingActionButtonDefaults.elevation() + }, + shape = todayShape, + icon = { Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(18.dp)) }, + text = { Text(stringResource(R.string.calendar_go_to_today), style = MaterialTheme.typography.labelLarge) }, + ) } } } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt b/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt index 5d290cae..3e945f83 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt @@ -11,8 +11,10 @@ import kotlinx.coroutines.flow.asStateFlow * Живёт в отдельных prefs, а не в DataStore рядом с темой: тема уезжает в * синхронизацию и экспорт, а вид поверхностей — местная настройка телефона. * - * Стеклянный вид по умолчанию выключен — у того, кто ничего не трогал, всё - * остаётся как было. + * Стеклянный вид включён по умолчанию: приложение с ним и задумано, и первый + * запуск должен показать его сразу. Дефолт срабатывает только пока ключа в + * prefs нет — стоит переключить тумблер, и дальше решает выбор пользователя: + * выключил стекло — оно остаётся выключенным. */ class AppearancePreferences(context: Context) { @@ -33,7 +35,7 @@ class AppearancePreferences(context: Context) { companion object { private const val PREFS_NAME = "neiro_appearance_prefs" private const val KEY_GLASS = "glass_surfaces" - private const val DEFAULT_GLASS = false + private const val DEFAULT_GLASS = true @Volatile private var instance: AppearancePreferences? = null diff --git a/app/src/main/java/ru/greemlab/neiro/ui/sync/SyncViewModel.kt b/app/src/main/java/ru/greemlab/neiro/ui/sync/SyncViewModel.kt index 82b4a489..df76c76c 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/sync/SyncViewModel.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/sync/SyncViewModel.kt @@ -107,12 +107,12 @@ class SyncViewModel(application: Application) : AndroidViewModel(application) { calendarRepository.clearAllData() LogoutCoordinator.logout(getApplication()) + // Цены не проставляем: их привезёт синк, и привезёт сегодняшние. + // Вписанные сюда руками жили до первого синка и показывали ставку + // прошлого года, из-за чего дев-сборка после сброса врала. + // Налог YClients не знает — он всегда ручной. calendarRepository.updateProfile { profile -> - profile.copy( - pricePerSession = 1400.0, - pricePerDiagnostics = 2250.0, - monthlyTaxAmount = 6500.0, - ) + profile.copy(monthlyTaxAmount = 6500.0) } devLogin(autoSync = true) diff --git a/app/src/main/java/ru/greemlab/neiro/ui/util/Formatters.kt b/app/src/main/java/ru/greemlab/neiro/ui/util/Formatters.kt index 2d75d9d7..97fce90c 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/util/Formatters.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/util/Formatters.kt @@ -2,6 +2,7 @@ package ru.greemlab.neiro.ui.util import java.text.DecimalFormat import java.text.DecimalFormatSymbols +import java.time.LocalDate import java.util.Locale /** Локаль для русскоязычного форматирования сумм и дат. */ @@ -24,6 +25,33 @@ fun formatRubles(value: Double): String = "${rublesFormat.format(value.toLong()) /** Краткое форматирование (без символа валюты), без дробной части. */ fun formatNumber(value: Double): String = String.format(RU_LOCALE, "%.0f", value) +/** + * Русское склонение по числу: `pluralRu(1, "диагностика", "диагностики", "диагностик")`. + * + * [one] — 1, 21, 31…; [few] — 2–4, 22–24…; [many] — всё остальное, включая + * 0 и подставные 11–14. + */ +fun pluralRu(n: Int, one: String, few: String, many: String): String { + val mod10 = n % 10 + val mod100 = n % 100 + return when { + mod10 == 1 && mod100 != 11 -> one + mod10 in 2..4 && mod100 !in 12..14 -> few + else -> many + } +} + +// Месяц берём таблицей, а не шаблоном `MMMM`: для русской локали он отдаёт +// именительный падеж, и в дате выходило «21 июнь» вместо «21 июня». +private val GENITIVE_MONTHS = arrayOf( + "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря", +) + +/** День с месяцем в родительном падеже: `21 июня`. */ +fun formatDayMonth(date: LocalDate): String = + "${date.dayOfMonth} ${GENITIVE_MONTHS[date.monthValue - 1]}" + /** * Форматирует строку цифр (11 знаков, начиная с 7) в читаемый формат: +7 (XXX) XXX-XX-XX. */ diff --git a/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt b/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt new file mode 100644 index 00000000..fe055072 --- /dev/null +++ b/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt @@ -0,0 +1,158 @@ +package ru.greemlab.neiro.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import ru.greemlab.neiro.data.network.DayFact +import ru.greemlab.neiro.domain.models.PriceOrigin +import ru.greemlab.neiro.domain.models.UserProfile +import ru.greemlab.neiro.ui.calendar.AttendanceStatus +import ru.greemlab.neiro.ui.calendar.Session +import ru.greemlab.neiro.ui.calendar.SessionFormat +import java.time.LocalDate + +/** + * Числа в тестах — живые, сняты с фирмы 520135 20.08.2026: в июле ставка 1400 + * (закрытое начисление), с августа 1500, и увидеть её можно только по дням. + */ +class DailySessionRateTest { + + private val today = LocalDate.of(2026, 8, 20) + + private fun students(count: Int): List = List(count) { "Ученик $it|3" } + + @Test + fun `price comes from the last clean day`() { + val facts = mapOf( + LocalDate.of(2026, 7, 31) to DayFact(salary = 8400.0, servicesCount = 6, groupServicesCount = 0), + today to DayFact(salary = 9000.0, servicesCount = 6, groupServicesCount = 0), + ) + val dayData = mapOf( + LocalDate.of(2026, 7, 31) to students(6), + today to students(6), + ) + + val rate = sessionRateFromDailyFacts(facts, dayData, today) + + assertEquals(today, rate?.date) + assertEquals(1500.0, rate?.pricePerSession ?: 0.0, 0.0) + } + + @Test + fun `day with diagnostics is skipped`() { + // 5 занятий по 1500 плюс диагностика 2250 — деление дало бы 1625. + val mixedDay = LocalDate.of(2026, 8, 19) + val cleanDay = LocalDate.of(2026, 8, 18) + val facts = mapOf( + mixedDay to DayFact(salary = 9750.0, servicesCount = 6, groupServicesCount = 0), + cleanDay to DayFact(salary = 9000.0, servicesCount = 6, groupServicesCount = 0), + ) + val dayData = mapOf( + mixedDay to students(5) + "__DIAGNOSTICS__:2250|Аня|3", + cleanDay to students(6), + ) + + val rate = sessionRateFromDailyFacts(facts, dayData, today) + + assertEquals(cleanDay, rate?.date) + assertEquals(1500.0, rate?.pricePerSession ?: 0.0, 0.0) + } + + @Test + fun `day with intensive is skipped`() { + // 29.07.2026 живьём: услуг ноль, группа одна, начислено 4200. + val intensiveDay = LocalDate.of(2026, 7, 29) + val facts = mapOf( + intensiveDay to DayFact(salary = 4200.0, servicesCount = 0, groupServicesCount = 1), + ) + val intensive = SessionFormat.serializeIntensive( + price = "", + name = "Интенсив", + status = AttendanceStatus.ARRIVED, + time = "18:00-18:50", + children = listOf(Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED)), + ) + + assertNull(sessionRateFromDailyFacts(facts, mapOf(intensiveDay to listOf(intensive)), today)) + } + + @Test + fun `day where calendar disagrees with api is skipped`() { + // В календаре пять занятий, YClients начислил за шесть: чего-то не видно. + val facts = mapOf(today to DayFact(salary = 9000.0, servicesCount = 6, groupServicesCount = 0)) + + assertNull(sessionRateFromDailyFacts(facts, mapOf(today to students(5)), today)) + } + + @Test + fun `day without local calendar is skipped`() { + val facts = mapOf(today to DayFact(salary = 9000.0, servicesCount = 6, groupServicesCount = 0)) + + assertNull(sessionRateFromDailyFacts(facts, emptyMap(), today)) + } + + @Test + fun `only arrived sessions count`() { + // Шестеро пришли, седьмой ожидается и в начисление не входит. + val facts = mapOf(today to DayFact(salary = 9000.0, servicesCount = 6, groupServicesCount = 0)) + val dayData = mapOf(today to students(6) + "Ожидающий|0") + + val rate = sessionRateFromDailyFacts(facts, dayData, today) + assertEquals(1500.0, rate?.pricePerSession ?: 0.0, 0.0) + } + + @Test + fun `fractional price is skipped`() { + // 9100 ÷ 6 = 1516,67 — в сумму дня попало что-то помимо ставки. + val facts = mapOf(today to DayFact(salary = 9100.0, servicesCount = 6, groupServicesCount = 0)) + + assertNull(sessionRateFromDailyFacts(facts, mapOf(today to students(6)), today)) + } + + @Test + fun `days older than depth are ignored`() { + val old = today.minusDays(DAILY_RATE_DEPTH_DAYS + 1) + val facts = mapOf(old to DayFact(salary = 8400.0, servicesCount = 6, groupServicesCount = 0)) + + assertNull(sessionRateFromDailyFacts(facts, mapOf(old to students(6)), today)) + } + + @Test + fun `daily rate overwrites the rate of a closed month`() { + val profile = UserProfile(pricePerSession = 1400.0) + + val updated = applyDailyRateToProfile( + profile = profile, + rate = DailySessionRate(today, 1500.0), + periodEnd = LocalDate.of(2026, 7, 31), + ) + + assertEquals(1500.0, updated.pricePerSession, 0.0) + } + + @Test + fun `day inside a closed month does not overwrite its rate`() { + // Начисление за июль знает про диагностики, день из июля — нет. + val profile = UserProfile(pricePerSession = 1400.0) + + val updated = applyDailyRateToProfile( + profile = profile, + rate = DailySessionRate(LocalDate.of(2026, 7, 15), 1250.0), + periodEnd = LocalDate.of(2026, 7, 31), + ) + + assertEquals(1400.0, updated.pricePerSession, 0.0) + } + + @Test + fun `manual price is never overwritten`() { + val profile = UserProfile( + pricePerSession = 1400.0, + sessionPriceOrigin = PriceOrigin.MANUAL, + ) + + val updated = applyDailyRateToProfile(profile, DailySessionRate(today, 1500.0)) + + assertEquals(profile, updated) + } +}