186 lines
9.7 KiB
Python
186 lines
9.7 KiB
Python
|
|
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()
|