feat(shift): support overrides and phase breaks in ShiftPattern
This commit is contained in:
parent
5afac5dd04
commit
778d5dc8be
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user