feat: 空白作息区间计入加班工时
This commit is contained in:
parent
427beaa342
commit
5fa3c695c0
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user