Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/main/java/ru/greemlab/neiro/data/network/SalaryModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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` — массив. */
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalDate>,
generation: Int,
): ApiResult<Long?> {
): ApiResult<SalaryCalculationSummary?> {
val all = when (val result = requestCalculations(period, generation)) {
is ApiResult.Success -> result.data
is ApiResult.Error -> return result
Expand All @@ -635,15 +645,11 @@ class YClientsRepository(context: Context) {
// Свежесть — по date_to; если его нет, берём наибольший id.
val latest = items.maxWithOrNull(
compareBy<SalaryCalculationSummary>(
{ 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)
}

/** Начисления периода: месяц, сумма, признак «проведено». */
Expand Down
90 changes: 90 additions & 0 deletions app/src/main/java/ru/greemlab/neiro/sync/DailySessionRate.kt
Original file line number Diff line number Diff line change
@@ -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<LocalDate, DayFact>,
dayData: Map<LocalDate, List<String>>,
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<String>?,
): 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)
}
24 changes: 24 additions & 0 deletions app/src/main/java/ru/greemlab/neiro/sync/ProfileRatesUpdater.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
}
54 changes: 46 additions & 8 deletions app/src/main/java/ru/greemlab/neiro/sync/SalaryHistorySync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) }
Expand Down
Loading
Loading