diff --git a/app/routers/reports.py b/app/routers/reports.py index a78b0bf..f912255 100644 --- a/app/routers/reports.py +++ b/app/routers/reports.py @@ -39,6 +39,7 @@ from app.services.display_names import mold_process_display_name from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time from app.services.misc_work import is_misc_product from app.services.mold_locks import release_session_locks +from app.services.report_allocations import refresh_report_allocations from app.services.report_lifecycle import purge_expired_voided_reports from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired from app.services.serializers import equipment_option, product_option, report_out @@ -456,6 +457,8 @@ def submit_report( release_session_locks(session, user.phone, "报工提交释放占用", submitted_at) session.status = SessionStatus.submitted db.add(report) + db.flush() + refresh_report_allocations(db, report, schedule=schedule, commit=False) db.commit() saved = _get_report_or_404(db, report.id) @@ -602,10 +605,13 @@ def submit_cleaning_report( items=report_items, ) db.add(report) + schedule = get_work_schedule_config(db, point_name) + db.flush() + refresh_report_allocations(db, report, schedule=schedule, commit=False) db.commit() saved = _get_report_or_404(db, report.id) - return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name)) + return report_out(saved, schedule) @router.get("/mine", response_model=PageResponse) diff --git a/app/routers/reviews.py b/app/routers/reviews.py index d3def65..4d8a55a 100644 --- a/app/routers/reviews.py +++ b/app/routers/reviews.py @@ -29,6 +29,7 @@ from app.services.continuous_die import is_continuous_die_item, is_continuous_di from app.services.display_names import mold_process_display_name from app.services.metrics import calculate_report_metrics from app.services.misc_work import is_misc_item, is_misc_product +from app.services.report_allocations import refresh_report_allocations from app.services.report_lifecycle import ( can_edit_report, mark_report_voided, @@ -528,6 +529,7 @@ def approve_report( _validate_normal_report_devices(db, report) _apply_metrics(report, schedule) + refresh_report_allocations(db, report, schedule=schedule, commit=False) reviewed_at = now() was_pending = report.status == ReportStatus.pending report.status = ReportStatus.approved diff --git a/app/services/auto_submit.py b/app/services/auto_submit.py index 5a3a02f..99f4a14 100644 --- a/app/services/auto_submit.py +++ b/app/services/auto_submit.py @@ -18,6 +18,7 @@ from app.models import ( from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time from app.services.misc_work import is_misc_product from app.services.mold_locks import release_session_locks +from app.services.report_allocations import refresh_report_allocations from app.services.work_schedule import DEFAULT_AUTO_SUBMIT_HOURS, get_work_schedule, get_work_schedule_config from app.timezone import now @@ -171,6 +172,8 @@ def auto_submit_session(db: Session, session: WorkSession, submitted_at: datetim items=items, ) db.add(report) + db.flush() + refresh_report_allocations(db, report, schedule=schedule, commit=False) return report diff --git a/app/services/report_lifecycle.py b/app/services/report_lifecycle.py index 9194f60..747c28a 100644 --- a/app/services/report_lifecycle.py +++ b/app/services/report_lifecycle.py @@ -8,6 +8,7 @@ from app.models import ( MoldLockFeedback, Personnel, ProductionReport, + ProductionReportAllocation, ProductionReportItem, ReportAuditLog, WorkSession, @@ -86,6 +87,7 @@ def purge_expired_voided_reports(db: Session) -> int: ) ) db.execute(delete(ReportAuditLog).where(ReportAuditLog.report_id == report.id)) + db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == report.id)) db.execute(delete(ProductionReportItem).where(ProductionReportItem.report_id == report.id)) db.delete(report) if session is not None: diff --git a/tests/test_report_allocations.py b/tests/test_report_allocations.py index af977cd..39ffa1b 100644 --- a/tests/test_report_allocations.py +++ b/tests/test_report_allocations.py @@ -6,7 +6,32 @@ 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.models import ( + AttendancePoint, + Equipment, + Product, + ProductionReport, + ProductionReportAllocation, + ProductionReportItem, + ReportStatus, + Role, + SessionStatus, + WorkSession, + WorkSessionDevice, +) +from app.routers import reports as reports_router +from app.routers import reviews as reviews_router +from app.schemas import ( + ApproveReportRequest, + CleaningReportSubmitItem, + CleaningReportSubmitRequest, + ReportItemCorrection, + ReportSubmitBlock, + ReportSubmitItem, + ReportSubmitRequest, +) +from app.services import auto_submit as auto_submit_service +from app.services.report_lifecycle import purge_expired_voided_reports from app.services.metrics import calculate_report_metrics from app.services.report_allocations import ( allocation_summary_text, @@ -481,3 +506,290 @@ def test_refresh_report_allocations_replaces_existing_rows_and_flushes(): assert [row.good_qty for row in stored] == [1, 300] finally: db.close() + + +def _sqlite_db(): + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine, future=True) + return SessionLocal() + + +def _product( + *, + point_name: str = "总厂", + project_no: str = "P1", + product_name: str = "23#", + process_name: str = "冲压", + stamping_method: str | None = None, +): + return Product( + attendance_point_name=point_name, + project_no=project_no, + product_name=product_name, + device_no="", + process_name=process_name, + stamping_method=stamping_method, + operator_count=1, + process_unit_price_yuan=2, + standard_beat=1, + standard_workload=0, + ) + + +def _press_equipment(point_name: str = "总厂", device_no: str = "23#"): + return Equipment( + attendance_point_name=point_name, + device_no=device_no, + device_type="冲压设备", + ) + + +def _session_with_device( + *, + point_name: str = "总厂", + employee_phone: str = "13800000000", + start_at: datetime = datetime(2026, 7, 25, 8, 0), + end_at: datetime = datetime(2026, 7, 25, 11, 0), +): + return WorkSession( + attendance_point_name=point_name, + employee_phone=employee_phone, + start_at=start_at, + end_at=end_at, + status=SessionStatus.reporting, + devices=[ + WorkSessionDevice( + attendance_point_name=point_name, + device_no="23#", + process_name="冲压", + scanned_at=start_at, + sort_order=0, + ) + ], + ) + + +def _stored_allocations(db, report_id: int): + return db.scalars( + select(ProductionReportAllocation).where(ProductionReportAllocation.report_id == report_id) + ).all() + + +def test_submit_report_creates_allocations(monkeypatch): + db = _sqlite_db() + try: + user = SimpleNamespace(phone="13800000000") + session = _session_with_device(employee_phone=user.phone) + db.add_all([session, _press_equipment(), _product()]) + db.commit() + monkeypatch.setattr(reports_router, "report_out", lambda report, schedule=None: report) + + report = reports_router.submit_report( + ReportSubmitRequest( + session_id=session.id, + device_blocks=[ + ReportSubmitBlock( + device_no="23#", + process_name="冲压", + items=[ + ReportSubmitItem( + device_no="23#", + project_no="P1", + product_name="23#", + process_name="冲压", + standard_beat=1, + good_qty=180, + ) + ], + ) + ], + ), + user=user, + db=db, + ) + + allocations = _stored_allocations(db, report.id) + assert len(allocations) == 1 + assert allocations[0].report_item_id == report.items[0].id + assert allocations[0].effective_minutes == 180 + assert allocations[0].good_qty == 180 + finally: + db.close() + + +def test_submit_cleaning_report_creates_zero_minute_allocation(monkeypatch): + db = _sqlite_db() + try: + point_name = "总厂" + user = SimpleNamespace(phone="13800000000", role=Role.manager) + db.add_all( + [ + AttendancePoint(name=point_name, latitude=0, longitude=0, radius_meters=500, is_active=True), + Equipment(attendance_point_name=point_name, device_no="C1", device_type="清洗设备"), + _product( + point_name=point_name, + project_no="CLEAN1", + product_name="清洗产品", + process_name="清洗", + stamping_method="清洗", + ), + ] + ) + db.commit() + monkeypatch.setattr(reports_router, "report_out", lambda report, schedule=None: report) + + report = reports_router.submit_cleaning_report( + CleaningReportSubmitRequest( + latitude=0, + longitude=0, + items=[ + CleaningReportSubmitItem( + attendance_point_name=point_name, + device_no="C1", + product_name="清洗产品", + process_name="清洗", + quantity=25, + ) + ], + ), + user=user, + db=db, + ) + + allocations = _stored_allocations(db, report.id) + assert len(allocations) == 1 + assert allocations[0].effective_minutes == 0 + assert allocations[0].good_qty == 25 + finally: + db.close() + + +def test_auto_submit_session_creates_allocations(): + db = _sqlite_db() + try: + session = _session_with_device( + start_at=datetime(2026, 7, 25, 8, 0), + end_at=None, + ) + session.status = SessionStatus.active + db.add_all([session, _product()]) + db.commit() + + report = auto_submit_service.auto_submit_session( + db, + session, + submitted_at=datetime(2026, 7, 25, 11, 0), + ) + db.flush() + + allocations = _stored_allocations(db, report.id) + assert len(allocations) == 1 + assert allocations[0].report_item_id == report.items[0].id + assert allocations[0].effective_minutes == 180 + finally: + db.close() + + +def test_approve_report_refreshes_allocations_after_edit_save(monkeypatch): + db = _sqlite_db() + try: + user = SimpleNamespace(phone="13900000000", role=Role.admin) + session = _session_with_device() + session.status = SessionStatus.submitted + report = ProductionReport( + attendance_point_name="总厂", + session=session, + employee_phone="13800000000", + report_date=date(2026, 7, 25), + start_at=datetime(2026, 7, 25, 8, 0), + end_at=datetime(2026, 7, 25, 11, 0), + duration_minutes=180, + effective_minutes=180, + total_good_qty=100, + total_output_qty=100, + actual_beat=1.8, + standard_beat=1, + expected_workload=100, + pace_rate=100, + workload_rate=100, + status=ReportStatus.pending, + submitted_at=datetime(2026, 7, 25, 11, 1), + items=[ + ProductionReportItem( + attendance_point_name="总厂", + device_no="23#", + project_no="P1", + product_name="23#", + process_name="冲压", + process_unit_price_yuan=2, + standard_beat=1, + standard_workload=0, + good_qty=100, + defect_qty=0, + scrap_qty=0, + ) + ], + ) + db.add_all([_press_equipment(), _product(), report]) + db.flush() + db.add( + ProductionReportAllocation( + report_id=report.id, + report_item_id=report.items[0].id, + attendance_point_name="总厂", + employee_phone="13800000000", + allocation_date=date(2026, 7, 20), + effective_minutes=1, + good_qty=1, + ) + ) + db.commit() + monkeypatch.setattr(reviews_router, "accessible_point_names", lambda _db, _user: ["总厂"]) + monkeypatch.setattr(reviews_router, "report_out", lambda report, schedule=None: report) + + saved = reviews_router.approve_report( + report.id, + ApproveReportRequest( + item_corrections=[ + ReportItemCorrection(id=report.items[0].id, good_qty=200) + ] + ), + user=user, + db=db, + ) + + allocations = _stored_allocations(db, saved.id) + assert len(allocations) == 1 + assert allocations[0].allocation_date == date(2026, 7, 25) + assert allocations[0].good_qty == 200 + assert allocations[0].reference_wage == 400 + finally: + db.close() + + +def test_purge_expired_voided_reports_deletes_allocations_before_report_items(): + report = SimpleNamespace(id=1, session=None) + operations: list[str] = [] + + class _Rows: + def all(self): + return [report] + + class _FakeDb: + def scalars(self, _statement): + return _Rows() + + def execute(self, statement): + operations.append(statement.table.name) + + def delete(self, _obj): + operations.append("delete-report") + + def commit(self): + operations.append("commit") + + purged_count = purge_expired_voided_reports(_FakeDb()) + + assert purged_count == 1 + assert operations.index("production_report_allocations") < operations.index("production_report_items")