feat(shift): support overrides and phase breaks in ShiftPattern

This commit is contained in:
xfy 2026-07-06 18:14:24 +08:00
parent 5afac5dd04
commit 778d5dc8be
2 changed files with 109 additions and 6 deletions

View File

@ -8,26 +8,52 @@ import kotlinx.datetime.daysUntil
*/ */
enum class ShiftKind { WORK, OFF } 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 cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休"
* @param name 方案名,用于后续多套方案场景 * @param overrides 单日翻转映射(调班),key 为日期
* @param phaseBreaks 相位断点列表,从某天起重排周期相位
* @param name 方案名
*/ */
data class ShiftPattern( data class ShiftPattern(
val anchorDate: LocalDate, val anchorDate: LocalDate,
val cycle: List<ShiftKind>, val cycle: List<ShiftKind>,
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
val phaseBreaks: List<PhaseBreak> = emptyList(),
val name: String = "默认" val name: String = "默认"
) { ) {
fun kindAt(date: LocalDate): ShiftKind? { fun kindAt(date: LocalDate): ShiftKind? {
if (cycle.isEmpty()) return null 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 size = cycle.size
val idx = ((diff % size) + size) % size val idx = (((diff + offset) % size) + size) % size
return cycle[idx] return cycle[idx]
} }
private fun activeAnchor(date: LocalDate): Pair<LocalDate, Int> {
val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date }
return if (applicable != null) applicable.date to applicable.cycleOffset
else anchorDate to 0
}
} }

View File

@ -176,4 +176,81 @@ class ShiftPatternTest {
assertEquals(a, b) assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode()) 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)))
}
} }