fix: 对齐报工归属有效工时分配

This commit is contained in:
souplearn 2026-07-25 03:13:06 +08:00
parent e4dd01bc72
commit 63ea6fdcc5
3 changed files with 72 additions and 23 deletions

View File

@ -461,9 +461,11 @@ def item_time_ranges(
ranges: list[ItemTimeRange] = []
allocated_item_ids: set[int] = set()
unassigned_ranges: list[tuple[datetime, datetime]] = []
for device_no, device_ranges in segment_ranges.items():
device_items = items_by_device.get(device_no, [])
if not device_items:
unassigned_ranges.extend(device_ranges)
continue
for segment_start, segment_end in device_ranges:
ranges.extend(_device_item_time_ranges(device_items, segment_start, segment_end))
@ -471,8 +473,18 @@ def item_time_ranges(
allocated_item_ids.add(id(item))
unmatched_items = [item for item in items if id(item) not in allocated_item_ids]
if unmatched_items:
ranges.extend(ItemTimeRange(item=item, start_at=start_at, end_at=end_at) for item in unmatched_items)
if unmatched_items and unassigned_ranges:
allocation_weight = 1 / len(unmatched_items)
for segment_start, segment_end in unassigned_ranges:
ranges.extend(
ItemTimeRange(
item=item,
start_at=segment_start,
end_at=segment_end,
allocation_weight=allocation_weight,
)
for item in unmatched_items
)
return ranges

View File

@ -1,6 +1,6 @@
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from datetime import date, datetime, timedelta
from sqlalchemy import delete
from sqlalchemy.orm import Session
@ -40,10 +40,6 @@ class ReportAllocationDraft:
reference_wage: float = 0
def _midnight(value: date) -> datetime:
return datetime.combine(value, time.min)
def _add_minutes(
rows: dict[date, dict[str, float]],
allocation_date: date,
@ -55,18 +51,6 @@ def _add_minutes(
rows[allocation_date][bucket] += minutes
def _add_night_minutes(
rows: dict[date, dict[str, float]],
start_at: datetime,
end_at: datetime,
base_date: date,
) -> None:
midnight = _midnight(base_date + timedelta(days=1))
before_midnight = _overlap_minutes(start_at, end_at, _midnight(base_date), midnight)
after_midnight = _overlap_minutes(start_at, end_at, midnight, midnight + timedelta(days=1))
_add_minutes(rows, base_date, "night", before_midnight + after_midnight)
def _range_allocation_minutes(
start_at: datetime,
end_at: datetime,
@ -107,7 +91,7 @@ def _range_allocation_minutes(
)
minutes = max(0.0, minutes_between(overlap_start, overlap_end) - meal_minutes)
if bucket == "night":
_add_night_minutes(rows, overlap_start, overlap_end, current_date)
_add_minutes(rows, current_date, "night", minutes)
else:
_add_minutes(rows, overlap_start.date(), bucket, minutes)

View File

@ -7,11 +7,13 @@ from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import ProductionReport, ProductionReportAllocation, ProductionReportItem
from app.services.metrics import calculate_report_metrics
from app.services.report_allocations import (
allocation_summary_text,
build_report_allocation_drafts,
refresh_report_allocations,
)
from app.services.work_schedule import WorkScheduleConfig
@compiles(BigInteger, "sqlite")
@ -28,12 +30,15 @@ def _item(
changeover_count: float = 0,
process_unit_price_yuan: float = 0,
started_at: datetime | None = None,
device_no: str = "23#",
product_name: str | None = None,
process_name: str | None = "冲压",
):
return SimpleNamespace(
id=id,
device_no="23#",
product_name="23#",
process_name="冲压",
device_no=device_no,
product_name=product_name or device_no,
process_name=process_name,
started_at=started_at,
standard_beat=1,
standard_workload=0,
@ -168,6 +173,54 @@ def test_items_sharing_same_started_at_split_the_segment_minutes():
]
def test_device_matched_and_unmatched_items_match_metrics_remaining_minutes():
start_at = datetime(2026, 7, 25, 8, 0)
end_at = datetime(2026, 7, 25, 11, 0)
matching_item = _item(id=1, good_qty=10, device_no="23#")
unmatched_item = _item(id=2, good_qty=20, device_no="99#")
report = _report(start_at, end_at, [matching_item, unmatched_item], devices=[_device(start_at)])
calculate_report_metrics(start_at, end_at, [matching_item, unmatched_item], devices=[_device(start_at)])
rows = build_report_allocation_drafts(report)
assert {matching_item.id: matching_item.allocated_minutes, unmatched_item.id: unmatched_item.allocated_minutes} == {
1: 180,
2: 0,
}
assert {row.report_item_id: row.effective_minutes for row in rows} == {
1: 180,
2: 0,
}
def test_night_allocation_uses_meal_deducted_minutes_with_previous_date_rules():
schedule = WorkScheduleConfig(
day_start="08:00",
day_end="17:20",
lunch_start="11:40",
lunch_end="12:40",
dinner_start="21:00",
dinner_end="21:30",
overtime_start="18:00",
overtime_end="20:00",
night_start="20:00",
night_end="06:00",
)
start_at = datetime(2026, 7, 25, 20, 0)
end_at = datetime(2026, 7, 25, 22, 0)
item = _item(id=1, good_qty=90)
report = _report(start_at, end_at, item)
metrics = calculate_report_metrics(start_at, end_at, [item], schedule=schedule)
rows = build_report_allocation_drafts(report, schedule=schedule)
assert metrics["effective_minutes"] == 90
assert metrics["shift_night_minutes"] == 90
assert [(row.allocation_date, row.night_minutes, row.effective_minutes) for row in rows] == [
(date(2026, 7, 25), 90, 90),
]
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(