diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 8f39a43..d723133 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -192,6 +192,7 @@ def _dashboard_rows( "product_name": item.product_name, "process_name": process_name, "report_ids": set(), + "source_report_dates": set(), "effective_minutes": 0.0, "shift_day_minutes": 0.0, "shift_overtime_minutes": 0.0, @@ -230,6 +231,7 @@ def _dashboard_rows( allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes) expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0 group["report_ids"].add(report.id) + group["source_report_dates"].add(report.report_date) group["effective_minutes"] += allocated_minutes group["expected_workload"] += expected_workload group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0)) @@ -277,11 +279,14 @@ def _dashboard_rows( "overtime": group["shift_overtime_minutes"], "night": group["shift_night_minutes"], } + source_report_dates = sorted(group["source_report_dates"]) rows.append( DashboardRow( id=group["id"], attendance_point_name=group["attendance_point_name"], report_date=group["report_date"], + source_report_date=source_report_dates[-1] if source_report_dates else group["report_date"], + source_report_dates=source_report_dates, employee_phone=group["employee_phone"], employee_name=group["employee_name"], worker_type=group["worker_type"], diff --git a/app/services/serializers.py b/app/services/serializers.py index 57dbf50..25c1dce 100644 --- a/app/services/serializers.py +++ b/app/services/serializers.py @@ -1,7 +1,9 @@ -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import select +from sqlalchemy import inspect, select +from sqlalchemy.exc import NoInspectionAvailable from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import NO_VALUE from app.models import ( AttendancePoint, @@ -358,7 +360,31 @@ def report_allocation_out(row) -> ReportAllocationOut: ) +def _loaded_relationship_rows(obj, relationship_name: str) -> list: + try: + state = inspect(obj) + except NoInspectionAvailable: + return list(getattr(obj, relationship_name, []) or []) + if relationship_name not in state.attrs: + return list(getattr(obj, relationship_name, []) or []) + relationship_state = state.attrs[relationship_name] + if relationship_state.loaded_value is NO_VALUE: + return [] + return list(relationship_state.value or []) + + +def _sorted_allocation_rows(rows) -> list: + return sorted( + list(rows or []), + key=lambda row: ( + getattr(row, "allocation_date", None) or date.min, + getattr(row, "id", None) or 0, + ), + ) + + def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut: + allocation_rows = _sorted_allocation_rows(_loaded_relationship_rows(item, "allocations")) return ReportItemOut( id=item.id, attendance_point_name=item.attendance_point_name, @@ -384,7 +410,7 @@ def report_item_out(item: ProductionReportItem, corrections: dict | None = None) is_continuous_die=is_continuous_die_item(item), is_multi_person=is_multi_person_item(item), remark=item.remark, - allocations=[report_allocation_out(row) for row in getattr(item, "allocations", []) or []], + allocations=[report_allocation_out(row) for row in allocation_rows], corrections=corrections or {}, ) @@ -423,6 +449,7 @@ def _cleaning_report_metrics(report: ProductionReport) -> dict: def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut: + allocation_rows = _sorted_allocation_rows(_loaded_relationship_rows(report, "allocations")) is_cleaning_report = _report_is_cleaning(report) calculated_metrics = ( _cleaning_report_metrics(report) @@ -504,6 +531,6 @@ def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = N if is_cleaning_report else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics)) ), - allocation_summary_text=allocation_summary_text(list(getattr(report, "allocations", []) or [])), + allocation_summary_text=allocation_summary_text(allocation_rows), corrections=report_corrections, ) diff --git a/tests/test_dashboard_source_dates.py b/tests/test_dashboard_source_dates.py new file mode 100644 index 0000000..8be861f --- /dev/null +++ b/tests/test_dashboard_source_dates.py @@ -0,0 +1,105 @@ +from datetime import date, datetime + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + +from app.database import Base +from app.models import ( + AttendancePoint, + Personnel, + Product, + ProductionReport, + ProductionReportItem, + ReportStatus, + SessionStatus, + WorkSession, +) +from app.routers.dashboard import _dashboard_rows + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw): + return "INTEGER" + + +def _db_session(): + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine, future=True) + return SessionLocal() + + +def test_dashboard_rows_populate_source_report_dates_from_original_reports(): + db = _db_session() + try: + point_name = "总厂" + report_date = date(2026, 7, 25) + person = Personnel(phone="13800000000", name="张三") + product = Product( + attendance_point_name=point_name, + project_no="P1", + product_name="23#", + device_no="", + process_name="冲压", + operator_count=1, + process_unit_price_yuan=2, + standard_beat=1, + standard_workload=0, + ) + db.add_all([ + AttendancePoint(name=point_name), + person, + product, + ]) + for index, start_hour in enumerate([8, 10], start=1): + session = WorkSession( + id=index, + attendance_point_name=point_name, + employee_phone=person.phone, + start_at=datetime(2026, 7, 25, start_hour, 0), + end_at=datetime(2026, 7, 25, start_hour + 1, 0), + status=SessionStatus.submitted, + ) + report = ProductionReport( + id=index, + session_id=session.id, + attendance_point_name=point_name, + employee_phone=person.phone, + report_date=report_date, + start_at=session.start_at, + end_at=session.end_at, + duration_minutes=60, + break_minutes=0, + effective_minutes=60, + status=ReportStatus.approved, + submitted_at=session.end_at, + ) + item = ProductionReportItem( + id=index, + report_id=report.id, + attendance_point_name=point_name, + device_no="23#", + project_no=product.project_no, + product_name=product.product_name, + process_name=product.process_name, + operator_count=1, + process_unit_price_yuan=2, + standard_beat=1, + standard_workload=0, + good_qty=10, + defect_qty=0, + scrap_qty=0, + allocated_minutes=60, + ) + db.add_all([session, report, item]) + db.commit() + + rows = _dashboard_rows(db, report_date, report_date) + + assert len(rows) == 1 + assert rows[0].report_count == 2 + assert rows[0].source_report_date == report_date + assert rows[0].source_report_dates == [report_date] + finally: + db.close() diff --git a/tests/test_report_allocations.py b/tests/test_report_allocations.py index 917d9a3..a5be109 100644 --- a/tests/test_report_allocations.py +++ b/tests/test_report_allocations.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from sqlalchemy import BigInteger, create_engine, select from sqlalchemy.ext.compiler import compiles -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import raiseload, selectinload, sessionmaker from app.database import Base from app.models import ( @@ -511,16 +511,33 @@ def test_report_out_exposes_allocation_summary_and_item_allocations(): changeover_count=Decimal("1.50"), reference_wage=Decimal("251.25"), ) - item.allocations = [allocation] + previous_allocation = ProductionReportAllocation( + id=2, + report_id=1, + report_item_id=1, + attendance_point_name="总厂", + employee_phone="13800000000", + allocation_date=date(2026, 7, 24), + day_minutes=Decimal("0.00"), + overtime_minutes=Decimal("0.00"), + night_minutes=Decimal("60.00"), + effective_minutes=Decimal("60.00"), + good_qty=Decimal("40.00"), + defect_qty=Decimal("0.00"), + scrap_qty=Decimal("0.00"), + changeover_count=Decimal("0.00"), + reference_wage=Decimal("100.00"), + ) + item.allocations = [allocation, previous_allocation] report.items = [item] - report.allocations = [allocation] + report.allocations = [allocation, previous_allocation] report.audit_logs = [] output = report_out(report) - assert output.allocation_summary_text == "2026-07-25 白班2.01小时、加班0.5小时" - assert len(output.items[0].allocations) == 1 - item_allocation = output.items[0].allocations[0] + assert output.allocation_summary_text == "2026-07-24 夜班1小时;2026-07-25 白班2.01小时、加班0.5小时" + assert [row.allocation_date for row in output.items[0].allocations] == [date(2026, 7, 24), date(2026, 7, 25)] + item_allocation = output.items[0].allocations[1] assert item_allocation.allocation_date == date(2026, 7, 25) assert item_allocation.day_minutes == 120.5 assert item_allocation.overtime_minutes == 30.25 @@ -643,6 +660,81 @@ def _sqlite_db(): return SessionLocal() +def test_report_out_skips_unloaded_allocations_without_lazy_load(): + db = _sqlite_db() + try: + person = Personnel(phone="13800000000", name="张三") + session = WorkSession( + id=1, + attendance_point_name="总厂", + employee_phone=person.phone, + start_at=datetime(2026, 7, 25, 8, 0), + end_at=datetime(2026, 7, 25, 10, 0), + status=SessionStatus.submitted, + ) + report = ProductionReport( + id=1, + session_id=session.id, + attendance_point_name="总厂", + employee_phone=person.phone, + report_date=date(2026, 7, 25), + start_at=datetime(2026, 7, 25, 8, 0), + end_at=datetime(2026, 7, 25, 10, 0), + break_minutes=0, + status=ReportStatus.pending, + submitted_at=datetime(2026, 7, 25, 10, 1), + ) + item = ProductionReportItem( + id=1, + report_id=report.id, + attendance_point_name="总厂", + device_no="23#", + project_no="P1", + product_name="23#", + process_name="冲压", + process_unit_price_yuan=1, + good_qty=100, + defect_qty=0, + scrap_qty=0, + changeover_count=0, + standard_beat=1, + standard_workload=0, + ) + allocation = ProductionReportAllocation( + id=1, + report_id=report.id, + report_item_id=item.id, + attendance_point_name="总厂", + employee_phone=person.phone, + allocation_date=date(2026, 7, 25), + day_minutes=120, + effective_minutes=120, + good_qty=100, + reference_wage=100, + ) + db.add_all([person, session, report, item, allocation]) + db.commit() + + loaded_report = db.scalar( + select(ProductionReport) + .options( + selectinload(ProductionReport.employee), + selectinload(ProductionReport.items).raiseload(ProductionReportItem.allocations), + selectinload(ProductionReport.audit_logs), + selectinload(ProductionReport.session).selectinload(WorkSession.devices), + raiseload(ProductionReport.allocations), + ) + .where(ProductionReport.id == report.id) + ) + + output = report_out(loaded_report) + + assert output.allocation_summary_text == "" + assert output.items[0].allocations == [] + finally: + db.close() + + def _product( *, point_name: str = "总厂",