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,17 +167,26 @@ class Equipment(db.Model):
@property
def total_quantity(self):
"""Quantité totale: si groupe, compte tous les descendants; sinon retourne quantity."""
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
"""Quantite physique declaree, individualisation comprise."""
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
def total_children(self):
@ -196,9 +205,11 @@ class Equipment(db.Model):
@property
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:
return self.parent.room
return self.parent.effective_room
return self.room
def create_scheduled_tasks_from_lot(self):
@ -413,4 +424,4 @@ class EquipmentRestriction(db.Model):
return self.restriction_type == 'room_occupancy'
def __repr__(self):
return f"<EquipmentRestriction {self.equipment_id} - {self.restriction_type}>"
return f"<EquipmentRestriction {self.equipment_id} - {self.restriction_type}>"

View file

@ -518,18 +518,27 @@ def add_elements(id):
if request.method == 'POST':
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):
new_element = Equipment(
name=f"{equipment.name} #{equipment.children.count() + i + 1}",
code=f"{equipment.code}-{equipment.children.count() + i + 1}" if equipment.code else None,
name=f"{equipment.name} #{first_number + i}",
code=f"{equipment.code}-{first_number + i}" if equipment.code else None,
parent_id=equipment.id,
individual_number=first_number + i,
status='en_service',
room_id=equipment.room_id,
lot_id=equipment.lot_id, # Hériter du lot
category_id=equipment.category_id, # Hériter de la catégorie
is_group=False # Un enfant n'est pas un groupe
quantity=1,
is_group=False,
tracked_individually=True,
)
db.session.add(new_element)
# L'ajout augmente le patrimoine physique du groupe.
equipment.quantity = (equipment.quantity or 0) + count
db.session.commit()
@ -549,22 +558,62 @@ def add_elements(id):
@main_bp.route('/<int:id>/individualize', methods=['GET', 'POST'])
@login_required
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)
if not equipment.parent_id:
flash("Cet équipement n'est pas un élément de groupe.", 'warning')
if not equipment.is_group:
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))
if request.method == 'POST':
# Rendre l'équipement indépendant
equipment.parent_id = None
equipment.is_group = False
next_number = max(
[child.individual_number or 0 for child in equipment.children] or [0]
) + 1
name = (request.form.get('name') or '').strip() or f"{equipment.name} #{next_number}"
code = (request.form.get('code') or '').strip() or None
position = (request.form.get('position') or '').strip() or None
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()
flash(f"Équipement '{equipment.name}' individualisé.", 'success')
return redirect(url_for('equipments.detail', id=equipment.id))
return render_template('equipments/individualize.html', equipment=equipment)
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')
@ -640,4 +689,3 @@ def move_to_room(id):
# === 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>
<td>
<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>
</tr>
<tr>
@ -450,6 +454,13 @@
<div class="card-body">
{% if equipment.is_group %}
<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 %}
<div class="col-md-4">
<a href="{{ url_for('equipments.create') }}?parent_id={{ equipment.id }}" class="btn btn-outline-success w-100">
@ -666,4 +677,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

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