From 1a8871d3b4954b45398f9ee24f5c6af8f7108b1a Mon Sep 17 00:00:00 2001 From: souplearn Date: Sat, 25 Jul 2026 03:41:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E6=8C=87=E6=A0=87=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=A0=A1=E5=87=86=E5=BD=92=E5=B1=9E=E5=B7=A5=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/report_allocations.py | 29 ++++++++++++++++++++++++ tests/test_report_allocations.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/app/services/report_allocations.py b/app/services/report_allocations.py index 42adb82..4868c92 100644 --- a/app/services/report_allocations.py +++ b/app/services/report_allocations.py @@ -17,6 +17,7 @@ from app.services.metrics import ( _mold_key, _overlap_minutes, _schedule_interval, + calculate_report_metrics, format_hours, item_time_ranges, minutes_between, @@ -129,6 +130,18 @@ def _item_reference_wage(item) -> float: return as_float(getattr(item, "good_qty", 0)) * as_float(getattr(item, "process_unit_price_yuan", 0)) +def _item_target_minutes(report, items: list, schedule: WorkScheduleConfig | None = None) -> dict[int, float]: + calculate_report_metrics( + getattr(report, "start_at"), + getattr(report, "end_at"), + items, + break_minutes=as_float(getattr(report, "break_minutes", 0)), + devices=_report_devices(report), + schedule=schedule, + ) + return {id(item): as_float(getattr(item, "allocated_minutes", 0)) for item in items} + + 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()) @@ -180,6 +193,21 @@ def _scale_item_minutes_to_bucket_targets( minutes[key] = minutes.get(key, 0.0) * scale +def _scale_item_minutes_to_item_targets( + minutes_by_item: dict[int, dict[date, dict[str, float]]], + target_minutes_by_item: dict[int, float], +) -> None: + for item_id, rows_by_date in minutes_by_item.items(): + raw_total = _minutes_total(rows_by_date) + target_total = target_minutes_by_item.get(item_id, raw_total) + if raw_total <= 0 or abs(raw_total - target_total) <= 0.000001: + continue + scale = target_total / raw_total + for minutes in rows_by_date.values(): + for key in SHIFT_BUCKETS: + minutes[key] = minutes.get(key, 0.0) * scale + + def _items_by_device(items: list) -> dict[str, list]: grouped: dict[str, list] = defaultdict(list) for item in items: @@ -305,6 +333,7 @@ def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None = minutes_by_item = _device_minutes_by_item(report, items, schedule) if minutes_by_item is None: minutes_by_item = _range_minutes_by_item(report, items, schedule) + _scale_item_minutes_to_item_targets(minutes_by_item, _item_target_minutes(report, items, schedule)) rows: list[ReportAllocationDraft] = [] for item in items: diff --git a/tests/test_report_allocations.py b/tests/test_report_allocations.py index 10ca235..331b2d2 100644 --- a/tests/test_report_allocations.py +++ b/tests/test_report_allocations.py @@ -297,6 +297,42 @@ def test_matched_meal_split_and_unmatched_device_range_follow_metrics_order(): assert round(sum(row.effective_minutes for row in rows), 2) == metrics["effective_minutes"] +def test_item_totals_anchor_to_metrics_when_unmatched_remaining_buckets_exceed_item_target(): + start_at = datetime(2026, 7, 25, 8, 0) + second_started_at = datetime(2026, 7, 25, 12, 0) + switch_at = datetime(2026, 7, 25, 18, 0) + end_at = datetime(2026, 7, 25, 20, 0) + devices = [ + _device(start_at, device_no="23#", sort_order=0), + _device(switch_at, device_no="77#", sort_order=1), + ] + first_item = _item(id=1, good_qty=240, device_no="23#", started_at=start_at) + second_item = _item(id=2, good_qty=360, device_no="23#", started_at=second_started_at) + unmatched_item = _item(id=3, good_qty=60, device_no="99#") + items = [first_item, second_item, unmatched_item] + report = _report(start_at, end_at, items, devices=devices) + + metrics = calculate_report_metrics(start_at, end_at, items, devices=devices) + rows = build_report_allocation_drafts(report) + + assert metrics["effective_minutes"] == 660 + assert {item.id: item.allocated_minutes for item in items} == { + 1: 240, + 2: 360, + 3: 60, + } + assert { + item_id: round(sum(row.effective_minutes for row in rows if row.report_item_id == item_id), 2) + for item_id in [1, 2, 3] + } == { + 1: 240, + 2: 360, + 3: 60, + } + assert round(sum(row.effective_minutes for row in rows), 2) == 660 + assert all(row.effective_minutes == row.day_minutes + row.overtime_minutes + row.night_minutes for row in rows) + + 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(