fix: 对齐报工归属有效工时分配
This commit is contained in:
parent
e4dd01bc72
commit
63ea6fdcc5
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user