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 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue