perf(c4): batch contract usage and detail loading

This commit is contained in:
root 2026-08-24 16:18:16 +00:00
parent e560d824cc
commit 1fe9e91354
3 changed files with 76 additions and 5 deletions

View file

@ -3,6 +3,7 @@ from datetime import date
from flask import Blueprint, flash, redirect, render_template, request, url_for from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload, selectinload
from ..core.authorization import permission_required from ..core.authorization import permission_required
from ..core.models import ( from ..core.models import (
@ -67,10 +68,14 @@ def new_contract():
@login_required @login_required
@permission_required("contract.view") @permission_required("contract.view")
def contract_detail(contract_id): 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 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} 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) return render_template("c4/contract_detail.html", contract=contract, periods=periods, usage=data, projections=projections)

View file

@ -133,6 +133,24 @@ def _meter_consumption(meter, start_date, end_date):
return total, quality, len(readings) 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): def calculate_contract_usage(period):
result = {"bw": 0.0, "color": 0.0, "quality": "EXACT", "machines": 0} result = {"bw": 0.0, "color": 0.0, "quality": "EXACT", "machines": 0}
assignments = ContractEquipmentAssignment.query.filter( assignments = ContractEquipmentAssignment.query.filter(
@ -140,12 +158,23 @@ def calculate_contract_usage(period):
ContractEquipmentAssignment.entry_date <= period.end_date, ContractEquipmentAssignment.entry_date <= period.end_date,
or_(ContractEquipmentAssignment.exit_date.is_(None), ContractEquipmentAssignment.exit_date >= period.start_date), or_(ContractEquipmentAssignment.exit_date.is_(None), ContractEquipmentAssignment.exit_date >= period.start_date),
).all() ).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: for assignment in assignments:
result["machines"] += 1 result["machines"] += 1
start = max(period.start_date, assignment.entry_date) start = max(period.start_date, assignment.entry_date)
end = min(period.end_date, assignment.exit_date or period.end_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(): for meter in meter_by_equipment.get(assignment.equipment_id, []):
amount, quality, _ = _meter_consumption(meter, start, end) amount, quality, _ = _meter_consumption_from_rows(readings_by_meter.get(meter.id, []), start, end)
result[_meter_role(meter)] += amount result[_meter_role(meter)] += amount
if quality != "EXACT": if quality != "EXACT":
result["quality"] = "PARTIAL" if result["quality"] == "EXACT" else result["quality"] result["quality"] = "PARTIAL" if result["quality"] == "EXACT" else result["quality"]

View file

@ -1,4 +1,5 @@
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from time import perf_counter
import pytest import pytest
@ -10,7 +11,7 @@ from app_new.core.models import (
from app_new.core.services.c4_service import ( from app_new.core.services.c4_service import (
C4DomainError, assign_equipment_to_contract, calculate_contract_usage, C4DomainError, assign_equipment_to_contract, calculate_contract_usage,
create_consumable_order, create_contract, estimate_consumable_lifetime, 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) assert "Contrats photocopieurs" in response.get_data(as_text=True)
response = authenticated_client.get("/c4/consumables") response = authenticated_client.get("/c4/consumables")
assert response.status_code == 200 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}")