test(c4): complete contract and consumable coverage
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
136b0e6c32
commit
254018d132
5 changed files with 215 additions and 5 deletions
|
|
@ -84,6 +84,14 @@ Les décisions sont ajoutées au fil des checkpoints. Les modèles existants son
|
||||||
|
|
||||||
**DATE/COMMIT** : 2026-08-23 / checkpoint A en cours.
|
**DATE/COMMIT** : 2026-08-23 / checkpoint A en cours.
|
||||||
|
|
||||||
|
## D-017 — Compléments C4
|
||||||
|
|
||||||
|
Le rôle contractuel d'un compteur est explicite (`COPIER_BW`, `COPIER_COLOR` ou `NONE`) : un compteur inconnu n'est jamais classé silencieusement N&B. Lorsqu'il manque l'index de début d'une période, la consommation manquante est estimée par moyenne journalière historique ; pour les équipements d'impression, les jours scolaires comparables sont privilégiés. Sans historique suffisant, la qualité est `PARTIAL`. L'agrégation globale suit `PARTIAL > ESTIMATED > EXACT`.
|
||||||
|
|
||||||
|
La clôture d'un contrat est centralisée dans un service métier : la période en cours est tronquée, les périodes futures jamais commencées sont neutralisées et les associations ouvertes sont fermées, sans suppression de l'historique. Une commande partiellement reçue peut être annulée pour son restant ; le stock déjà reçu est conservé, et plusieurs commandes ouvertes sont agrégées.
|
||||||
|
|
||||||
|
La couverture de stock est une estimation globale fondée sur les rythmes des équipements actifs compatibles, avec qualité `ESTIMATED`, `PARTIAL` ou indisponible. Les alertes quota et stock ouvertes remontent dans « Ma journée » comme informations sans durée ni intervention automatique, filtrées par `contract.view` et `stock.view`. La migration additive `t5h6i7j8k9l0` complète `s4g5h6i7j8k9`.
|
||||||
|
|
||||||
## D-017 — Contrats photocopieurs et consommables C4
|
## D-017 — Contrats photocopieurs et consommables C4
|
||||||
|
|
||||||
Les photocopieurs et imprimantes restent des `Equipment` et leurs compteurs restent des `Meter`. Les contrats sont pluriannuels ; la date globale du contrat est distincte de la date de début des périodes de quota et de l'anniversaire configurable. Les quotas N&B/couleur appartiennent à la période contractuelle, jamais à une machine.
|
Les photocopieurs et imprimantes restent des `Equipment` et leurs compteurs restent des `Meter`. Les contrats sont pluriannuels ; la date globale du contrat est distincte de la date de début des périodes de quota et de l'anniversaire configurable. Les quotas N&B/couleur appartiennent à la période contractuelle, jamais à une machine.
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ Alban, watchdogs, ENT, Outlook, Pronote, Yeastar, IA, secrets, données TEST_UI_
|
||||||
|
|
||||||
### C3-COMPLETE — finalisation analytique
|
### C3-COMPLETE — finalisation analytique
|
||||||
|
|
||||||
La finalisation C3 ajoute les agrégations DAY/WEEK/MONTH, les métriques contextuelles à répartition temporelle approximative, les seuils statistiques et preuves idempotentes, l’exclusion de baseline, la détection zero/low, la facture gaz, les coûts informatifs, les alertes informatives dans « Ma journée » et les graphiques locaux. La version reste `0.2.0-dev.2` jusqu’à validation indépendante. C4 (contrats, quotas et consommables photocopieurs) reste non commencé.
|
La finalisation C3 ajoute les agrégations DAY/WEEK/MONTH, les métriques contextuelles à répartition temporelle approximative, les seuils statistiques et preuves idempotentes, l’exclusion de baseline, la détection zero/low, la facture gaz, les coûts informatifs, les alertes informatives dans « Ma journée » et les graphiques locaux. La version reste `0.2.0-dev.2` jusqu’à validation indépendante.
|
||||||
|
|
||||||
La clôture C3 ajoute le recalcul ciblé après création/correction d'un relevé, la clôture HTTP d'alerte avec permissions, le filtrage RBAC des alertes informatives de « Ma journée », l'idempotence de l'intervention liée et les synthèses dashboard sur les 30 derniers jours. Les synthèses conservent les unités : eau en m³, gaz en m³ et kWh seulement si converti, électricité en kWh. Les performances mesurées sur `TEST_UI_C3_CLOSE_PERF_*` restent dépendantes de MariaDB ; aucune optimisation ne doit masquer le coût historique de C2.
|
La clôture C3 ajoute le recalcul ciblé après création/correction d'un relevé, la clôture HTTP d'alerte avec permissions, le filtrage RBAC des alertes informatives de « Ma journée », l'idempotence de l'intervention liée et les synthèses dashboard sur les 30 derniers jours. Les synthèses conservent les unités : eau en m³, gaz en m³ et kWh seulement si converti, électricité en kWh. Les performances mesurées sur `TEST_UI_C3_CLOSE_PERF_*` restent dépendantes de MariaDB ; aucune optimisation ne doit masquer le coût historique de C2.
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ La clôture C3 ajoute le recalcul ciblé après création/correction d'un relev
|
||||||
|
|
||||||
C4 ajoute les contrats photocopieurs pluriannuels et leurs périodes annuelles de quota, avec anniversaire configurable, parc historisé par association datée, remplacement de machine, quotas N&B/couleur, projection selon les jours scolaires et alertes de dépassement dédupliquées. Les photocopieurs et imprimantes restent des `Equipment` et leurs compteurs restent ceux de C1-C3.
|
C4 ajoute les contrats photocopieurs pluriannuels et leurs périodes annuelles de quota, avec anniversaire configurable, parc historisé par association datée, remplacement de machine, quotas N&B/couleur, projection selon les jours scolaires et alertes de dépassement dédupliquées. Les photocopieurs et imprimantes restent des `Equipment` et leurs compteurs restent ceux de C1-C3.
|
||||||
|
|
||||||
Le stock réutilise les références `Consumable`, les compatibilités `EquipmentConsumable` et les sorties `ConsumableUsage`. Les commandes/réceptions C4 augmentent le stock uniquement à la réception ; les références partagées restent uniques et la durée est calculée par couple équipement/consommable. La migration additive est `s4g5h6i7j8k9`; validation base vide, couverture UI/mobile et revue indépendante restent à effectuer.
|
Le stock réutilise les références `Consumable`, les compatibilités `EquipmentConsumable` et les sorties `ConsumableUsage`. Les commandes/réceptions C4 augmentent le stock uniquement à la réception ; les références partagées restent uniques et la durée est calculée par couple équipement/consommable. Les compléments C4 ajoutent l'estimation réelle des débuts de période manquants, les rôles de compteurs explicites, la clôture métier des contrats, l'annulation auditée et l'agrégation des commandes ouvertes, ainsi que la couverture stock multi-machines. La migration additive `t5h6i7j8k9l0` complète `s4g5h6i7j8k9`; validation base vide, couverture UI/mobile et revue indépendante restent à effectuer.
|
||||||
|
|
||||||
### D — Graphe technique générique
|
### D — Graphe technique générique
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,3 +176,14 @@ Lire ROADMAP_020_DEV3.md et DECISIONS_020_DEV3.md, puis reprendre sur NEXT ACTIO
|
||||||
- [ ] installation complète sur base vide non validée : l'utilisateur MariaDB applicatif n'a pas le privilège de création de base temporaire.
|
- [ ] installation complète sur base vide non validée : l'utilisateur MariaDB applicatif n'a pas le privilège de création de base temporaire.
|
||||||
|
|
||||||
La version reste `0.2.0-dev.2`. Les seuils historiques de compteur ne sont pas réutilisés pour les alertes de consommation futures.
|
La version reste `0.2.0-dev.2`. Les seuils historiques de compteur ne sont pas réutilisés pour les alertes de consommation futures.
|
||||||
|
|
||||||
|
## C4-COMPLETE — état de travail
|
||||||
|
|
||||||
|
- [x] estimation réelle d'une consommation manquante au début d'une période contractuelle et qualité globale `EXACT`/`ESTIMATED`/`PARTIAL` ;
|
||||||
|
- [x] rôle contractuel explicite des compteurs (`COPIER_BW`/`COPIER_COLOR`/`NONE`) ;
|
||||||
|
- [x] clôture métier des contrats, périodes futures neutralisées et associations clôturées ;
|
||||||
|
- [x] annulation auditée des commandes non entièrement reçues, sans diminution du stock reçu ;
|
||||||
|
- [x] agrégation de plusieurs commandes ouvertes et couverture de stock multi-machines ;
|
||||||
|
- [x] alertes quota/stock informatives dans « Ma journée », sans durée automatique ;
|
||||||
|
- [x] migration additive `t5h6i7j8k9l0` ;
|
||||||
|
- [ ] validation base vide, validation Playwright et revue indépendante finale.
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ def _equipment(name, device_type="photocopier"):
|
||||||
|
|
||||||
|
|
||||||
def _meter(equipment, name, usage):
|
def _meter(equipment, name, usage):
|
||||||
meter = Meter(equipment=equipment, name=name, meter_type="photocopieur", unit="pages", usage=usage)
|
role = "COPIER_COLOR" if "couleur" in usage.lower() else "COPIER_BW"
|
||||||
|
meter = Meter(equipment=equipment, name=name, meter_type="photocopieur", unit="pages", usage=usage, contract_role=role)
|
||||||
db.session.add(meter)
|
db.session.add(meter)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
return meter
|
return meter
|
||||||
|
|
@ -170,6 +171,7 @@ def test_c4_performance_paths(authenticated_client, app):
|
||||||
db.session.add(EquipmentConsumable(equipment=machine, consumable=consumable))
|
db.session.add(EquipmentConsumable(equipment=machine, consumable=consumable))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
|
from app_new.core.services.c4_service import estimate_consumable_stock_coverage
|
||||||
counts = {"n": 0}
|
counts = {"n": 0}
|
||||||
def count(_conn, _cursor, _statement, _params, _ctx, _executemany): counts["n"] += 1
|
def count(_conn, _cursor, _statement, _params, _ctx, _executemany): counts["n"] += 1
|
||||||
event.listen(db.engine, "before_cursor_execute", count)
|
event.listen(db.engine, "before_cursor_execute", count)
|
||||||
|
|
@ -179,7 +181,11 @@ def test_c4_performance_paths(authenticated_client, app):
|
||||||
started = perf_counter(); response = authenticated_client.get(f"/c4/contracts/{contract.id}"); contract_seconds = perf_counter() - started; contract_sql = counts["n"]
|
started = perf_counter(); response = authenticated_client.get(f"/c4/contracts/{contract.id}"); contract_seconds = perf_counter() - started; contract_sql = counts["n"]
|
||||||
counts["n"] = 0
|
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"]
|
started = perf_counter(); response_consumables = authenticated_client.get(f"/c4/consumables/{consumable.id}"); consumable_seconds = perf_counter() - started; consumable_sql = counts["n"]
|
||||||
|
counts["n"] = 0
|
||||||
|
started = perf_counter(); coverage = estimate_consumable_stock_coverage(consumable=consumable); coverage_seconds = perf_counter() - started; coverage_sql = counts["n"]
|
||||||
|
counts["n"] = 0
|
||||||
|
started = perf_counter(); response_day = authenticated_client.get("/planning/my-day"); day_seconds = perf_counter() - started; day_sql = counts["n"]
|
||||||
event.remove(db.engine, "before_cursor_execute", count)
|
event.remove(db.engine, "before_cursor_execute", count)
|
||||||
assert response.status_code == 200 and response_consumables.status_code == 200
|
assert response.status_code == 200 and response_consumables.status_code == 200 and response_day.status_code == 200
|
||||||
assert usage["machines"] == 5 and projection["usage"]["bw"] > 0
|
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}")
|
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} coverage_seconds={coverage_seconds:.4f} coverage_queries={coverage_sql} my_day_seconds={day_seconds:.4f} my_day_queries={day_sql}")
|
||||||
|
|
|
||||||
185
tests/integration/test_checkpoint_c4_complete.py
Normal file
185
tests/integration/test_checkpoint_c4_complete.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app_new.extensions import db
|
||||||
|
from app_new.core.models import (
|
||||||
|
Alert, Company, Consumable, ConsumableOrder, ConsumableUsage, Equipment, EquipmentConsumable,
|
||||||
|
Meter, MeterReading, PhotocopierContract, PhotocopierContractPeriod, User,
|
||||||
|
)
|
||||||
|
from app_new.core.services.c4_service import (
|
||||||
|
C4DomainError, assign_equipment_to_contract, calculate_contract_usage,
|
||||||
|
cancel_consumable_order, close_photocopier_contract, create_consumable_order,
|
||||||
|
create_contract, estimate_consumable_stock_coverage, receive_consumable_order,
|
||||||
|
visible_open_c4_alerts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _company(name):
|
||||||
|
row = Company(name=name)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _equipment(name, device_type="photocopier"):
|
||||||
|
row = Equipment(name=name, device_type=device_type, serial_number=f"TEST_UI_C4_COMPLETE_{name}")
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _meter(equipment, name, role="COPIER_BW"):
|
||||||
|
row = Meter(equipment=equipment, name=name, meter_type="photocopieur", unit="pages", contract_role=role)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _reading(meter, value, day):
|
||||||
|
row = MeterReading(meter=meter, value=value,
|
||||||
|
reading_date=datetime.combine(day, datetime.min.time(), tzinfo=timezone.utc))
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_estimates_missing_contract_start_and_quality(app):
|
||||||
|
with app.app_context():
|
||||||
|
supplier = _company("TEST_UI_C4_COMPLETE_ESTIMATE_SUPPLIER")
|
||||||
|
contract = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_COMPLETE_ESTIMATE",
|
||||||
|
start_date=date(2026, 10, 1), end_date=date(2027, 9, 30),
|
||||||
|
anniversary_month=10, anniversary_day=1)
|
||||||
|
period = contract.periods[0]
|
||||||
|
machine = _equipment("TEST_UI_C4_COMPLETE_ESTIMATE_MACHINE")
|
||||||
|
assign_equipment_to_contract(contract=contract, equipment=machine, entry_date=period.start_date)
|
||||||
|
meter = _meter(machine, "N&B", "COPIER_BW")
|
||||||
|
_reading(meter, 9000, date(2026, 9, 1))
|
||||||
|
_reading(meter, 9140, date(2026, 9, 15)) # historique fiable : 10/jour
|
||||||
|
_reading(meter, 9440, date(2026, 10, 15))
|
||||||
|
_reading(meter, 9610, date(2026, 11, 1))
|
||||||
|
usage = calculate_contract_usage(period)
|
||||||
|
# 10 jours scolaires environ avant le premier relevé + delta mesuré.
|
||||||
|
assert usage["bw"] > 170
|
||||||
|
assert usage["quality"] == "ESTIMATED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_missing_contract_start_without_history_is_partial(app):
|
||||||
|
with app.app_context():
|
||||||
|
supplier = _company("TEST_UI_C4_COMPLETE_PARTIAL_SUPPLIER")
|
||||||
|
contract = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_COMPLETE_PARTIAL",
|
||||||
|
start_date=date(2026, 10, 1), end_date=date(2027, 9, 30),
|
||||||
|
anniversary_month=10, anniversary_day=1)
|
||||||
|
period = contract.periods[0]
|
||||||
|
machine = _equipment("TEST_UI_C4_COMPLETE_PARTIAL_MACHINE")
|
||||||
|
assign_equipment_to_contract(contract=contract, equipment=machine, entry_date=period.start_date)
|
||||||
|
meter = _meter(machine, "N&B", "COPIER_BW")
|
||||||
|
_reading(meter, 100, date(2026, 10, 15))
|
||||||
|
_reading(meter, 200, date(2026, 11, 1))
|
||||||
|
usage = calculate_contract_usage(period)
|
||||||
|
assert usage["bw"] == 100
|
||||||
|
assert usage["quality"] == "PARTIAL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_unknown_contract_role_is_ignored(app):
|
||||||
|
with app.app_context():
|
||||||
|
supplier = _company("TEST_UI_C4_COMPLETE_ROLE_SUPPLIER")
|
||||||
|
contract = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_COMPLETE_ROLE",
|
||||||
|
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31),
|
||||||
|
anniversary_month=1, anniversary_day=1)
|
||||||
|
period = contract.periods[0]
|
||||||
|
machine = _equipment("TEST_UI_C4_COMPLETE_ROLE_MACHINE")
|
||||||
|
assign_equipment_to_contract(contract=contract, equipment=machine, entry_date=period.start_date)
|
||||||
|
_meter(machine, "Heures", "NONE")
|
||||||
|
usage = calculate_contract_usage(period)
|
||||||
|
assert usage["bw"] == 0 and usage["color"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_close_contract_truncates_current_and_future_history(app):
|
||||||
|
with app.app_context():
|
||||||
|
supplier = _company("TEST_UI_C4_COMPLETE_CLOSE_SUPPLIER")
|
||||||
|
contract = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_COMPLETE_CLOSE",
|
||||||
|
start_date=date(2026, 10, 1), end_date=date(2031, 9, 30),
|
||||||
|
anniversary_month=10, anniversary_day=1)
|
||||||
|
machine = _equipment("TEST_UI_C4_COMPLETE_CLOSE_MACHINE")
|
||||||
|
assignment = assign_equipment_to_contract(contract=contract, equipment=machine, entry_date=date(2026, 10, 1))
|
||||||
|
close_photocopier_contract(contract=contract, closed_at=date(2028, 3, 15))
|
||||||
|
assert contract.status == "CLOSED" and contract.end_date == date(2028, 3, 15)
|
||||||
|
assert assignment.exit_date == date(2028, 3, 15)
|
||||||
|
assert contract.periods[1].status == "CLOSED"
|
||||||
|
assert contract.periods[2].status == "CANCELLED"
|
||||||
|
assert contract.periods[2].start_date == date(2028, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_new_contract_after_close_preserves_meter_identity(app):
|
||||||
|
with app.app_context():
|
||||||
|
supplier = _company("TEST_UI_C4_COMPLETE_RESET_SUPPLIER")
|
||||||
|
old = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_COMPLETE_OLD",
|
||||||
|
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31),
|
||||||
|
anniversary_month=1, anniversary_day=1)
|
||||||
|
machine = _equipment("TEST_UI_C4_COMPLETE_RESET_MACHINE")
|
||||||
|
assignment = assign_equipment_to_contract(contract=old, equipment=machine, entry_date=date(2026, 1, 1))
|
||||||
|
meter = _meter(machine, "N&B", "COPIER_BW")
|
||||||
|
_reading(meter, 100, date(2026, 1, 1))
|
||||||
|
close_photocopier_contract(contract=old, closed_at=date(2026, 9, 30))
|
||||||
|
new = create_contract(supplier_id=supplier.id, name="TEST_UI_C4_COMPLETE_NEW",
|
||||||
|
start_date=date(2026, 10, 1), end_date=date(2027, 9, 30),
|
||||||
|
anniversary_month=10, anniversary_day=1)
|
||||||
|
new_assignment = assign_equipment_to_contract(contract=new, equipment=machine, entry_date=date(2026, 10, 1))
|
||||||
|
assert assignment.exit_date == date(2026, 9, 30)
|
||||||
|
assert new_assignment.entry_date == date(2026, 10, 1)
|
||||||
|
assert meter.id and meter.current_value == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_daily_view_alerts_are_open_and_permission_filtered(app, admin_user):
|
||||||
|
with app.app_context():
|
||||||
|
quota = Alert(alert_type="QUOTA_OVERAGE_PROJECTED", title="Quota", message="Quota à examiner", is_resolved=False)
|
||||||
|
stock = Alert(alert_type="CONSUMABLE_STOCK_LOW", title="Stock", message="Stock à examiner", is_resolved=False)
|
||||||
|
closed = Alert(alert_type="CONSUMABLE_STOCK_LOW", title="Ancienne", message="Ne pas afficher", is_resolved=True)
|
||||||
|
db.session.add_all([quota, stock, closed])
|
||||||
|
db.session.commit()
|
||||||
|
rows = visible_open_c4_alerts(db.session.get(User, admin_user["id"]))
|
||||||
|
assert {row.alert_type for row in rows} >= {"QUOTA_OVERAGE_PROJECTED", "CONSUMABLE_STOCK_LOW"}
|
||||||
|
assert all(row.is_resolved is False for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_cancel_partial_order_keeps_stock_and_aggregates_open_orders(app, admin_user):
|
||||||
|
with app.app_context():
|
||||||
|
consumable = Consumable(name="TEST_UI_C4_COMPLETE_ORDER", quantity=1, min_quantity=2, target_quantity=8)
|
||||||
|
db.session.add(consumable)
|
||||||
|
db.session.commit()
|
||||||
|
first = create_consumable_order(consumable=consumable, quantity=5, user_id=admin_user["id"])
|
||||||
|
second = create_consumable_order(consumable=consumable, quantity=4, user_id=admin_user["id"])
|
||||||
|
receive_consumable_order(order=first, quantity=3, user_id=admin_user["id"])
|
||||||
|
assert first.remaining_quantity == 2 and second.remaining_quantity == 4
|
||||||
|
cancel_consumable_order(order=first, user_id=admin_user["id"], reason="TEST_UI_C4_COMPLETE_CANCEL")
|
||||||
|
assert first.status == "CANCELLED"
|
||||||
|
assert consumable.quantity == 4 # stock reçu conservé
|
||||||
|
assert second.remaining_quantity == 4
|
||||||
|
with pytest.raises(C4DomainError):
|
||||||
|
cancel_consumable_order(order=first, user_id=admin_user["id"], reason="second appel")
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_complete_stock_coverage_uses_active_compatible_machines(app):
|
||||||
|
with app.app_context():
|
||||||
|
consumable = Consumable(name="TEST_UI_C4_COMPLETE_COVERAGE", quantity=3)
|
||||||
|
a, b, retired = (_equipment("TEST_UI_C4_COMPLETE_COVERAGE_A", "printer"),
|
||||||
|
_equipment("TEST_UI_C4_COMPLETE_COVERAGE_B", "printer"),
|
||||||
|
_equipment("TEST_UI_C4_COMPLETE_COVERAGE_RETIRED", "printer"))
|
||||||
|
retired.lifecycle_status = "retired"
|
||||||
|
db.session.add(consumable)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add_all([EquipmentConsumable(equipment=item, consumable=consumable) for item in (a, b, retired)])
|
||||||
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
for equipment, offsets in ((a, (0, 30, 60)), (b, (0, 60, 120))):
|
||||||
|
for offset in offsets:
|
||||||
|
db.session.add(ConsumableUsage(consumable=consumable, equipment=equipment,
|
||||||
|
quantity_used=1, used_at=base + timedelta(days=offset)))
|
||||||
|
db.session.commit()
|
||||||
|
coverage = estimate_consumable_stock_coverage(consumable=consumable)
|
||||||
|
assert coverage["machines_total"] == 2
|
||||||
|
assert coverage["machines_with_history"] == 2
|
||||||
|
assert coverage["quality"] == "ESTIMATED"
|
||||||
|
assert round(coverage["coverage_days"]) == 60
|
||||||
|
ConsumableUsage.query.update({ConsumableUsage.used_by_id: None}, synchronize_session=False)
|
||||||
|
db.session.commit()
|
||||||
Loading…
Reference in a new issue