perf(c4): batch contract usage and detail loading
This commit is contained in:
parent
e560d824cc
commit
1fe9e91354
3 changed files with 76 additions and 5 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue