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 collections import defaultdict
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import date, datetime, timedelta 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 import delete, select
from sqlalchemy.orm import Session 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 from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig
SPLIT_QUANT = Decimal("0.01")
@dataclass(frozen=True) @dataclass(frozen=True)
class ReportAllocationDraft: class ReportAllocationDraft:
@ -126,8 +130,34 @@ def _report_devices(report) -> list | None:
return list(devices) if devices is not None else None return list(devices) if devices is not None else None
def _item_reference_wage(item) -> float: def _decimal_value(value: Any) -> Decimal:
return as_float(getattr(item, "good_qty", 0)) * as_float(getattr(item, "process_unit_price_yuan", 0)) 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): 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 ""), attendance_point_name=str(getattr(report, "attendance_point_name", "") or ""),
employee_phone=str(getattr(report, "employee_phone", "") or ""), employee_phone=str(getattr(report, "employee_phone", "") or ""),
allocation_date=getattr(report, "report_date"), allocation_date=getattr(report, "report_date"),
good_qty=round2(getattr(item, "good_qty", 0)), good_qty=_round_split_value(getattr(item, "good_qty", 0)),
defect_qty=round2(getattr(item, "defect_qty", 0)), defect_qty=_round_split_value(getattr(item, "defect_qty", 0)),
scrap_qty=round2(getattr(item, "scrap_qty", 0)), scrap_qty=_round_split_value(getattr(item, "scrap_qty", 0)),
changeover_count=round2(getattr(item, "changeover_count", 0)), changeover_count=_round_split_value(getattr(item, "changeover_count", 0)),
reference_wage=round2(_item_reference_wage(item)), 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, overtime_minutes=overtime_minutes,
night_minutes=night_minutes, night_minutes=night_minutes,
effective_minutes=round2(day_minutes + overtime_minutes + night_minutes), effective_minutes=round2(day_minutes + overtime_minutes + night_minutes),
good_qty=round2(as_float(getattr(item, "good_qty", 0)) * ratio), good_qty=_scaled_split_value(getattr(item, "good_qty", 0), ratio),
defect_qty=round2(as_float(getattr(item, "defect_qty", 0)) * ratio), defect_qty=_scaled_split_value(getattr(item, "defect_qty", 0), ratio),
scrap_qty=round2(as_float(getattr(item, "scrap_qty", 0)) * ratio), scrap_qty=_scaled_split_value(getattr(item, "scrap_qty", 0), ratio),
changeover_count=round2(as_float(getattr(item, "changeover_count", 0)) * ratio), changeover_count=_scaled_split_value(getattr(item, "changeover_count", 0), ratio),
reference_wage=round2(reference_wage * 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]: def _item_split_targets(item) -> dict[str, float]:
return { return {
"good_qty": round2(getattr(item, "good_qty", 0)), "good_qty": _round_split_value(getattr(item, "good_qty", 0)),
"defect_qty": round2(getattr(item, "defect_qty", 0)), "defect_qty": _round_split_value(getattr(item, "defect_qty", 0)),
"scrap_qty": round2(getattr(item, "scrap_qty", 0)), "scrap_qty": _round_split_value(getattr(item, "scrap_qty", 0)),
"changeover_count": round2(getattr(item, "changeover_count", 0)), "changeover_count": _round_split_value(getattr(item, "changeover_count", 0)),
"reference_wage": round2(_item_reference_wage(item)), "reference_wage": _round_split_value(_item_reference_wage(item)),
} }
@ -456,12 +486,12 @@ def _balance_item_split_residuals(
return drafts return drafts
mutable_values = [ 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 draft in drafts
] ]
for field in SPLIT_RESIDUAL_FIELDS: 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) current_cents = sum(values[field] for values in mutable_values)
residual = target_cents - current_cents residual = target_cents - current_cents
if residual == 0: 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 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(): 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)