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] = [] ranges: list[ItemTimeRange] = []
allocated_item_ids: set[int] = set() allocated_item_ids: set[int] = set()
unassigned_ranges: list[tuple[datetime, datetime]] = []
for device_no, device_ranges in segment_ranges.items(): for device_no, device_ranges in segment_ranges.items():
device_items = items_by_device.get(device_no, []) device_items = items_by_device.get(device_no, [])
if not device_items: if not device_items:
unassigned_ranges.extend(device_ranges)
continue continue
for segment_start, segment_end in device_ranges: for segment_start, segment_end in device_ranges:
ranges.extend(_device_item_time_ranges(device_items, segment_start, segment_end)) 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)) allocated_item_ids.add(id(item))
unmatched_items = [item for item in items if id(item) not in allocated_item_ids] unmatched_items = [item for item in items if id(item) not in allocated_item_ids]
if unmatched_items: if unmatched_items and unassigned_ranges:
ranges.extend(ItemTimeRange(item=item, start_at=start_at, end_at=end_at) for item in unmatched_items) 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 return ranges

View File

@ -1,6 +1,6 @@
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, time, timedelta from datetime import date, datetime, timedelta
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -40,10 +40,6 @@ class ReportAllocationDraft:
reference_wage: float = 0 reference_wage: float = 0
def _midnight(value: date) -> datetime:
return datetime.combine(value, time.min)
def _add_minutes( def _add_minutes(
rows: dict[date, dict[str, float]], rows: dict[date, dict[str, float]],
allocation_date: date, allocation_date: date,
@ -55,18 +51,6 @@ def _add_minutes(
rows[allocation_date][bucket] += 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( def _range_allocation_minutes(
start_at: datetime, start_at: datetime,
end_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) minutes = max(0.0, minutes_between(overlap_start, overlap_end) - meal_minutes)
if bucket == "night": if bucket == "night":
_add_night_minutes(rows, overlap_start, overlap_end, current_date) _add_minutes(rows, current_date, "night", minutes)
else: else:
_add_minutes(rows, overlap_start.date(), bucket, minutes) _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.database import Base
from app.models import ProductionReport, ProductionReportAllocation, ProductionReportItem from app.models import ProductionReport, ProductionReportAllocation, ProductionReportItem
from app.services.metrics import calculate_report_metrics
from app.services.report_allocations import ( from app.services.report_allocations import (
allocation_summary_text, allocation_summary_text,
build_report_allocation_drafts, build_report_allocation_drafts,
refresh_report_allocations, refresh_report_allocations,
) )
from app.services.work_schedule import WorkScheduleConfig
@compiles(BigInteger, "sqlite") @compiles(BigInteger, "sqlite")
@ -28,12 +30,15 @@ def _item(
changeover_count: float = 0, changeover_count: float = 0,
process_unit_price_yuan: float = 0, process_unit_price_yuan: float = 0,
started_at: datetime | None = None, started_at: datetime | None = None,
device_no: str = "23#",
product_name: str | None = None,
process_name: str | None = "冲压",
): ):
return SimpleNamespace( return SimpleNamespace(
id=id, id=id,
device_no="23#", device_no=device_no,
product_name="23#", product_name=product_name or device_no,
process_name="冲压", process_name=process_name,
started_at=started_at, started_at=started_at,
standard_beat=1, standard_beat=1,
standard_workload=0, 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(): 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(