Add individual tracking for equipment groups
This commit is contained in:
parent
fc10d2ef8b
commit
89c7845f5b
6 changed files with 206 additions and 124 deletions
|
|
@ -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}>"
|
||||
|
|
|
|||
|
|
@ -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 ===
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 d’origine</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 l’action</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 %}
|
||||
|
|
|
|||
61
tests/integration/test_equipment_individualization.py
Normal file
61
tests/integration/test_equipment_individualization.py
Normal 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
|
||||
Loading…
Reference in a new issue