JhHardwareWRS_BackPoint/tests/test_report_allocations.py
2026-07-25 03:54:00 +08:00

453 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import date, datetime
from types import SimpleNamespace
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import ProductionReport, ProductionReportAllocation, ProductionReportItem
from app.services.metrics import calculate_report_metrics
from app.services.report_allocations import (
allocation_summary_text,
build_report_allocation_drafts,
refresh_report_allocations,
)
from app.services.work_schedule import WorkScheduleConfig
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw):
return "INTEGER"
def _item(
*,
id: int = 1,
good_qty: float = 0,
defect_qty: float = 0,
scrap_qty: float = 0,
changeover_count: float = 0,
process_unit_price_yuan: float = 0,
started_at: datetime | None = None,
device_no: str = "23#",
product_name: str | None = None,
process_name: str | None = "冲压",
):
return SimpleNamespace(
id=id,
device_no=device_no,
product_name=product_name or device_no,
process_name=process_name,
started_at=started_at,
standard_beat=1,
standard_workload=0,
stamping_method=None,
good_qty=good_qty,
defect_qty=defect_qty,
scrap_qty=scrap_qty,
changeover_count=changeover_count,
process_unit_price_yuan=process_unit_price_yuan,
allocated_minutes=0,
)
def _device(scanned_at: datetime, *, device_no: str = "23#", process_name: str = "冲压", sort_order: int = 0):
return SimpleNamespace(
device_no=device_no,
process_name=process_name,
scanned_at=scanned_at,
released_at=None,
release_reason=None,
sort_order=sort_order,
)
def _report(
start_at: datetime,
end_at: datetime,
item,
*,
id: int = 1,
report_date: date | None = None,
devices: list | None = None,
):
return SimpleNamespace(
id=id,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=report_date or start_at.date(),
start_at=start_at,
end_at=end_at,
items=item if isinstance(item, list) else [item],
session=SimpleNamespace(devices=devices or []),
)
def test_cross_day_after_midnight_allocates_previous_night_and_current_overtime_day():
item = _item(
good_qty=650,
defect_qty=65,
scrap_qty=6.5,
changeover_count=13,
process_unit_price_yuan=1,
)
report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert len(rows) == 2
previous, current = rows
assert previous.allocation_date == date(2026, 7, 24)
assert previous.night_minutes == 350
assert previous.day_minutes == 0
assert previous.overtime_minutes == 0
assert previous.effective_minutes == 350
assert previous.good_qty == 350
assert previous.defect_qty == 35
assert previous.scrap_qty == 3.5
assert previous.changeover_count == 7
assert previous.reference_wage == 350
assert current.allocation_date == date(2026, 7, 25)
assert current.night_minutes == 0
assert current.overtime_minutes == 120
assert current.day_minutes == 180
assert current.effective_minutes == 300
assert current.good_qty == 300
assert current.defect_qty == 30
assert current.scrap_qty == 3
assert current.changeover_count == 6
assert current.reference_wage == 300
def test_one_minute_before_night_end_allocates_to_previous_date():
item = _item(good_qty=301, process_unit_price_yuan=2)
report = _report(datetime(2026, 7, 25, 5, 59), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert [(row.allocation_date, row.night_minutes, row.overtime_minutes, row.day_minutes) for row in rows] == [
(date(2026, 7, 24), 1, 0, 0),
(date(2026, 7, 25), 0, 120, 180),
]
assert rows[0].good_qty == 1
assert rows[0].reference_wage == 2
assert rows[1].good_qty == 300
assert rows[1].reference_wage == 600
def test_no_device_shared_full_report_splits_effective_minutes_between_items():
first_item = _item(id=1, good_qty=10)
second_item = _item(id=2, good_qty=20)
report = _report(
datetime(2026, 7, 25, 8, 0),
datetime(2026, 7, 25, 11, 0),
[first_item, second_item],
)
rows = build_report_allocation_drafts(report)
assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [
(1, 90, 90),
(2, 90, 90),
]
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():
started_at = datetime(2026, 7, 25, 8, 0)
first_item = _item(id=1, good_qty=10, started_at=started_at)
second_item = _item(id=2, good_qty=20, started_at=started_at)
report = _report(
started_at,
datetime(2026, 7, 25, 11, 0),
[first_item, second_item],
devices=[_device(started_at)],
)
rows = build_report_allocation_drafts(report)
assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [
(1, 90, 90),
(2, 90, 90),
]
def test_device_matched_and_unmatched_items_match_metrics_remaining_minutes():
start_at = datetime(2026, 7, 25, 8, 0)
end_at = datetime(2026, 7, 25, 11, 0)
matching_item = _item(id=1, good_qty=10, device_no="23#")
unmatched_item = _item(id=2, good_qty=20, device_no="99#")
report = _report(start_at, end_at, [matching_item, unmatched_item], devices=[_device(start_at)])
calculate_report_metrics(start_at, end_at, [matching_item, unmatched_item], devices=[_device(start_at)])
rows = build_report_allocation_drafts(report)
assert {matching_item.id: matching_item.allocated_minutes, unmatched_item.id: unmatched_item.allocated_minutes} == {
1: 180,
2: 0,
}
assert {row.report_item_id: row.effective_minutes for row in rows} == {
1: 180,
2: 0,
}
def test_night_allocation_uses_meal_deducted_minutes_with_previous_date_rules():
schedule = WorkScheduleConfig(
day_start="08:00",
day_end="17:20",
lunch_start="11:40",
lunch_end="12:40",
dinner_start="21:00",
dinner_end="21:30",
overtime_start="18:00",
overtime_end="20:00",
night_start="20:00",
night_end="06:00",
)
start_at = datetime(2026, 7, 25, 20, 0)
end_at = datetime(2026, 7, 25, 22, 0)
item = _item(id=1, good_qty=90)
report = _report(start_at, end_at, item)
metrics = calculate_report_metrics(start_at, end_at, [item], schedule=schedule)
rows = build_report_allocation_drafts(report, schedule=schedule)
assert metrics["effective_minutes"] == 90
assert metrics["shift_night_minutes"] == 90
assert [(row.allocation_date, row.night_minutes, row.effective_minutes) for row in rows] == [
(date(2026, 7, 25), 90, 90),
]
def test_blank_overtime_meal_deduction_is_clipped_like_metrics():
start_at = datetime(2026, 7, 25, 6, 0)
end_at = datetime(2026, 7, 25, 19, 0)
item = _item(id=1, good_qty=680)
report = _report(start_at, end_at, item)
metrics = calculate_report_metrics(start_at, end_at, [item])
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 680
assert metrics["shift_day_minutes"] == 500
assert metrics["shift_overtime_minutes"] == 180
assert [(row.effective_minutes, row.day_minutes, row.overtime_minutes, row.night_minutes) for row in rows] == [
(680, 500, 180, 0),
]
def test_same_device_item_split_inside_meal_is_rescaled_to_metrics_effective_minutes():
start_at = datetime(2026, 7, 25, 8, 0)
second_started_at = datetime(2026, 7, 25, 12, 0)
end_at = datetime(2026, 7, 25, 13, 0)
first_item = _item(id=1, good_qty=192, started_at=start_at)
second_item = _item(id=2, good_qty=48, started_at=second_started_at)
report = _report(start_at, end_at, [first_item, second_item], devices=[_device(start_at)])
metrics = calculate_report_metrics(start_at, end_at, [first_item, second_item], devices=[_device(start_at)])
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 240
assert metrics["shift_day_minutes"] == 240
assert {first_item.id: first_item.allocated_minutes, second_item.id: second_item.allocated_minutes} == {
1: 192,
2: 48,
}
assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [
(1, 192, 192),
(2, 48, 48),
]
assert sum(row.effective_minutes for row in rows) == 240
def test_matched_meal_split_and_unmatched_device_range_follow_metrics_order():
start_at = datetime(2026, 7, 25, 8, 0)
second_started_at = datetime(2026, 7, 25, 12, 0)
switch_at = datetime(2026, 7, 25, 13, 0)
end_at = datetime(2026, 7, 25, 20, 0)
devices = [
_device(start_at, device_no="23#", sort_order=0),
_device(switch_at, device_no="77#", sort_order=1),
]
first_item = _item(id=1, good_qty=240, device_no="23#", started_at=start_at)
second_item = _item(id=2, good_qty=60, device_no="23#", started_at=second_started_at)
unmatched_item = _item(id=3, good_qty=320, device_no="99#")
items = [first_item, second_item, unmatched_item]
report = _report(start_at, end_at, items, devices=devices)
metrics = calculate_report_metrics(start_at, end_at, items, devices=devices)
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 620
assert {item.id: item.allocated_minutes for item in items} == {
1: 240,
2: 60,
3: 320,
}
assert {
item_id: round(sum(row.effective_minutes for row in rows if row.report_item_id == item_id), 2)
for item_id in [1, 2, 3]
} == {
1: 240,
2: 60,
3: 320,
}
assert round(sum(row.effective_minutes for row in rows), 2) == metrics["effective_minutes"]
def test_item_totals_anchor_to_metrics_when_unmatched_remaining_buckets_exceed_item_target():
start_at = datetime(2026, 7, 25, 8, 0)
second_started_at = datetime(2026, 7, 25, 12, 0)
switch_at = datetime(2026, 7, 25, 18, 0)
end_at = datetime(2026, 7, 25, 20, 0)
devices = [
_device(start_at, device_no="23#", sort_order=0),
_device(switch_at, device_no="77#", sort_order=1),
]
first_item = _item(id=1, good_qty=240, device_no="23#", started_at=start_at)
second_item = _item(id=2, good_qty=360, device_no="23#", started_at=second_started_at)
unmatched_item = _item(id=3, good_qty=60, device_no="99#")
items = [first_item, second_item, unmatched_item]
report = _report(start_at, end_at, items, devices=devices)
metrics = calculate_report_metrics(start_at, end_at, items, devices=devices)
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 660
assert {item.id: item.allocated_minutes for item in items} == {
1: 240,
2: 360,
3: 60,
}
assert {
item_id: round(sum(row.effective_minutes for row in rows if row.report_item_id == item_id), 2)
for item_id in [1, 2, 3]
} == {
1: 240,
2: 360,
3: 60,
}
assert {
"day": round(sum(row.day_minutes for row in rows), 2),
"overtime": round(sum(row.overtime_minutes for row in rows), 2),
"night": round(sum(row.night_minutes for row in rows), 2),
} == {
"day": metrics["shift_day_minutes"],
"overtime": metrics["shift_overtime_minutes"],
"night": metrics["shift_night_minutes"],
}
assert round(sum(row.effective_minutes for row in rows), 2) == 660
assert all(row.effective_minutes == row.day_minutes + row.overtime_minutes + row.night_minutes for row in rows)
def test_zero_effective_minutes_falls_back_to_original_report_date():
item = _item(good_qty=5, defect_qty=1, scrap_qty=0.5, changeover_count=2, process_unit_price_yuan=3)
report = _report(
datetime(2026, 7, 25, 9, 0),
datetime(2026, 7, 25, 9, 0),
item,
report_date=date(2026, 7, 25),
)
rows = build_report_allocation_drafts(report)
assert len(rows) == 1
row = rows[0]
assert row.allocation_date == date(2026, 7, 25)
assert row.effective_minutes == 0
assert row.day_minutes == 0
assert row.overtime_minutes == 0
assert row.night_minutes == 0
assert row.good_qty == 5
assert row.defect_qty == 1
assert row.scrap_qty == 0.5
assert row.changeover_count == 2
assert row.reference_wage == 15
def test_allocation_summary_text_groups_dates_and_shift_kinds():
item = _item(good_qty=650, process_unit_price_yuan=1)
report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert allocation_summary_text(rows) == "2026-07-24 夜班5.83小时2026-07-25 白班3小时、加班2小时"
def test_refresh_report_allocations_replaces_existing_rows_and_flushes():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, future=True)
db = SessionLocal()
try:
report = ProductionReport(
id=1,
session_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=date(2026, 7, 25),
start_at=datetime(2026, 7, 25, 5, 59),
end_at=datetime(2026, 7, 25, 11, 0),
)
item = ProductionReportItem(
id=1,
report_id=1,
attendance_point_name="总厂",
device_no="23#",
project_no="P1",
product_name="23#",
process_name="冲压",
process_unit_price_yuan=2,
good_qty=301,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
standard_beat=1,
standard_workload=0,
)
stale = ProductionReportAllocation(
id=99,
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 20),
effective_minutes=1,
)
report.items.append(item)
db.add_all([report, stale])
db.flush()
rows = refresh_report_allocations(db, report)
assert len(rows) == 2
assert all(row.id is not None for row in rows)
stored = db.scalars(select(ProductionReportAllocation).order_by(ProductionReportAllocation.allocation_date)).all()
assert [row.allocation_date for row in stored] == [date(2026, 7, 24), date(2026, 7, 25)]
assert [row.good_qty for row in stored] == [1, 300]
finally:
db.close()