diff --git a/app_new/c4/routes.py b/app_new/c4/routes.py index 207aa8d..b86b87b 100644 --- a/app_new/c4/routes.py +++ b/app_new/c4/routes.py @@ -3,6 +3,7 @@ from datetime import date from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required +from sqlalchemy.orm import joinedload, selectinload from ..core.authorization import permission_required from ..core.models import ( @@ -67,10 +68,14 @@ def new_contract(): @login_required @permission_required("contract.view") def contract_detail(contract_id): - contract = PhotocopierContract.query.get_or_404(contract_id) + contract = (PhotocopierContract.query.options( + joinedload(PhotocopierContract.supplier), + selectinload(PhotocopierContract.periods), + selectinload(PhotocopierContract.assignments).selectinload(ContractEquipmentAssignment.equipment), + ).get_or_404(contract_id)) periods = contract.periods - data = {period.id: calculate_contract_usage(period) for period in periods} projections = {period.id: project_contract_usage(period) for period in periods} + data = {period.id: projections[period.id]["usage"] for period in periods} return render_template("c4/contract_detail.html", contract=contract, periods=periods, usage=data, projections=projections) diff --git a/app_new/core/services/c4_service.py b/app_new/core/services/c4_service.py index b86f179..29bf0a1 100644 --- a/app_new/core/services/c4_service.py +++ b/app_new/core/services/c4_service.py @@ -133,6 +133,24 @@ def _meter_consumption(meter, start_date, end_date): return total, quality, len(readings) +def _meter_consumption_from_rows(rows, start_date, end_date): + """Même calcul que ``_meter_consumption`` à partir d'un lot déjà chargé.""" + start_dt = datetime.combine(start_date, datetime.min.time()) + end_dt = datetime.combine(end_date, datetime.max.time()) + relevant = [row for row in rows if row.reading_date <= end_dt] + before = next((row for row in reversed(relevant) if row.reading_date < start_dt), None) + inside = [row for row in relevant if start_dt <= row.reading_date <= end_dt] + ordered = ([before] if before else []) + inside + total = 0.0 + previous = None + for reading in ordered: + if previous and not reading.is_reset and not previous.is_reset and reading.value >= previous.value: + total += reading.value - previous.value + previous = reading + quality = "EXACT" if before else ("ESTIMATED" if len(ordered) >= 2 else "PARTIAL") + return total, quality, len(ordered) + + def calculate_contract_usage(period): result = {"bw": 0.0, "color": 0.0, "quality": "EXACT", "machines": 0} assignments = ContractEquipmentAssignment.query.filter( @@ -140,12 +158,23 @@ def calculate_contract_usage(period): ContractEquipmentAssignment.entry_date <= period.end_date, or_(ContractEquipmentAssignment.exit_date.is_(None), ContractEquipmentAssignment.exit_date >= period.start_date), ).all() + equipment_ids = {assignment.equipment_id for assignment in assignments} + meters = Meter.query.filter(Meter.equipment_id.in_(equipment_ids)).all() if equipment_ids else [] + meter_by_equipment = {} + for meter in meters: + meter_by_equipment.setdefault(meter.equipment_id, []).append(meter) + meter_ids = [meter.id for meter in meters] + reading_rows = (MeterReading.query.filter(MeterReading.meter_id.in_(meter_ids)) + .order_by(MeterReading.meter_id, MeterReading.reading_date, MeterReading.id).all()) if meter_ids else [] + readings_by_meter = {} + for reading in reading_rows: + readings_by_meter.setdefault(reading.meter_id, []).append(reading) for assignment in assignments: result["machines"] += 1 start = max(period.start_date, assignment.entry_date) end = min(period.end_date, assignment.exit_date or period.end_date) - for meter in Meter.query.filter_by(equipment_id=assignment.equipment_id).all(): - amount, quality, _ = _meter_consumption(meter, start, end) + for meter in meter_by_equipment.get(assignment.equipment_id, []): + amount, quality, _ = _meter_consumption_from_rows(readings_by_meter.get(meter.id, []), start, end) result[_meter_role(meter)] += amount if quality != "EXACT": result["quality"] = "PARTIAL" if result["quality"] == "EXACT" else result["quality"] diff --git a/tests/integration/test_checkpoint_c4.py b/tests/integration/test_checkpoint_c4.py index c4c5fa7..f1bdfb1 100644 --- a/tests/integration/test_checkpoint_c4.py +++ b/tests/integration/test_checkpoint_c4.py @@ -1,4 +1,5 @@ from datetime import date, datetime, timedelta, timezone +from time import perf_counter import pytest @@ -10,7 +11,7 @@ from app_new.core.models import ( from app_new.core.services.c4_service import ( C4DomainError, assign_equipment_to_contract, calculate_contract_usage, create_consumable_order, create_contract, estimate_consumable_lifetime, - evaluate_quota_alert, receive_consumable_order, record_consumable_issue, replace_contract_equipment, + evaluate_quota_alert, project_contract_usage, receive_consumable_order, record_consumable_issue, replace_contract_equipment, ) @@ -146,3 +147,39 @@ def test_c4_contract_http_and_permissions(authenticated_client): assert "Contrats photocopieurs" in response.get_data(as_text=True) response = authenticated_client.get("/c4/consumables") assert response.status_code == 200 + + +def test_c4_performance_paths(authenticated_client, app): + """Mesure les chemins C4 sans confondre le coût d'un GET et d'un calcul métier.""" + with app.app_context(): + supplier = _company("TEST_UI_C4_PERF_SUPPLIER") + contract = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_PERF_CONTRACT", start_date=date(2026, 1, 1), end_date=date(2027, 12, 31), anniversary_month=1, anniversary_day=1) + period = contract.periods[0] + period.quota_bw = 10000; period.quota_color = 5000 + machines = [_equipment(f"TEST_UI_C4_PERF_MACHINE_{i}") for i in range(5)] + for machine in machines: + assign_equipment_to_contract(contract=contract, equipment=machine, entry_date=period.start_date, commit=False) + bw = _meter(machine, f"N&B {machine.name}", "N&B") + color = _meter(machine, f"Couleur {machine.name}", "Couleur") + for index in range(1, 8): + _reading(bw, index * 100, date(2026, index, 1)) + _reading(color, index * 40, date(2026, index, 1)) + consumable = Consumable(name="TEST_UI_C4_PERF_TONER", quantity=10, min_quantity=2) + db.session.add(consumable); db.session.flush() + for machine in machines: + db.session.add(EquipmentConsumable(equipment=machine, consumable=consumable)) + db.session.commit() + from sqlalchemy import event + counts = {"n": 0} + def count(_conn, _cursor, _statement, _params, _ctx, _executemany): counts["n"] += 1 + event.listen(db.engine, "before_cursor_execute", count) + started = perf_counter(); usage = calculate_contract_usage(period); projection = project_contract_usage(period); service_seconds = perf_counter() - started + service_sql = counts["n"] + counts["n"] = 0 + started = perf_counter(); response = authenticated_client.get(f"/c4/contracts/{contract.id}"); contract_seconds = perf_counter() - started; contract_sql = counts["n"] + counts["n"] = 0 + started = perf_counter(); response_consumables = authenticated_client.get(f"/c4/consumables/{consumable.id}"); consumable_seconds = perf_counter() - started; consumable_sql = counts["n"] + event.remove(db.engine, "before_cursor_execute", count) + assert response.status_code == 200 and response_consumables.status_code == 200 + assert usage["machines"] == 5 and projection["usage"]["bw"] > 0 + print(f"C4_PERF service_seconds={service_seconds:.4f} service_queries={service_sql} contract_seconds={contract_seconds:.4f} contract_queries={contract_sql} consumable_seconds={consumable_seconds:.4f} consumable_queries={consumable_sql}")