fix: 统一报工归属金额舍入
This commit is contained in:
parent
4c0cbc9815
commit
4db517ac49
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user