Add individual tracking for equipment groups

This commit is contained in:
root 2026-08-14 17:20:56 +00:00
parent fc10d2ef8b
commit 89c7845f5b
6 changed files with 206 additions and 124 deletions

View file

@ -167,18 +167,27 @@ class Equipment(db.Model):
@property @property
def total_quantity(self): def total_quantity(self):
"""Quantité totale: si groupe, compte tous les descendants; sinon retourne quantity.""" """Quantite physique declaree, individualisation comprise."""
if self.is_group:
# Compter récursivement tous les descendants
total = 0
for child in Equipment.query.filter_by(parent_id=self.id).all():
if child.is_group:
total += child.total_quantity
else:
total += child.quantity
return total if total > 0 else self.quantity
return self.quantity return self.quantity
@property
def individualized_quantity(self):
"""Nombre d'unites du groupe disposant deja de leur propre fiche."""
if not self.is_group:
return 0
return sum(
max(child.quantity or 1, 1)
for child in self.children
if not child.is_group
)
@property
def unindividualized_quantity(self):
"""Nombre d'unites encore gerees uniquement sous forme quantitative."""
if not self.is_group:
return 0
return max((self.quantity or 0) - self.individualized_quantity, 0)
@property @property
def total_children(self): def total_children(self):
"""Nombre total d'enfants.""" """Nombre total d'enfants."""
@ -196,9 +205,11 @@ class Equipment(db.Model):
@property @property
def effective_room(self): def effective_room(self):
"""Salle effective (celle du parent si individuel, sinon la salle).""" """Salle propre, ou salle heritee du parent si elle n'est pas definie."""
if self.room_id and self.room:
return self.room
if self.is_individual and self.parent: if self.is_individual and self.parent:
return self.parent.room return self.parent.effective_room
return self.room return self.room
def create_scheduled_tasks_from_lot(self): def create_scheduled_tasks_from_lot(self):

View file

@ -518,19 +518,28 @@ def add_elements(id):
if request.method == 'POST': if request.method == 'POST':
count = int(request.form.get('count', 1)) count = int(request.form.get('count', 1))
if count < 1:
flash('La quantite ajoutee doit etre positive.', 'danger')
return redirect(url_for('equipments.add_elements', id=equipment.id))
first_number = equipment.individualized_quantity + 1
for i in range(count): for i in range(count):
new_element = Equipment( new_element = Equipment(
name=f"{equipment.name} #{equipment.children.count() + i + 1}", name=f"{equipment.name} #{first_number + i}",
code=f"{equipment.code}-{equipment.children.count() + i + 1}" if equipment.code else None, code=f"{equipment.code}-{first_number + i}" if equipment.code else None,
parent_id=equipment.id, parent_id=equipment.id,
individual_number=first_number + i,
status='en_service', status='en_service',
room_id=equipment.room_id, room_id=equipment.room_id,
lot_id=equipment.lot_id, # Hériter du lot quantity=1,
category_id=equipment.category_id, # Hériter de la catégorie is_group=False,
is_group=False # Un enfant n'est pas un groupe tracked_individually=True,
) )
db.session.add(new_element) db.session.add(new_element)
# L'ajout augmente le patrimoine physique du groupe.
equipment.quantity = (equipment.quantity or 0) + count
db.session.commit() db.session.commit()
# Créer les tâches planifiées pour les nouveaux éléments # Créer les tâches planifiées pour les nouveaux éléments
@ -549,22 +558,62 @@ def add_elements(id):
@main_bp.route('/<int:id>/individualize', methods=['GET', 'POST']) @main_bp.route('/<int:id>/individualize', methods=['GET', 'POST'])
@login_required @login_required
def individualize(id): def individualize(id):
"""Individualiser un élément d'un groupe.""" """Extraire une unite quantitative d'un groupe vers une fiche individuelle."""
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
if not equipment.parent_id: if not equipment.is_group:
flash("Cet équipement n'est pas un élément de groupe.", 'warning') flash("Seul un groupe d'équipements peut fournir une unité à individualiser.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id))
if equipment.unindividualized_quantity < 1:
flash("Toutes les unités de ce groupe sont déjà individualisées.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id)) return redirect(url_for('equipments.detail', id=equipment.id))
if request.method == 'POST': if request.method == 'POST':
# Rendre l'équipement indépendant next_number = max(
equipment.parent_id = None [child.individual_number or 0 for child in equipment.children] or [0]
equipment.is_group = False ) + 1
db.session.commit() name = (request.form.get('name') or '').strip() or f"{equipment.name} #{next_number}"
flash(f"Équipement '{equipment.name}' individualisé.", 'success') code = (request.form.get('code') or '').strip() or None
return redirect(url_for('equipments.detail', id=equipment.id)) position = (request.form.get('position') or '').strip() or None
return render_template('equipments/individualize.html', equipment=equipment) individual = Equipment(
name=name,
code=code,
description=equipment.description,
status='en_service',
parent_id=equipment.id,
is_group=False,
quantity=1,
tracked_individually=True,
individual_number=next_number,
position=position,
room_id=equipment.room_id,
# Le lot et la categorie restent herites du groupe.
lot_id=None,
category_id=None,
)
db.session.add(individual)
db.session.commit()
if individual.effective_lot_id:
individual.create_scheduled_tasks_from_lot()
flash(
f"Unité #{next_number} individualisée. Le groupe conserve un total de "
f"{equipment.quantity} unité(s).",
'success',
)
return redirect(url_for('equipments.detail', id=individual.id))
next_number = max(
[child.individual_number or 0 for child in equipment.children] or [0]
) + 1
return render_template(
'equipments/individualize.html',
equipment=equipment,
next_number=next_number,
)
@main_bp.route('/<int:id>/children') @main_bp.route('/<int:id>/children')
@ -640,4 +689,3 @@ def move_to_room(id):
# === Gestion des Zones === # === Gestion des Zones ===

View file

@ -1,47 +0,0 @@
{% extends "base.html" %}
{% block title %}Individualiser un élément — GMAO Collège{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<i class="bi bi-box-arrow-up-right"></i> Individualiser un élément
</div>
<div class="card-body">
<p class="mb-3">
Créer un élément individuel à partir du groupe <strong>{{ equipment.name }}</strong>
({{ equipment.quantity }} unités).
</p>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
L'élément sera numéroté <strong>n°{{ next_number }}</strong> et pourra être suivi indépendamment.
</div>
<form method="POST">
{{ csrf_token() if csrf_token is defined }}
<div class="mb-3">
<label class="form-label">Numéro de l'élément</label>
<input type="text" class="form-control" value="{{ next_number }}" disabled>
<small class="text-muted">Attribué automatiquement</small>
</div>
<div class="mb-3">
<label for="position" class="form-label">Position (optionnel)</label>
<input type="text" class="form-control" id="position" name="position"
placeholder="Ex: Rang 1, Place 3">
<small class="text-muted">Localisation précise dans la salle</small>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Individualiser l'élément n°{{ next_number }}
</button>
<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-outline-secondary">
Annuler
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -105,6 +105,10 @@
<th>Quantité déclarée</th> <th>Quantité déclarée</th>
<td> <td>
<span class="badge bg-primary fs-5">{{ equipment.quantity }}</span> unités <span class="badge bg-primary fs-5">{{ equipment.quantity }}</span> unités
<div class="small text-muted mt-1">
{{ equipment.individualized_quantity }} individualisée(s),
{{ equipment.unindividualized_quantity }} encore gérée(s) en quantité
</div>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -450,6 +454,13 @@
<div class="card-body"> <div class="card-body">
{% if equipment.is_group %} {% if equipment.is_group %}
<div class="row g-2"> <div class="row g-2">
{% if equipment.unindividualized_quantity > 0 %}
<div class="col-md-4">
<a href="{{ url_for('equipments.individualize', id=equipment.id) }}" class="btn btn-outline-primary w-100">
<i class="bi bi-box-arrow-up-right me-1"></i>Individualiser une unité
</a>
</div>
{% endif %}
{% if not equipment.parent %} {% if not equipment.parent %}
<div class="col-md-4"> <div class="col-md-4">
<a href="{{ url_for('equipments.create') }}?parent_id={{ equipment.id }}" class="btn btn-outline-success w-100"> <a href="{{ url_for('equipments.create') }}?parent_id={{ equipment.id }}" class="btn btn-outline-success w-100">

View file

@ -1,55 +1,53 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Individualiser une unité — GMAO{% endblock %}
{% block title %}Individualiser — GMAO{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('equipments.index') }}">Équipements</a></li> <li class="breadcrumb-item"><a href="{{ url_for('equipments.index') }}">Équipements</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('equipments.detail', id=equipment.id) }}">{{ equipment.name }}</a></li> <li class="breadcrumb-item"><a href="{{ url_for('equipments.detail', id=equipment.id) }}">{{ equipment.name }}</a></li>
<li class="breadcrumb-item active">Individualiser</li> <li class="breadcrumb-item active">Individualiser une unité</li>
</ol> </ol>
</nav> </nav>
<h1 class="h3"> <div class="row"><div class="col-lg-7">
<i class="bi bi-box-arrow-up-right me-2"></i> <div class="card mb-3">
Individualiser l'équipement <div class="card-header"><i class="bi bi-box-seam me-1"></i> Groupe dorigine</div>
</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{ equipment.name }}</h5> <h1 class="h4">{{ equipment.name }}</h1>
<p class="text-muted"> <div class="row g-2">
Cet élément appartient au groupe: <strong>{{ equipment.parent.name }}</strong><br> <div class="col-sm-4"><strong>Total physique</strong><br>{{ equipment.quantity }}</div>
Individualiser cet élément le rendra indépendant du groupe. <div class="col-sm-4"><strong>Déjà individualisées</strong><br>{{ equipment.individualized_quantity }}</div>
</p> <div class="col-sm-4"><strong>Restantes après laction</strong><br>{{ equipment.unindividualized_quantity - 1 }}</div>
</div>
{% if equipment.effective_lot %}
<p class="mt-3 mb-0 text-muted">Lot hérité : <strong>{{ equipment.effective_lot.name }}</strong></p>
{% endif %}
</div> </div>
</div> </div>
<form method="POST" class="card">
<form method="POST" action="{{ url_for('equipments.individualize', id=equipment.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card-header">Nouvelle fiche individuelle #{{ next_number }}</div>
<div class="alert alert-warning mt-3"> <div class="card-body">
<i class="bi bi-exclamation-triangle me-2"></i> <div class="mb-3">
Cette action est irréversible. L'équipement sera dissocié du groupe parent. <label for="name" class="form-label">Nom</label>
<input id="name" name="name" class="form-control" placeholder="{{ equipment.name }} #{{ next_number }}">
</div> </div>
<div class="mb-3">
<div class="d-flex gap-2 mt-4"> <label for="code" class="form-label">Code, repère ou numéro de série</label>
<button type="submit" class="btn btn-warning"> <input id="code" name="code" class="form-control" placeholder="Ex. A01-LUM-{{ '%02d'|format(next_number) }}">
<i class="bi bi-box-arrow-up-right me-1"></i> Individualiser </div>
</button> <div class="mb-3">
<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-secondary"> <label for="position" class="form-label">Position précise</label>
<i class="bi bi-arrow-left me-1"></i> Annuler <input id="position" name="position" class="form-control" placeholder="Ex. milieu, côté fenêtres">
</a> <div class="form-text">Facultatif pour le mobilier, recommandé pour un luminaire, une prise ou une fenêtre.</div>
</div>
</div>
<div class="card-footer d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-up-right me-1"></i> Individualiser une unité</button>
<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-secondary">Annuler</a>
</div> </div>
</form> </form>
</div> </div></div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,61 @@
from app_new.extensions import db
from app_new.core.models.equipment import Equipment, EquipmentCategory
from app_new.core.models.maintenance import Lot
from app_new.equipments.main import individualize
def test_group_unit_can_be_individualized_without_changing_total(app):
with app.app_context():
category = EquipmentCategory(name="Luminaires")
lot = Lot(name="Electricite")
group = Equipment(
name="Luminaires A01",
is_group=True,
quantity=10,
category=category,
lot=lot,
status="en_service",
)
db.session.add_all([category, lot, group])
db.session.commit()
group_id = group.id
lot_id = lot.id
category_id = category.id
with app.test_request_context(
f"/equipments/{group_id}/individualize",
method="POST",
data={
"name": "Luminaire du milieu",
"code": "A01-LUM-01",
"position": "milieu, cote fenetres",
},
):
response = individualize.__wrapped__(group_id)
assert response.status_code == 302
group = db.session.get(Equipment, group_id)
individual = Equipment.query.filter_by(parent_id=group_id).one()
assert group.quantity == 10
assert group.individualized_quantity == 1
assert group.unindividualized_quantity == 9
assert individual.quantity == 1
assert individual.individual_number == 1
assert individual.position == "milieu, cote fenetres"
assert individual.lot_id is None
assert individual.category_id is None
assert individual.effective_lot_id == lot_id
assert individual.effective_category_id == category_id
def test_group_cannot_individualize_more_than_declared_quantity(app):
with app.app_context():
group = Equipment(name="Luminaire unique", is_group=True, quantity=1)
db.session.add(group)
db.session.commit()
group_id = group.id
with app.test_request_context(method="POST"):
assert individualize.__wrapped__(group_id).status_code == 302
with app.test_request_context(method="POST"):
assert individualize.__wrapped__(group_id).status_code == 302
assert Equipment.query.filter_by(parent_id=group_id).count() == 1