feat: 空白作息区间计入加班工时

This commit is contained in:
souplearn 2026-07-25 02:21:19 +08:00
parent 427beaa342
commit 5fa3c695c0
2 changed files with 114 additions and 0 deletions

View File

@ -71,6 +71,75 @@ def _overlap_minutes(
return minutes_between(start, end) if end > start else 0.0 return minutes_between(start, end) if end > start else 0.0
def _clip_interval(
interval_start: datetime,
interval_end: datetime,
boundary_start: datetime,
boundary_end: datetime,
) -> tuple[datetime, datetime] | None:
start = max(interval_start, boundary_start)
end = min(interval_end, boundary_end)
if end <= start:
return None
return start, end
def _merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]:
ordered = sorted(intervals, key=lambda item: item[0])
merged: list[tuple[datetime, datetime]] = []
for start, end in ordered:
if not merged or start > merged[-1][1]:
merged.append((start, end))
elif end > merged[-1][1]:
merged[-1] = (merged[-1][0], end)
return merged
def _subtract_intervals(
base_start: datetime,
base_end: datetime,
occupied: list[tuple[datetime, datetime]],
) -> list[tuple[datetime, datetime]]:
gaps: list[tuple[datetime, datetime]] = []
cursor = base_start
for start, end in _merge_intervals(occupied):
if start > cursor:
gaps.append((cursor, start))
if end > cursor:
cursor = end
if cursor < base_end:
gaps.append((cursor, base_end))
return gaps
def _calendar_dates_between(start_at: datetime, end_at: datetime):
current = start_at.date()
last = end_at.date()
while current <= last:
yield current
current += timedelta(days=1)
def _blank_overtime_intervals(
current_date: date,
schedule: WorkScheduleConfig,
) -> list[tuple[datetime, datetime]]:
day_start = datetime.combine(current_date, time.min)
day_end = day_start + timedelta(days=1)
occupied: list[tuple[datetime, datetime]] = []
raw_intervals = [
_schedule_interval(current_date, schedule.day_start, schedule.day_end),
_schedule_interval(current_date, schedule.overtime_start, schedule.overtime_end),
_schedule_interval(current_date, schedule.night_start, schedule.night_end),
_schedule_interval(current_date - timedelta(days=1), schedule.night_start, schedule.night_end),
]
for interval_start, interval_end in raw_intervals:
clipped = _clip_interval(interval_start, interval_end, day_start, day_end)
if clipped is not None:
occupied.append(clipped)
return _subtract_intervals(day_start, day_end, occupied)
def _covered_meal_overlap_minutes( def _covered_meal_overlap_minutes(
start_at: datetime, start_at: datetime,
end_at: datetime, end_at: datetime,
@ -143,6 +212,21 @@ def _raw_shift_minutes(
) )
) )
minutes[bucket] += max(0.0, raw_minutes - meal_minutes) minutes[bucket] += max(0.0, raw_minutes - meal_minutes)
for blank_start, blank_end in _blank_overtime_intervals(current_date, active_schedule):
raw_minutes = _overlap_minutes(start_at, end_at, blank_start, blank_end)
blank_start_at = max(start_at, blank_start)
blank_end_at = min(end_at, blank_end)
meal_minutes = sum(
_overlap_minutes(blank_start_at, blank_end_at, meal_start, meal_end)
for meal_start, meal_end in meal_intervals
if (
start_at <= meal_start
and end_at >= meal_end
and end_at > meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES)
)
)
minutes["overtime"] += max(0.0, raw_minutes - meal_minutes)
return minutes return minutes

View File

@ -280,3 +280,33 @@ def test_admin_forced_release_still_caps_work_time():
assert metrics["duration_minutes"] == 60 assert metrics["duration_minutes"] == 60
assert metrics["effective_minutes"] == 60 assert metrics["effective_minutes"] == 60
assert item.allocated_minutes == 60 assert item.allocated_minutes == 60
def test_morning_blank_interval_counts_as_overtime():
start_at = datetime(2026, 7, 25, 6, 0, 0)
end_at = datetime(2026, 7, 25, 8, 0, 0)
item = Item(good_qty=10)
metrics = calculate_report_metrics(start_at, end_at, [item])
assert metrics["duration_minutes"] == 120
assert metrics["effective_minutes"] == 120
assert metrics["shift_day_minutes"] == 0
assert metrics["shift_overtime_minutes"] == 120
assert metrics["shift_night_minutes"] == 0
assert item.allocated_minutes == 120
def test_dinner_blank_interval_counts_as_overtime():
start_at = datetime(2026, 7, 25, 17, 20, 0)
end_at = datetime(2026, 7, 25, 18, 0, 0)
item = Item(good_qty=10)
metrics = calculate_report_metrics(start_at, end_at, [item])
assert metrics["duration_minutes"] == 40
assert metrics["effective_minutes"] == 40
assert metrics["shift_day_minutes"] == 0
assert metrics["shift_overtime_minutes"] == 40
assert metrics["shift_night_minutes"] == 0
assert item.allocated_minutes == 40