From 778d5dc8be4c05c80ccac1671a9e802f1337fb3a Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:14:24 +0800 Subject: [PATCH] feat(shift): support overrides and phase breaks in ShiftPattern --- .../kotlin/plus/rua/project/ShiftPattern.kt | 38 +++++++-- .../plus/rua/project/ShiftPatternTest.kt | 77 +++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt index d34037f..7cacda9 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt @@ -8,26 +8,52 @@ import kotlinx.datetime.daysUntil */ enum class ShiftKind { WORK, OFF } +/** + * 相位断点:从 [date] 起重排周期相位,[cycleOffset] 指定当天对应 cycle 的第几位。 + * + * @param date 断点生效日(含当天) + * @param cycleOffset 该天对应的 cycle 索引(0 = cycle[0]) + */ +data class PhaseBreak( + val date: LocalDate, + val cycleOffset: Int +) + /** * 个人轮班周期。 * - * 与法定节假日完全独立:周期内某天是 WORK 还是 OFF,只看 - * `(date - anchorDate) mod cycle.size` 在 cycle 中的取值,不受任何节假日/调休影响。 + * 与法定节假日完全独立。某天的班/休由以下顺序决定: + * 1. 若 [overrides] 命中该天,取 override 值(单日翻转); + * 2. 否则取该天"活跃锚点"(最近的 phaseBreak 或基础 anchorDate)起算的 cycle 索引。 * - * @param anchorDate 周期基准日,对应 cycle[0] + * @param anchorDate 基础周期基准日,对应 cycle[0] * @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休" - * @param name 方案名,用于后续多套方案场景 + * @param overrides 单日翻转映射(调班),key 为日期 + * @param phaseBreaks 相位断点列表,从某天起重排周期相位 + * @param name 方案名 */ data class ShiftPattern( val anchorDate: LocalDate, val cycle: List, + val overrides: Map = emptyMap(), + val phaseBreaks: List = emptyList(), val name: String = "默认" ) { fun kindAt(date: LocalDate): ShiftKind? { if (cycle.isEmpty()) return null - val diff = anchorDate.daysUntil(date) + // 1. 单日翻转优先 + overrides[date]?.let { return it } + // 2. 找活跃锚点:phaseBreaks 中 date <= 当天 的最大那个;无则用基础锚点 + val (anchor, offset) = activeAnchor(date) + val diff = anchor.daysUntil(date) val size = cycle.size - val idx = ((diff % size) + size) % size + val idx = (((diff + offset) % size) + size) % size return cycle[idx] } + + private fun activeAnchor(date: LocalDate): Pair { + val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date } + return if (applicable != null) applicable.date to applicable.cycleOffset + else anchorDate to 0 + } } diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt index 1958796..8f60227 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -176,4 +176,81 @@ class ShiftPatternTest { assertEquals(a, b) assertEquals(a.hashCode(), b.hashCode()) } + + // ---- kindAt: 单日 override ---- + + @Test + fun kindAt_overrideOnBaseDay_flipsToOff() { + // 基础周期下 5/15 = WORK(锚点),override 为 OFF + val pattern = twoOnTwoOff.copy( + overrides = mapOf(LocalDate(2026, 5, 15) to ShiftKind.OFF) + ) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 15))) + // 隔天不受影响 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 16))) + } + + @Test + fun kindAt_overrideOnOffDay_flipsToWork() { + // 基础周期下 5/17 = OFF,override 为 WORK + val pattern = twoOnTwoOff.copy( + overrides = mapOf(LocalDate(2026, 5, 17) to ShiftKind.WORK) + ) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17))) + } + + // ---- kindAt: 相位断点 phaseBreak ---- + + @Test + fun kindAt_phaseBreak_restartsCycleFromBreakDate() { + // 断点设在 5/19,从这天起重新 cycle[0] + val pattern = twoOnTwoOff.copy( + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 0)) + ) + // 5/19 = cycle[0] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19))) + // 5/20 = cycle[1] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20))) + // 5/21 = cycle[2] = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21))) + // 断点之前保持原相位:5/18 = (18-15)%4=3 = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18))) + } + + // ---- kindAt: override + phaseBreak 组合(用户原始例子)---- + + @Test + fun kindAt_userExample_combinedOverridesAndPhaseBreak() { + // 基础锚点 7/8,周期 [WORK,WORK,OFF,OFF] + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 7, 8), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), + overrides = mapOf( + LocalDate(2026, 7, 12) to ShiftKind.OFF, // 班→休 + LocalDate(2026, 7, 14) to ShiftKind.WORK, // 休→班 + LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班 + LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休 + LocalDate(2026, 7, 17) to ShiftKind.OFF // 班→休 + ), + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0)) + ) + // 7/10,11 = 基础周期自动算 (idx 2,3) = OFF,OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 11))) + // 7/12 = override OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 12))) + // 7/13 = 基础周期 (idx 5%4=1) = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 13))) + // 7/14,15 = override WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 14))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15))) + // 7/16,17 = override OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17))) + // 7/18 起 phaseBreak 重排:cycle[0,1,2,3] = WORK,WORK,OFF,OFF + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 18))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 19))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21))) + } }