diff --git a/app/services/report_allocations.py b/app/services/report_allocations.py index 87141de..318383a 100644 --- a/app/services/report_allocations.py +++ b/app/services/report_allocations.py @@ -1,6 +1,8 @@ from collections import defaultdict from dataclasses import dataclass, replace from datetime import date, datetime, timedelta +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Any from sqlalchemy import delete, select from sqlalchemy.orm import Session @@ -24,6 +26,8 @@ from app.services.metrics import ( ) from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig +SPLIT_QUANT = Decimal("0.01") + @dataclass(frozen=True) class ReportAllocationDraft: @@ -126,8 +130,34 @@ def _report_devices(report) -> list | None: return list(devices) if devices is not None else None -def _item_reference_wage(item) -> float: - return as_float(getattr(item, "good_qty", 0)) * as_float(getattr(item, "process_unit_price_yuan", 0)) +def _decimal_value(value: Any) -> Decimal: + if value is None: + return Decimal("0") + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return Decimal("0") + + +def _round_split_value(value: Any) -> float: + return float(_decimal_value(value).quantize(SPLIT_QUANT, rounding=ROUND_HALF_UP)) + + +def _split_cents(value: Any) -> int: + rounded = _decimal_value(value).quantize(SPLIT_QUANT, rounding=ROUND_HALF_UP) + return int(rounded * 100) + + +def _scaled_split_value(value: Any, ratio: float) -> float: + return _round_split_value(_decimal_value(value) * _decimal_value(ratio)) + + +def _item_reference_wage(item) -> Decimal: + return _decimal_value(getattr(item, "good_qty", 0)) * _decimal_value( + getattr(item, "process_unit_price_yuan", 0) + ) def _allocation_targets(report, items: list, schedule: WorkScheduleConfig | None = None): @@ -398,11 +428,11 @@ def _fallback_row(report, item) -> ReportAllocationDraft: attendance_point_name=str(getattr(report, "attendance_point_name", "") or ""), employee_phone=str(getattr(report, "employee_phone", "") or ""), allocation_date=getattr(report, "report_date"), - good_qty=round2(getattr(item, "good_qty", 0)), - defect_qty=round2(getattr(item, "defect_qty", 0)), - scrap_qty=round2(getattr(item, "scrap_qty", 0)), - changeover_count=round2(getattr(item, "changeover_count", 0)), - reference_wage=round2(_item_reference_wage(item)), + good_qty=_round_split_value(getattr(item, "good_qty", 0)), + defect_qty=_round_split_value(getattr(item, "defect_qty", 0)), + scrap_qty=_round_split_value(getattr(item, "scrap_qty", 0)), + changeover_count=_round_split_value(getattr(item, "changeover_count", 0)), + reference_wage=_round_split_value(_item_reference_wage(item)), ) @@ -421,11 +451,11 @@ def _draft_from_minutes(report, item, allocation_date: date, minutes: dict[str, overtime_minutes=overtime_minutes, night_minutes=night_minutes, effective_minutes=round2(day_minutes + overtime_minutes + night_minutes), - good_qty=round2(as_float(getattr(item, "good_qty", 0)) * ratio), - defect_qty=round2(as_float(getattr(item, "defect_qty", 0)) * ratio), - scrap_qty=round2(as_float(getattr(item, "scrap_qty", 0)) * ratio), - changeover_count=round2(as_float(getattr(item, "changeover_count", 0)) * ratio), - reference_wage=round2(reference_wage * ratio), + good_qty=_scaled_split_value(getattr(item, "good_qty", 0), ratio), + defect_qty=_scaled_split_value(getattr(item, "defect_qty", 0), ratio), + scrap_qty=_scaled_split_value(getattr(item, "scrap_qty", 0), ratio), + changeover_count=_scaled_split_value(getattr(item, "changeover_count", 0), ratio), + reference_wage=_round_split_value(reference_wage * _decimal_value(ratio)), ) @@ -440,11 +470,11 @@ SPLIT_RESIDUAL_FIELDS = ( def _item_split_targets(item) -> dict[str, float]: return { - "good_qty": round2(getattr(item, "good_qty", 0)), - "defect_qty": round2(getattr(item, "defect_qty", 0)), - "scrap_qty": round2(getattr(item, "scrap_qty", 0)), - "changeover_count": round2(getattr(item, "changeover_count", 0)), - "reference_wage": round2(_item_reference_wage(item)), + "good_qty": _round_split_value(getattr(item, "good_qty", 0)), + "defect_qty": _round_split_value(getattr(item, "defect_qty", 0)), + "scrap_qty": _round_split_value(getattr(item, "scrap_qty", 0)), + "changeover_count": _round_split_value(getattr(item, "changeover_count", 0)), + "reference_wage": _round_split_value(_item_reference_wage(item)), } @@ -456,12 +486,12 @@ def _balance_item_split_residuals( return drafts mutable_values = [ - {field: int(round(as_float(getattr(draft, field)) * 100)) for field in SPLIT_RESIDUAL_FIELDS} + {field: _split_cents(getattr(draft, field)) for field in SPLIT_RESIDUAL_FIELDS} for draft in drafts ] for field in SPLIT_RESIDUAL_FIELDS: - target_cents = int(round(round2(targets[field]) * 100)) + target_cents = _split_cents(targets[field]) current_cents = sum(values[field] for values in mutable_values) residual = target_cents - current_cents if residual == 0: diff --git a/tests/test_report_allocations.py b/tests/test_report_allocations.py index ceee5bf..8b3cbe4 100644 --- a/tests/test_report_allocations.py +++ b/tests/test_report_allocations.py @@ -230,6 +230,26 @@ def test_item_split_fields_keep_original_totals_after_rounding_residual(): assert round(sum(row.reference_wage for row in rows), 2) == 1 +def test_reference_wage_uses_half_up_rounding_for_allocation_totals(): + item = _item(good_qty=115, process_unit_price_yuan=Decimal("0.867")) + report = _report(datetime(2026, 7, 25, 8, 0), datetime(2026, 7, 25, 10, 0), item) + + rows = build_report_allocation_drafts(report) + + assert len(rows) == 1 + assert rows[0].reference_wage == 99.71 + + +def test_split_reference_wage_residual_keeps_half_up_item_total(): + item = _item(good_qty=115, process_unit_price_yuan=Decimal("0.867")) + report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item) + + rows = build_report_allocation_drafts(report) + + assert [row.allocation_date for row in rows] == [date(2026, 7, 24), date(2026, 7, 25)] + assert round(sum(row.reference_wage for row in rows), 2) == 99.71 + + def test_items_sharing_same_started_at_split_the_segment_minutes(): started_at = datetime(2026, 7, 25, 8, 0) first_item = _item(id=1, good_qty=10, started_at=started_at)