fix: 统一报工归属金额舍入

This commit is contained in:
souplearn 2026-07-25 06:54:10 +08:00
parent 4c0cbc9815
commit 4db517ac49
2 changed files with 69 additions and 19 deletions

View File

@ -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:

View File

@ -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)