fix: 修正归属工时舍入残差
This commit is contained in:
parent
72acc16a09
commit
2084f834e3
@ -240,6 +240,58 @@ def _balance_minutes_to_targets(
|
|||||||
_scale_bucket_minutes(minutes_by_item, key, scale)
|
_scale_bucket_minutes(minutes_by_item, key, scale)
|
||||||
|
|
||||||
|
|
||||||
|
def _round_bucket_cells_to_targets(
|
||||||
|
minutes_by_item: dict[int, dict[date, dict[str, float]]],
|
||||||
|
target_buckets: dict[str, float],
|
||||||
|
item_order: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
for bucket in SHIFT_BUCKETS:
|
||||||
|
cells: list[tuple[float, int, int, date]] = []
|
||||||
|
for item_id, rows_by_date in minutes_by_item.items():
|
||||||
|
for allocation_date, minutes in rows_by_date.items():
|
||||||
|
value = minutes.get(bucket, 0.0)
|
||||||
|
if value > 0:
|
||||||
|
cents_value = max(0.0, value * 100)
|
||||||
|
floor_cents = int(cents_value + 0.000000001)
|
||||||
|
cells.append((cents_value - floor_cents, floor_cents, item_order.get(item_id, 0), allocation_date))
|
||||||
|
minutes[bucket] = floor_cents / 100
|
||||||
|
else:
|
||||||
|
minutes[bucket] = 0.0
|
||||||
|
|
||||||
|
target_cents = int(round(round2(target_buckets.get(bucket, 0.0)) * 100))
|
||||||
|
floor_total = sum(cell[1] for cell in cells)
|
||||||
|
residual = target_cents - floor_total
|
||||||
|
if not cells or residual == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ordered = sorted(
|
||||||
|
cells,
|
||||||
|
key=lambda cell: (-cell[0], cell[2], cell[3].isoformat()),
|
||||||
|
)
|
||||||
|
if residual < 0:
|
||||||
|
ordered = sorted(
|
||||||
|
cells,
|
||||||
|
key=lambda cell: (cell[0], cell[2], cell[3].isoformat()),
|
||||||
|
)
|
||||||
|
|
||||||
|
cents_by_cell: dict[tuple[int, date], int] = {
|
||||||
|
(cell[2], cell[3]): cell[1] for cell in cells
|
||||||
|
}
|
||||||
|
steps = abs(residual)
|
||||||
|
for index in range(steps):
|
||||||
|
_, _, item_index, allocation_date = ordered[index % len(ordered)]
|
||||||
|
key = (item_index, allocation_date)
|
||||||
|
if residual > 0:
|
||||||
|
cents_by_cell[key] += 1
|
||||||
|
elif cents_by_cell[key] > 0:
|
||||||
|
cents_by_cell[key] -= 1
|
||||||
|
|
||||||
|
item_id_by_order = {order: item_id for item_id, order in item_order.items()}
|
||||||
|
for (item_index, allocation_date), cents in cents_by_cell.items():
|
||||||
|
item_id = item_id_by_order[item_index]
|
||||||
|
minutes_by_item[item_id][allocation_date][bucket] = cents / 100
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@ -367,6 +419,11 @@ def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None =
|
|||||||
minutes_by_item = _range_minutes_by_item(report, items, schedule)
|
minutes_by_item = _range_minutes_by_item(report, items, schedule)
|
||||||
item_targets, bucket_targets = _allocation_targets(report, items, schedule)
|
item_targets, bucket_targets = _allocation_targets(report, items, schedule)
|
||||||
_balance_minutes_to_targets(minutes_by_item, item_targets, bucket_targets)
|
_balance_minutes_to_targets(minutes_by_item, item_targets, bucket_targets)
|
||||||
|
_round_bucket_cells_to_targets(
|
||||||
|
minutes_by_item,
|
||||||
|
bucket_targets,
|
||||||
|
{id(item): index for index, item in enumerate(items)},
|
||||||
|
)
|
||||||
|
|
||||||
rows: list[ReportAllocationDraft] = []
|
rows: list[ReportAllocationDraft] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|||||||
@ -154,6 +154,27 @@ def test_no_device_shared_full_report_splits_effective_minutes_between_items():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_shared_report_rounding_residual_keeps_aggregate_night_total():
|
||||||
|
start_at = datetime(2026, 5, 3, 1, 4, 50)
|
||||||
|
end_at = datetime(2026, 5, 3, 1, 4, 53)
|
||||||
|
first_item = _item(id=1, good_qty=1)
|
||||||
|
second_item = _item(id=2, good_qty=1)
|
||||||
|
report = _report(start_at, end_at, [first_item, second_item])
|
||||||
|
|
||||||
|
metrics = calculate_report_metrics(start_at, end_at, [first_item, second_item])
|
||||||
|
rows = build_report_allocation_drafts(report)
|
||||||
|
|
||||||
|
assert metrics["effective_minutes"] == 0.05
|
||||||
|
assert metrics["shift_night_minutes"] == 0.05
|
||||||
|
assert [row.allocation_date for row in rows] == [date(2026, 5, 2), date(2026, 5, 2)]
|
||||||
|
assert [row.effective_minutes for row in rows] == [0.03, 0.02]
|
||||||
|
assert round(sum(row.effective_minutes for row in rows), 2) == 0.05
|
||||||
|
assert round(sum(row.night_minutes for row in rows), 2) == 0.05
|
||||||
|
assert round(sum(row.day_minutes for row in rows), 2) == 0
|
||||||
|
assert round(sum(row.overtime_minutes for row in rows), 2) == 0
|
||||||
|
assert all(row.effective_minutes == round(row.day_minutes + row.overtime_minutes + row.night_minutes, 2) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
def test_items_sharing_same_started_at_split_the_segment_minutes():
|
def test_items_sharing_same_started_at_split_the_segment_minutes():
|
||||||
started_at = datetime(2026, 7, 25, 8, 0)
|
started_at = datetime(2026, 7, 25, 8, 0)
|
||||||
first_item = _item(id=1, good_qty=10, started_at=started_at)
|
first_item = _item(id=1, good_qty=10, started_at=started_at)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user