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): return SimpleNamespace( device_no="23#", process_name="冲压", scanned_at=scanned_at, released_at=None, release_reason=None, sort_order=0, ) 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_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_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()