diff --git a/app/services/report_allocations.py b/app/services/report_allocations.py index 597c119..3809b0f 100644 --- a/app/services/report_allocations.py +++ b/app/services/report_allocations.py @@ -11,6 +11,7 @@ from app.services.metrics import ( SHIFT_BUCKETS, SHIFT_LABELS, _blank_overtime_intervals, + _device_segment_ranges, _iter_shift_base_dates, _overlap_minutes, _schedule_interval, @@ -126,6 +127,38 @@ def _item_reference_wage(item) -> float: return as_float(getattr(item, "good_qty", 0)) * as_float(getattr(item, "process_unit_price_yuan", 0)) +def _minutes_total(rows: dict[date, dict[str, float]]) -> float: + return sum(sum(minutes.get(key, 0.0) for key in SHIFT_BUCKETS) for minutes in rows.values()) + + +def _target_effective_minutes(report, schedule: WorkScheduleConfig | None = None) -> float: + start_at = getattr(report, "start_at") + end_at = getattr(report, "end_at") + segment_ranges = _device_segment_ranges(start_at, end_at, _report_devices(report)) + if segment_ranges: + return sum( + _minutes_total(_range_allocation_minutes(segment_start, segment_end, schedule)) + for ranges in segment_ranges.values() + for segment_start, segment_end in ranges + ) + return _minutes_total(_range_allocation_minutes(start_at, end_at, schedule)) + + +def _scale_minutes_to_target( + minutes_by_item: dict[int, dict[date, dict[str, float]]], + target_effective_minutes: float, +) -> None: + raw_total = sum(_minutes_total(item_minutes) for item_minutes in minutes_by_item.values()) + if raw_total <= 0 or abs(raw_total - target_effective_minutes) <= 0.000001: + return + + scale = target_effective_minutes / raw_total + for item_minutes in minutes_by_item.values(): + for minutes in item_minutes.values(): + for key in SHIFT_BUCKETS: + minutes[key] = minutes.get(key, 0.0) * scale + + def _fallback_row(report, item) -> ReportAllocationDraft: return ReportAllocationDraft( report_id=getattr(report, "id", None), @@ -179,6 +212,7 @@ def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None = target[allocation_date][key] += minutes.get(key, 0.0) * as_float( getattr(item_range, "allocation_weight", 1.0) ) + _scale_minutes_to_target(minutes_by_item, _target_effective_minutes(report, schedule)) rows: list[ReportAllocationDraft] = [] for item in items: diff --git a/tests/test_report_allocations.py b/tests/test_report_allocations.py index 4ed94d7..25fc492 100644 --- a/tests/test_report_allocations.py +++ b/tests/test_report_allocations.py @@ -238,6 +238,30 @@ def test_blank_overtime_meal_deduction_is_clipped_like_metrics(): ] +def test_same_device_item_split_inside_meal_is_rescaled_to_metrics_effective_minutes(): + start_at = datetime(2026, 7, 25, 8, 0) + second_started_at = datetime(2026, 7, 25, 12, 0) + end_at = datetime(2026, 7, 25, 13, 0) + first_item = _item(id=1, good_qty=192, started_at=start_at) + second_item = _item(id=2, good_qty=48, started_at=second_started_at) + report = _report(start_at, end_at, [first_item, second_item], devices=[_device(start_at)]) + + metrics = calculate_report_metrics(start_at, end_at, [first_item, second_item], devices=[_device(start_at)]) + rows = build_report_allocation_drafts(report) + + assert metrics["effective_minutes"] == 240 + assert metrics["shift_day_minutes"] == 240 + assert {first_item.id: first_item.allocated_minutes, second_item.id: second_item.allocated_minutes} == { + 1: 192, + 2: 48, + } + assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [ + (1, 192, 192), + (2, 48, 48), + ] + assert sum(row.effective_minutes for row in rows) == 240 + + def test_zero_effective_minutes_falls_back_to_original_report_date(): item = _item(good_qty=5, defect_qty=1, scrap_qty=0.5, changeover_count=2, process_unit_price_yuan=3) report = _report(