fix: 按指标结果校准归属工时
This commit is contained in:
parent
891c10ddea
commit
1a8871d3b4
@ -17,6 +17,7 @@ from app.services.metrics import (
|
|||||||
_mold_key,
|
_mold_key,
|
||||||
_overlap_minutes,
|
_overlap_minutes,
|
||||||
_schedule_interval,
|
_schedule_interval,
|
||||||
|
calculate_report_metrics,
|
||||||
format_hours,
|
format_hours,
|
||||||
item_time_ranges,
|
item_time_ranges,
|
||||||
minutes_between,
|
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))
|
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:
|
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())
|
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
|
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]:
|
def _items_by_device(items: list) -> dict[str, list]:
|
||||||
grouped: dict[str, list] = defaultdict(list)
|
grouped: dict[str, list] = defaultdict(list)
|
||||||
for item in items:
|
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)
|
minutes_by_item = _device_minutes_by_item(report, items, schedule)
|
||||||
if minutes_by_item is None:
|
if minutes_by_item is None:
|
||||||
minutes_by_item = _range_minutes_by_item(report, items, schedule)
|
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] = []
|
rows: list[ReportAllocationDraft] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|||||||
@ -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"]
|
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():
|
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)
|
item = _item(good_qty=5, defect_qty=1, scrap_qty=0.5, changeover_count=2, process_unit_price_yuan=3)
|
||||||
report = _report(
|
report = _report(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user