Gère le patrimoine mobile et son cycle de vie
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
7d8d67a9d2
commit
82c2683c4a
13 changed files with 515 additions and 148 deletions
|
|
@ -3,8 +3,8 @@ Core Models - GMAO Collège
|
|||
Import tous les modèles du core
|
||||
"""
|
||||
from .user import User, Staff
|
||||
from .college import College, Building, Room, RoomType, RoomSchedule, Zone
|
||||
from .equipment import EquipmentCategory, Equipment, EquipmentDocument, EquipmentRoomHistory
|
||||
from .college import College, Site, Building, Room, RoomType, RoomSchedule, Zone
|
||||
from .equipment import EquipmentCategory, Equipment, EquipmentDocument, EquipmentRoomHistory, EquipmentQuantityMovement
|
||||
from .maintenance import (
|
||||
Intervention, StatusChange, InterventionComment, InterventionDocument,
|
||||
InterventionPart, Lot, LotTask, LotService
|
||||
|
|
@ -21,8 +21,8 @@ from .audit import AuditLog
|
|||
|
||||
__all__ = [
|
||||
'User', 'Staff',
|
||||
'College', 'Building', 'Room', 'RoomType', 'RoomSchedule', 'Zone',
|
||||
'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory',
|
||||
'College', 'Site', 'Building', 'Room', 'RoomType', 'RoomSchedule', 'Zone',
|
||||
'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory', 'EquipmentQuantityMovement',
|
||||
'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument',
|
||||
'Lot', 'LotTask', 'LotService',
|
||||
'Company', 'Part', 'InterventionPart', 'Alert', 'Service',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,19 @@ class College(db.Model):
|
|||
return f"<College {self.name}>"
|
||||
|
||||
|
||||
class Site(db.Model):
|
||||
"""Site physique : collège, logements de fonction, annexe, etc."""
|
||||
__tablename__ = "sites"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False, unique=True)
|
||||
site_type = db.Column(db.String(40), nullable=False, default="college")
|
||||
address = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
buildings = db.relationship("Building", back_populates="site", lazy="dynamic")
|
||||
|
||||
|
||||
class Building(db.Model):
|
||||
"""Bâtiment du collège."""
|
||||
__tablename__ = "buildings"
|
||||
|
|
@ -37,10 +50,12 @@ class Building(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
site_id = db.Column(db.Integer, db.ForeignKey("sites.id"), nullable=True, index=True)
|
||||
|
||||
# Relations - utiliser overlaps pour éviter warning SQLAlchemy
|
||||
rooms = db.relationship("Room", overlaps="building", lazy="dynamic")
|
||||
zones = db.relationship("Zone", back_populates="building", lazy="dynamic")
|
||||
site = db.relationship("Site", back_populates="buildings")
|
||||
|
||||
@property
|
||||
def room_count(self):
|
||||
|
|
|
|||
|
|
@ -45,6 +45,20 @@ class Equipment(db.Model):
|
|||
position_y = db.Column(db.Float, nullable=True)
|
||||
install_date = db.Column(db.Date, nullable=True)
|
||||
warranty_end = db.Column(db.Date, nullable=True)
|
||||
mobility = db.Column(db.String(20), nullable=False, default="non_precise")
|
||||
serial_number = db.Column(db.String(120), nullable=True, index=True)
|
||||
manufacturer = db.Column(db.String(120), nullable=True)
|
||||
model_reference = db.Column(db.String(120), nullable=True)
|
||||
supplier_id = db.Column(db.Integer, db.ForeignKey("companies.id"), nullable=True)
|
||||
supplier_reference = db.Column(db.String(120), nullable=True)
|
||||
purchase_price = db.Column(db.Numeric(12, 2), nullable=True)
|
||||
purchase_date = db.Column(db.Date, nullable=True)
|
||||
lifecycle_status = db.Column(db.String(30), nullable=False, default="en_service")
|
||||
unavailable_since = db.Column(db.DateTime, nullable=True)
|
||||
planned_disposal_date = db.Column(db.Date, nullable=True)
|
||||
disposal_date = db.Column(db.Date, nullable=True)
|
||||
disposal_reason = db.Column(db.String(500), nullable=True)
|
||||
recurrence_monitoring = db.Column(db.Boolean, nullable=False, default=False)
|
||||
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
|
@ -59,6 +73,7 @@ class Equipment(db.Model):
|
|||
documents = db.relationship("EquipmentDocument", back_populates="equipment")
|
||||
consumables = db.relationship("EquipmentConsumable", back_populates="equipment", lazy="dynamic")
|
||||
room_history = db.relationship("EquipmentRoomHistory", backref="equipment", order_by="desc(EquipmentRoomHistory.changed_at)")
|
||||
supplier = db.relationship("Company", foreign_keys=[supplier_id])
|
||||
|
||||
@property
|
||||
def effective_lot_id(self):
|
||||
|
|
@ -158,7 +173,11 @@ class Equipment(db.Model):
|
|||
@property
|
||||
def needs_deep_work(self):
|
||||
"""Nécessite une intervention profonde."""
|
||||
return False
|
||||
return bool(self.recurrence_monitoring and self.curative_count >= 3)
|
||||
|
||||
@property
|
||||
def is_mobile(self):
|
||||
return self.mobility == "mobile"
|
||||
|
||||
@property
|
||||
def children_count(self):
|
||||
|
|
@ -301,6 +320,25 @@ class EquipmentRoomHistory(db.Model):
|
|||
return f"<EquipmentRoomHistory {self.equipment_id}>"
|
||||
|
||||
|
||||
class EquipmentQuantityMovement(db.Model):
|
||||
"""Mouvement d'une quantité entre deux salles ou groupes."""
|
||||
__tablename__ = "equipment_quantity_movements"
|
||||
|
||||
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
|
||||
root_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False, index=True)
|
||||
source_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True)
|
||||
target_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True)
|
||||
source_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True)
|
||||
target_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=False)
|
||||
quantity = db.Column(db.Integer, nullable=False)
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
moved_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
moved_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
source_room = db.relationship("Room", foreign_keys=[source_room_id])
|
||||
target_room = db.relationship("Room", foreign_keys=[target_room_id])
|
||||
|
||||
|
||||
class RoomConstraint(db.Model):
|
||||
"""Contrainte d'indisponibilite d'une salle (cours PRONOTE, horaire special, etc.)."""
|
||||
__tablename__ = "room_constraints"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash,
|
|||
from flask_login import login_required
|
||||
|
||||
from app_new.extensions import db
|
||||
from app_new.core.models.college import Building, Zone, Room
|
||||
from app_new.core.models.college import Site, Building, Zone, Room
|
||||
|
||||
buildings_bp = Blueprint('buildings', __name__, template_folder='templates')
|
||||
|
||||
|
|
@ -13,7 +13,22 @@ buildings_bp = Blueprint('buildings', __name__, template_folder='templates')
|
|||
def index():
|
||||
"""Liste des bâtiments."""
|
||||
buildings = Building.query.order_by(Building.name).all()
|
||||
return render_template('equipments/buildings.html', buildings=buildings)
|
||||
return render_template('equipments/buildings.html', buildings=buildings, sites=Site.query.order_by(Site.name).all())
|
||||
|
||||
|
||||
@buildings_bp.route('/sites', methods=['POST'])
|
||||
@login_required
|
||||
def create_site():
|
||||
name = (request.form.get('name') or '').strip()
|
||||
if not name:
|
||||
flash('Le nom du site est obligatoire.', 'danger')
|
||||
elif Site.query.filter_by(name=name).first():
|
||||
flash('Ce site existe déjà.', 'warning')
|
||||
else:
|
||||
db.session.add(Site(name=name, site_type=request.form.get('site_type', 'college'), address=(request.form.get('address') or '').strip() or None))
|
||||
db.session.commit()
|
||||
flash(f"Site '{name}' créé.", 'success')
|
||||
return redirect(url_for('buildings.index'))
|
||||
|
||||
|
||||
@buildings_bp.route('/new', methods=['GET', 'POST'])
|
||||
|
|
@ -25,14 +40,14 @@ def create():
|
|||
description = (request.form.get('description') or '').strip()
|
||||
if not name:
|
||||
flash('Le nom du bâtiment est requis.', 'danger')
|
||||
return render_template('equipments/building_form.html', title='Nouveau bâtiment'), 400
|
||||
building = Building(name=name, description=description)
|
||||
return render_template('equipments/building_form.html', title='Nouveau bâtiment', sites=Site.query.order_by(Site.name).all()), 400
|
||||
building = Building(name=name, description=description, site_id=request.form.get('site_id', type=int))
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
flash(f"Bâtiment '{building.name}' créé.", 'success')
|
||||
return redirect(url_for('buildings.index'), code=303)
|
||||
|
||||
return render_template('equipments/building_form.html', title='Nouveau bâtiment')
|
||||
return render_template('equipments/building_form.html', title='Nouveau bâtiment', sites=Site.query.order_by(Site.name).all())
|
||||
|
||||
|
||||
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
|
|
@ -50,9 +65,10 @@ def edit(id):
|
|||
return render_template('equipments/building_form.html',
|
||||
title='Modifier le bâtiment',
|
||||
building=building,
|
||||
zones=zones), 400
|
||||
zones=zones, sites=Site.query.order_by(Site.name).all()), 400
|
||||
building.name = name
|
||||
building.description = description
|
||||
building.site_id = request.form.get('site_id', type=int)
|
||||
db.session.commit()
|
||||
flash(f"Bâtiment '{building.name}' mis à jour.", 'success')
|
||||
return redirect(url_for('buildings.index'), code=303)
|
||||
|
|
@ -61,7 +77,7 @@ def edit(id):
|
|||
return render_template('equipments/building_form.html',
|
||||
title='Modifier le bâtiment',
|
||||
building=building,
|
||||
zones=zones)
|
||||
zones=zones, sites=Site.query.order_by(Site.name).all())
|
||||
|
||||
|
||||
@buildings_bp.route('/<int:id>/zones', methods=['POST'])
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash,
|
|||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
from app_new.extensions import db
|
||||
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction
|
||||
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
|
||||
from ..core.models.college import Room, Building, RoomType, Zone
|
||||
from ..core.models.maintenance import Lot, Intervention
|
||||
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask
|
||||
|
|
@ -13,6 +13,10 @@ def _empty_to_none(v):
|
|||
"""Convertit une chaîne vide en None."""
|
||||
return None if v == '' else v
|
||||
|
||||
|
||||
def _date_or_none(value):
|
||||
return datetime.strptime(value, '%Y-%m-%d').date() if value else None
|
||||
|
||||
main_bp = Blueprint("equipments", __name__, template_folder='templates')
|
||||
|
||||
"""
|
||||
|
|
@ -23,7 +27,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash,
|
|||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
from app_new.extensions import db
|
||||
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction
|
||||
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
|
||||
from ..core.models.college import Room, Building, RoomType, Zone
|
||||
from ..core.models.maintenance import Lot, Intervention
|
||||
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable
|
||||
|
|
@ -216,7 +220,16 @@ def create():
|
|||
status=request.form.get('status', 'en_service'),
|
||||
is_group=request.form.get('is_group') == '1',
|
||||
quantity=int(request.form.get('quantity', 1) or 1),
|
||||
parent_id=parent_id
|
||||
parent_id=parent_id,
|
||||
mobility=request.form.get('mobility', 'non_precise'),
|
||||
serial_number=_empty_to_none(request.form.get('serial_number', '').strip()),
|
||||
manufacturer=_empty_to_none(request.form.get('manufacturer', '').strip()),
|
||||
model_reference=_empty_to_none(request.form.get('model_reference', '').strip()),
|
||||
supplier_id=request.form.get('supplier_id') or None,
|
||||
supplier_reference=_empty_to_none(request.form.get('supplier_reference', '').strip()),
|
||||
purchase_price=request.form.get('purchase_price') or None,
|
||||
purchase_date=_date_or_none(request.form.get('purchase_date')),
|
||||
recurrence_monitoring=request.form.get('recurrence_monitoring') == '1',
|
||||
)
|
||||
db.session.add(equipment)
|
||||
db.session.commit()
|
||||
|
|
@ -233,6 +246,8 @@ def create():
|
|||
buildings = Building.query.order_by(Building.name).all()
|
||||
parent_equipments = Equipment.query.filter(Equipment.is_group == True, Equipment.parent_id == None).order_by(Equipment.name).all()
|
||||
lots = Lot.query.order_by(Lot.name).all()
|
||||
from ..core.models.company import Company
|
||||
companies = Company.query.order_by(Company.name).all()
|
||||
|
||||
return render_template('equipments/form.html',
|
||||
categories=categories,
|
||||
|
|
@ -241,6 +256,7 @@ def create():
|
|||
parent_equipments=parent_equipments,
|
||||
lots=lots,
|
||||
statuses=EQUIPMENT_STATUSES,
|
||||
companies=companies,
|
||||
preselected_parent_id=preselected_parent_id)
|
||||
|
||||
|
||||
|
|
@ -249,7 +265,13 @@ def create():
|
|||
def detail(id):
|
||||
"""Détail d'un équipement."""
|
||||
equipment = Equipment.query.get_or_404(id)
|
||||
return render_template('equipments/detail.html', equipment=equipment)
|
||||
root_id = equipment.parent_id or equipment.id
|
||||
if equipment.parent and equipment.parent.parent_id:
|
||||
root_id = equipment.parent.parent_id
|
||||
quantity_movements = EquipmentQuantityMovement.query.filter_by(
|
||||
root_equipment_id=root_id
|
||||
).order_by(EquipmentQuantityMovement.moved_at.desc()).limit(50).all()
|
||||
return render_template('equipments/detail.html', equipment=equipment, quantity_movements=quantity_movements)
|
||||
|
||||
|
||||
@main_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
|
|
@ -270,6 +292,15 @@ def edit(id):
|
|||
equipment.is_group = bool(request.form.get('is_group'))
|
||||
equipment.tracked_individually = bool(request.form.get('tracked_individually'))
|
||||
equipment.position = _empty_to_none(request.form.get('position'))
|
||||
equipment.mobility = request.form.get('mobility', 'non_precise')
|
||||
equipment.serial_number = _empty_to_none(request.form.get('serial_number', '').strip())
|
||||
equipment.manufacturer = _empty_to_none(request.form.get('manufacturer', '').strip())
|
||||
equipment.model_reference = _empty_to_none(request.form.get('model_reference', '').strip())
|
||||
equipment.supplier_id = request.form.get('supplier_id') or None
|
||||
equipment.supplier_reference = _empty_to_none(request.form.get('supplier_reference', '').strip())
|
||||
equipment.purchase_price = request.form.get('purchase_price') or None
|
||||
equipment.purchase_date = _date_or_none(request.form.get('purchase_date'))
|
||||
equipment.recurrence_monitoring = bool(request.form.get('recurrence_monitoring'))
|
||||
|
||||
# Position X/Y
|
||||
pos_x = request.form.get('position_x')
|
||||
|
|
@ -302,6 +333,8 @@ def edit(id):
|
|||
buildings = Building.query.order_by(Building.name).all()
|
||||
parent_equipments = Equipment.query.filter(Equipment.id != id).order_by(Equipment.name).all()
|
||||
lots = Lot.query.order_by(Lot.name).all()
|
||||
from ..core.models.company import Company
|
||||
companies = Company.query.order_by(Company.name).all()
|
||||
|
||||
# Charger les relations
|
||||
meters = Meter.query.filter_by(equipment_id=id).all()
|
||||
|
|
@ -316,6 +349,7 @@ def edit(id):
|
|||
buildings=buildings,
|
||||
parent_equipments=parent_equipments,
|
||||
lots=lots,
|
||||
companies=companies,
|
||||
meters=meters,
|
||||
equipment_consumables=equipment_consumables,
|
||||
all_consumables=all_consumables,
|
||||
|
|
@ -443,6 +477,9 @@ def mark_as_trash(id):
|
|||
"""Marquer un équipement comme à jeter."""
|
||||
equipment = Equipment.query.get_or_404(id)
|
||||
equipment.status = 'a_jeter'
|
||||
equipment.lifecycle_status = 'mise_au_rebut_planifiee'
|
||||
equipment.planned_disposal_date = _date_or_none(request.form.get('planned_disposal_date'))
|
||||
equipment.disposal_reason = _empty_to_none(request.form.get('reason', '').strip())
|
||||
db.session.commit()
|
||||
flash(f"Équipement '{equipment.name}' marqué comme à jeter.", 'warning')
|
||||
return redirect(url_for('equipments.detail', id=equipment.id))
|
||||
|
|
@ -458,6 +495,8 @@ def cancel_trash(id):
|
|||
return redirect(url_for('equipments.detail', id=equipment.id))
|
||||
|
||||
equipment.status = 'en_service'
|
||||
equipment.lifecycle_status = 'en_service'
|
||||
equipment.planned_disposal_date = None
|
||||
db.session.commit()
|
||||
flash(f"Équipement '{equipment.name}' remis en service.", 'success')
|
||||
return redirect(url_for('equipments.detail', id=equipment.id))
|
||||
|
|
@ -469,6 +508,8 @@ def confirm_trashed(id):
|
|||
"""Confirmer qu'un équipement a été jeté."""
|
||||
equipment = Equipment.query.get_or_404(id)
|
||||
equipment.status = 'jete'
|
||||
equipment.lifecycle_status = 'jete'
|
||||
equipment.disposal_date = datetime.now().date()
|
||||
equipment.room_id = None
|
||||
db.session.commit()
|
||||
flash(f"Équipement '{equipment.name}' marqué comme jeté.", 'success')
|
||||
|
|
@ -659,33 +700,118 @@ def init_scheduled_tasks(id):
|
|||
@main_bp.route('/<int:id>/move', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def move_to_room(id):
|
||||
"""Déplacer un équipement vers une autre salle."""
|
||||
from ..core.models.college import Building, Room
|
||||
from ..core.models.equipment import EquipmentRoomHistory
|
||||
|
||||
"""Déplace une unité ou une quantité, sans casser les totaux des groupes."""
|
||||
equipment = Equipment.query.get_or_404(id)
|
||||
|
||||
if equipment.mobility == 'fixe':
|
||||
flash("Cet équipement est fixe. Sa relocalisation nécessite une modification de sa fiche.", 'danger')
|
||||
return redirect(url_for('equipments.detail', id=id))
|
||||
|
||||
if request.method == 'POST':
|
||||
new_room_id = request.form.get('room_id')
|
||||
if new_room_id:
|
||||
old_room_id = equipment.room_id
|
||||
target_room_id = request.form.get('room_id', type=int)
|
||||
target_room = db.session.get(Room, target_room_id) if target_room_id else None
|
||||
if target_room is None:
|
||||
flash('La salle cible est invalide.', 'danger')
|
||||
return redirect(url_for('equipments.move_to_room', id=id))
|
||||
reason = _empty_to_none((request.form.get('reason') or '').strip())
|
||||
|
||||
# Créer l'historique du déplacement
|
||||
if old_room_id != int(new_room_id):
|
||||
history = EquipmentRoomHistory(
|
||||
equipment_id=equipment.id,
|
||||
old_room_id=old_room_id,
|
||||
new_room_id=new_room_id
|
||||
if equipment.is_group:
|
||||
quantity = request.form.get('quantity', 1, type=int)
|
||||
if quantity < 1:
|
||||
flash('La quantité doit être positive.', 'danger')
|
||||
return redirect(url_for('equipments.move_to_room', id=id))
|
||||
|
||||
root = equipment.parent if equipment.parent_id else equipment
|
||||
if equipment.parent_id:
|
||||
available = equipment.unindividualized_quantity
|
||||
else:
|
||||
allocated = sum(child.quantity or 0 for child in equipment.children if child.is_group)
|
||||
available = max((equipment.quantity or 0) - allocated, 0)
|
||||
if quantity > available:
|
||||
flash(f'Seulement {available} unité(s) non individualisée(s) sont déplaçables.', 'danger')
|
||||
return redirect(url_for('equipments.move_to_room', id=id))
|
||||
|
||||
target = Equipment.query.filter_by(
|
||||
parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False
|
||||
).first()
|
||||
if target is None:
|
||||
target = Equipment(
|
||||
name=equipment.name if equipment.parent_id else root.name,
|
||||
parent_id=root.id, room_id=target_room.id, is_group=True,
|
||||
quantity=0, status='en_service', mobility=root.mobility,
|
||||
)
|
||||
db.session.add(history)
|
||||
db.session.add(target)
|
||||
db.session.flush()
|
||||
if equipment.parent_id and target.id == equipment.id:
|
||||
flash('La salle cible est déjà la salle source.', 'warning')
|
||||
return redirect(url_for('equipments.move_to_room', id=id))
|
||||
|
||||
equipment.room_id = new_room_id
|
||||
source_room_id = equipment.room_id if equipment.parent_id else None
|
||||
if equipment.parent_id:
|
||||
equipment.quantity -= quantity
|
||||
target.quantity = (target.quantity or 0) + quantity
|
||||
db.session.add(EquipmentQuantityMovement(
|
||||
root_equipment_id=root.id, source_equipment_id=equipment.id,
|
||||
target_equipment_id=target.id, source_room_id=source_room_id,
|
||||
target_room_id=target_room.id, quantity=quantity, reason=reason,
|
||||
moved_by_id=current_user.id,
|
||||
))
|
||||
db.session.commit()
|
||||
flash(f"Équipement déplacé.", 'success')
|
||||
flash(f'{quantity} unité(s) déplacée(s) vers {target_room.name}.', 'success')
|
||||
return redirect(url_for('equipments.detail', id=root.id))
|
||||
|
||||
old_room_id = equipment.effective_room.id if equipment.effective_room else None
|
||||
if old_room_id == target_room.id:
|
||||
flash('Cet équipement est déjà dans cette salle.', 'warning')
|
||||
return redirect(url_for('equipments.move_to_room', id=id))
|
||||
equipment.room_id = target_room.id
|
||||
if equipment.parent_id and equipment.parent and equipment.parent.parent_id:
|
||||
root = equipment.parent.parent
|
||||
target_group = Equipment.query.filter_by(
|
||||
parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False
|
||||
).first()
|
||||
if target_group:
|
||||
equipment.parent_id = target_group.id
|
||||
db.session.add(EquipmentRoomHistory(
|
||||
equipment_id=equipment.id, old_room_id=old_room_id,
|
||||
new_room_id=target_room.id, changed_by_id=current_user.id, reason=reason,
|
||||
))
|
||||
db.session.commit()
|
||||
flash('Équipement déplacé.', 'success')
|
||||
return redirect(url_for('equipments.detail', id=equipment.id))
|
||||
|
||||
buildings = Building.query.order_by(Building.name).all()
|
||||
return render_template('equipments/move.html', equipment=equipment, buildings=buildings)
|
||||
rooms = Room.query.join(Building).order_by(Building.name, Room.name).all()
|
||||
return render_template(
|
||||
'equipments/move.html', equipment=equipment, rooms=rooms,
|
||||
current_room=equipment.effective_room,
|
||||
max_quantity=(equipment.unindividualized_quantity if equipment.parent_id else max(
|
||||
(equipment.quantity or 0) - sum(c.quantity or 0 for c in equipment.children if c.is_group), 0
|
||||
)) if equipment.is_group else 1,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/<int:id>/repair/start', methods=['POST'])
|
||||
@login_required
|
||||
def start_repair(id):
|
||||
equipment = Equipment.query.get_or_404(id)
|
||||
equipment.lifecycle_status = 'en_reparation'
|
||||
equipment.status = 'en_maintenance'
|
||||
equipment.unavailable_since = datetime.now()
|
||||
db.session.commit()
|
||||
flash('Équipement retiré provisoirement pour réparation.', 'warning')
|
||||
return redirect(url_for('equipments.detail', id=id))
|
||||
|
||||
|
||||
@main_bp.route('/<int:id>/repair/complete', methods=['POST'])
|
||||
@login_required
|
||||
def complete_repair(id):
|
||||
equipment = Equipment.query.get_or_404(id)
|
||||
equipment.lifecycle_status = 'en_service'
|
||||
equipment.status = 'en_service'
|
||||
equipment.unavailable_since = None
|
||||
db.session.commit()
|
||||
flash('Réparation terminée, équipement remis en service.', 'success')
|
||||
return redirect(url_for('equipments.detail', id=id))
|
||||
|
||||
|
||||
# === Gestion des Zones ===
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
<label class="form-label">Nom *</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ building.name if building else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="site_id">Site</label>
|
||||
<select id="site_id" name="site_id" class="form-select"><option value="">— Non renseigné —</option>{% for site in sites %}<option value="{{ site.id }}" {% if building and building.site_id == site.id %}selected{% endif %}>{{ site.name }}</option>{% endfor %}</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ building.description if building else '' }}</textarea>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,23 @@
|
|||
<a href="{{ url_for('buildings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Nouveau bâtiment</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4"><div class="card-header"><i class="bi bi-geo"></i> Sites</div><div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">{% for site in sites %}<span class="badge text-bg-light border">{{ site.name }} · {{ site.site_type|replace('_',' ') }}</span>{% else %}<span class="text-muted">Aucun site défini.</span>{% endfor %}</div>
|
||||
<form method="post" action="{{ url_for('buildings.create_site') }}" class="row g-2">
|
||||
<div class="col-md-4"><input class="form-control" name="name" placeholder="Ex. Collège principal" required></div>
|
||||
<div class="col-md-3"><select class="form-select" name="site_type"><option value="college">Collège</option><option value="logements">Logements de fonction</option><option value="annexe">Annexe</option><option value="autre">Autre</option></select></div>
|
||||
<div class="col-md-3"><input class="form-control" name="address" placeholder="Adresse (facultatif)"></div>
|
||||
<div class="col-md-2"><button class="btn btn-outline-primary w-100" type="submit">Ajouter</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="row">
|
||||
{% for building in buildings %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ building.name }}</h5>
|
||||
<div class="small text-muted mb-2"><i class="bi bi-geo-alt"></i> {{ building.site.name if building.site else 'Site non renseigné' }}</div>
|
||||
<p class="card-text text-muted">{{ building.description or '' }}</p>
|
||||
<p><span class="badge bg-secondary">{{ building.rooms.count() }} salle(s)</span></p>
|
||||
<div class="btn-group">
|
||||
|
|
|
|||
|
|
@ -48,16 +48,31 @@
|
|||
<a href="{{ url_for('interventions.create') }}?equipment_id={{ equipment.id }}" class="btn btn-danger">
|
||||
<i class="bi bi-wrench"></i> Intervention
|
||||
</a>
|
||||
{% if equipment.mobility != 'fixe' %}<a href="{{ url_for('equipments.move_to_room', id=equipment.id) }}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left-right"></i> Déplacer</a>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alertes -->
|
||||
{% if equipment.lifecycle_status == 'en_reparation' %}
|
||||
<div class="alert alert-warning d-flex justify-content-between align-items-center"><span><i class="bi bi-tools"></i> Retiré provisoirement pour réparation depuis {{ equipment.unavailable_since|datetime_fmt }}.</span><form method="post" action="{{ url_for('equipments.complete_repair', id=equipment.id) }}"><button class="btn btn-success btn-sm" type="submit">Réparation terminée</button></form></div>
|
||||
{% elif equipment.status != 'jete' %}
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<form method="post" action="{{ url_for('equipments.start_repair', id=equipment.id) }}"><button class="btn btn-outline-warning btn-sm" type="submit"><i class="bi bi-tools"></i> Retirer pour réparation</button></form>
|
||||
<form method="post" action="{{ url_for('equipments.mark_as_trash', id=equipment.id) }}" class="row g-2">
|
||||
<div class="col-auto"><input class="form-control form-control-sm" type="date" name="planned_disposal_date" title="Date prévue"></div>
|
||||
<div class="col-auto"><input class="form-control form-control-sm" name="reason" placeholder="Motif de mise au rebut"></div>
|
||||
<div class="col-auto"><button class="btn btn-outline-danger btn-sm" type="submit">Prévoir la mise au rebut</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if equipment.status == 'a_jeter' %}
|
||||
<div class="alert alert-danger d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Cet équipement est marqué comme à jeter.</strong>
|
||||
{% if equipment.planned_disposal_date %}<span>Date prévue : {{ equipment.planned_disposal_date|date_fmt }}.</span>{% endif %}
|
||||
{% if equipment.disposal_reason %}<span>Motif : {{ equipment.disposal_reason }}.</span>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<form action="{{ url_for('equipments.confirm_trashed', id=equipment.id) }}" method="POST" style="display:inline;">
|
||||
|
|
@ -99,6 +114,10 @@
|
|||
<th style="width: 30%;">Statut</th>
|
||||
<td><span class="badge {{ equipment.status|status_badge }}">{{ equipment.status|format_status }}</span></td>
|
||||
</tr>
|
||||
<tr><th>Mobilité</th><td>{{ equipment.mobility|replace('_', ' ')|title }}</td></tr>
|
||||
{% if equipment.serial_number %}<tr><th>Numéro de série</th><td><code>{{ equipment.serial_number }}</code></td></tr>{% endif %}
|
||||
{% if equipment.manufacturer or equipment.model_reference %}<tr><th>Fabricant / modèle</th><td>{{ equipment.manufacturer or '—' }} / {{ equipment.model_reference or '—' }}</td></tr>{% endif %}
|
||||
{% if equipment.supplier %}<tr><th>Fournisseur</th><td><a href="{{ url_for('companies.detail', id=equipment.supplier.id) }}">{{ equipment.supplier.name }}</a></td></tr>{% endif %}
|
||||
|
||||
{% if equipment.is_group %}
|
||||
<tr>
|
||||
|
|
@ -203,6 +222,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if quantity_movements %}
|
||||
<div class="card mb-3"><div class="card-header"><i class="bi bi-arrow-left-right"></i> Mouvements quantitatifs</div><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Date</th><th>Origine</th><th>Destination</th><th>Quantité</th><th>Motif</th></tr></thead><tbody>{% for movement in quantity_movements %}<tr><td>{{ movement.moved_at|datetime_fmt }}</td><td>{{ movement.source_room.full_name if movement.source_room else 'Stock non localisé' }}</td><td>{{ movement.target_room.full_name }}</td><td><strong>{{ movement.quantity }}</strong></td><td>{{ movement.reason or '—' }}</td></tr>{% endfor %}</tbody></table></div></div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Compteurs -->
|
||||
{% set meters_list = equipment.meters.all() %}
|
||||
{% if meters_list %}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3"><div class="card-header"><i class="bi bi-wrench-adjustable"></i> Données techniques</div><div class="card-body row g-3">
|
||||
<div class="col-md-4"><label class="form-label" for="mobility">Mobilité</label><select class="form-select" id="mobility" name="mobility">{% for value, label in [('non_precise','À préciser'),('mobile','Mobile'),('fixe','Fixe')] %}<option value="{{ value }}" {% if equipment.mobility == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div class="col-md-4"><label class="form-label" for="serial_number">N° de série</label><input class="form-control" id="serial_number" name="serial_number" value="{{ equipment.serial_number or '' }}"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="manufacturer">Fabricant</label><input class="form-control" id="manufacturer" name="manufacturer" value="{{ equipment.manufacturer or '' }}"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="model_reference">Modèle</label><input class="form-control" id="model_reference" name="model_reference" value="{{ equipment.model_reference or '' }}"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="supplier_id">Fournisseur</label><select class="form-select" id="supplier_id" name="supplier_id"><option value="">—</option>{% for company in companies %}<option value="{{ company.id }}" {% if equipment.supplier_id == company.id %}selected{% endif %}>{{ company.name }}</option>{% endfor %}</select></div>
|
||||
<div class="col-md-4"><label class="form-label" for="supplier_reference">Référence fournisseur</label><input class="form-control" id="supplier_reference" name="supplier_reference" value="{{ equipment.supplier_reference or '' }}"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="purchase_price">Prix d'achat</label><input class="form-control" id="purchase_price" name="purchase_price" type="number" min="0" step="0.01" value="{{ equipment.purchase_price or '' }}"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="purchase_date">Date d'achat</label><input class="form-control" id="purchase_date" name="purchase_date" type="date" value="{{ equipment.purchase_date.strftime('%Y-%m-%d') if equipment.purchase_date else '' }}"></div>
|
||||
<div class="col-md-4 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" id="recurrence_monitoring" name="recurrence_monitoring" type="checkbox" {% if equipment.recurrence_monitoring %}checked{% endif %}><label class="form-check-label" for="recurrence_monitoring">Surveiller les récurrences</label></div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-geo me-1"></i>Position
|
||||
|
|
|
|||
|
|
@ -155,6 +155,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-wrench-adjustable"></i> Suivi technique et mobilité</div>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-md-4"><label class="form-label" for="mobility">Mobilité</label><select class="form-select" id="mobility" name="mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label" for="serial_number">Numéro de série</label><input class="form-control" id="serial_number" name="serial_number"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="manufacturer">Fabricant</label><input class="form-control" id="manufacturer" name="manufacturer"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="model_reference">Modèle</label><input class="form-control" id="model_reference" name="model_reference"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="supplier_id">Fournisseur</label><select class="form-select" id="supplier_id" name="supplier_id"><option value="">—</option>{% for company in companies %}<option value="{{ company.id }}">{{ company.name }}</option>{% endfor %}</select></div>
|
||||
<div class="col-md-4"><label class="form-label" for="supplier_reference">Référence fournisseur</label><input class="form-control" id="supplier_reference" name="supplier_reference"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="purchase_price">Prix d'achat informatif</label><input class="form-control" id="purchase_price" name="purchase_price" type="number" min="0" step="0.01"></div>
|
||||
<div class="col-md-4"><label class="form-label" for="purchase_date">Date d'achat</label><input class="form-control" id="purchase_date" name="purchase_date" type="date"></div>
|
||||
<div class="col-md-4 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" id="recurrence_monitoring" name="recurrence_monitoring" value="1" type="checkbox"><label class="form-check-label" for="recurrence_monitoring">Alerter après 3 pannes récurrentes</label></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ equipment.description if equipment else '' }}</textarea>
|
||||
|
|
|
|||
|
|
@ -1,109 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Déplacer l'équipement — GMAO Collège{% endblock %}
|
||||
|
||||
{% block title %}Déplacer {{ equipment.name }} — GMAO{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="mb-4">
|
||||
<i class="bi bi-arrow-left-right"></i> Déplacer l'équipement
|
||||
</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="container-fluid" style="max-width: 850px">
|
||||
<h1 class="h3 mb-4"><i class="bi bi-arrow-left-right"></i> Déplacer {{ equipment.name }}</h1>
|
||||
<div class="card"><div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Salle actuelle</dt><dd class="col-sm-8">{{ current_room.full_name if current_room else 'Stock non localisé' }}</dd>
|
||||
<dt class="col-sm-4">Mobilité</dt><dd class="col-sm-8">{{ equipment.mobility|replace('_', ' ')|title }}</dd>
|
||||
{% if equipment.is_group %}<dt class="col-sm-4">Quantité disponible</dt><dd class="col-sm-8"><strong>{{ max_quantity }}</strong> non individualisée(s)</dd>{% endif %}
|
||||
</dl>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if equipment.is_group %}
|
||||
<div class="mb-3">
|
||||
<strong>{{ equipment.name }}</strong>
|
||||
{% if equipment.code %}<span class="text-muted">({{ equipment.code }})</span>{% endif %}
|
||||
|
||||
{% if equipment.is_individual and equipment.parent %}
|
||||
<br><small class="text-muted">Élément n°{{ equipment.individual_number }} du groupe « <a href="{{ url_for('equipments.detail', id=equipment.parent_id) }}">{{ equipment.parent.name }}</a> »</small>
|
||||
<label for="quantity" class="form-label">Quantité à déplacer</label>
|
||||
<input id="quantity" name="quantity" type="number" class="form-control" min="1" max="{{ max_quantity }}" value="1" required>
|
||||
<div class="form-text">Les unités déjà individualisées doivent être déplacées depuis leur propre fiche.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
{{ csrf_token() if csrf_token is defined }}
|
||||
|
||||
{% if equipment.is_individual and equipment.parent %}
|
||||
{# Élément individuel : doit choisir un groupe cible #}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Les éléments individuels doivent être déplacés vers un groupe du même type dans une autre salle.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Salle actuelle</label>
|
||||
<input type="text" class="form-control"
|
||||
value="{{ current_room.building.name ~ ' > ' ~ current_room.name if current_room else 'Non définie' }}"
|
||||
disabled>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="group_id" class="form-label">Groupe cible</label>
|
||||
<select class="form-select" id="group_id" name="group_id" required>
|
||||
<option value="">— Sélectionner un groupe —</option>
|
||||
{% for group in target_groups %}
|
||||
{% set group_room = group.effective_room %}
|
||||
<option value="{{ group.id }}" data-room-id="{{ group_room.id if group_room else '' }}">
|
||||
{{ group.name }}
|
||||
{% if group_room %}
|
||||
({{ group_room.building.name }} > {{ group_room.name }})
|
||||
{% endif %}
|
||||
— {{ group.quantity }} unités
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if not target_groups %}
|
||||
<small class="text-danger">Aucun groupe du même type ({{ equipment.parent.name }}) disponible dans d'autres salles.</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="room_id" id="room_id" value="">
|
||||
|
||||
{% else %}
|
||||
{# Équipement standard ou groupe #}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="room_id" class="form-label">Nouvelle salle</label>
|
||||
<select class="form-select" id="room_id" name="room_id">
|
||||
<option value="">— Aucune salle —</option>
|
||||
{% for room in rooms %}
|
||||
<option value="{{ room.id }}"
|
||||
{% if current_room and room.id == current_room.id %}selected{% endif %}>
|
||||
{{ room.building.name }} > {{ room.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<label for="room_id" class="form-label">Salle cible</label>
|
||||
<select id="room_id" name="room_id" class="form-select" required>
|
||||
<option value="">— Sélectionner —</option>
|
||||
{% for room in rooms %}<option value="{{ room.id }}">{{ room.full_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Salle actuelle</label>
|
||||
<input type="text" class="form-control"
|
||||
value="{{ current_room.building.name ~ ' > ' ~ current_room.name if current_room else 'Non définie' }}"
|
||||
disabled>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary" {% if equipment.is_individual and not target_groups %}disabled{% endif %}>
|
||||
<i class="bi bi-check-lg"></i> Déplacer
|
||||
</button>
|
||||
<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-outline-secondary">
|
||||
Annuler
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-3"><label for="reason" class="form-label">Motif</label><input id="reason" name="reason" class="form-control" maxlength="255"></div>
|
||||
<button class="btn btn-primary" type="submit" {% if equipment.is_group and max_quantity < 1 %}disabled{% endif %}><i class="bi bi-check-lg"></i> Confirmer le déplacement</button>
|
||||
<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-outline-secondary">Annuler</a>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const groupSelect = document.getElementById('group_id');
|
||||
const roomInput = document.getElementById('room_id');
|
||||
|
||||
if (groupSelect && roomInput) {
|
||||
groupSelect.addEventListener('change', function() {
|
||||
const selected = this.options[this.selectedIndex];
|
||||
const roomId = selected.dataset.roomId || '';
|
||||
roomInput.value = roomId;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
89
migrations/versions/c4d8e9f0a1b2_add_phase2_assets.py
Normal file
89
migrations/versions/c4d8e9f0a1b2_add_phase2_assets.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Ajoute sites, données techniques et mouvements quantitatifs.
|
||||
|
||||
Revision ID: c4d8e9f0a1b2
|
||||
Revises: b3c7d8e9f0a1
|
||||
Create Date: 2026-08-14
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "c4d8e9f0a1b2"
|
||||
down_revision = "b3c7d8e9f0a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"sites",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("site_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("address", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.add_column("buildings", sa.Column("site_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key("fk_buildings_site", "buildings", "sites", ["site_id"], ["id"])
|
||||
op.create_index("ix_buildings_site_id", "buildings", ["site_id"])
|
||||
|
||||
equipment_columns = (
|
||||
sa.Column("mobility", sa.String(length=20), nullable=False, server_default="non_precise"),
|
||||
sa.Column("serial_number", sa.String(length=120), nullable=True),
|
||||
sa.Column("manufacturer", sa.String(length=120), nullable=True),
|
||||
sa.Column("model_reference", sa.String(length=120), nullable=True),
|
||||
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("supplier_reference", sa.String(length=120), nullable=True),
|
||||
sa.Column("purchase_price", sa.Numeric(12, 2), nullable=True),
|
||||
sa.Column("purchase_date", sa.Date(), nullable=True),
|
||||
sa.Column("lifecycle_status", sa.String(length=30), nullable=False, server_default="en_service"),
|
||||
sa.Column("unavailable_since", sa.DateTime(), nullable=True),
|
||||
sa.Column("planned_disposal_date", sa.Date(), nullable=True),
|
||||
sa.Column("disposal_date", sa.Date(), nullable=True),
|
||||
sa.Column("disposal_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("recurrence_monitoring", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
for column in equipment_columns:
|
||||
op.add_column("equipments", column)
|
||||
op.create_foreign_key("fk_equipments_supplier", "equipments", "companies", ["supplier_id"], ["id"])
|
||||
op.create_index("ix_equipments_serial_number", "equipments", ["serial_number"])
|
||||
|
||||
op.create_table(
|
||||
"equipment_quantity_movements",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("root_equipment_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_equipment_id", sa.Integer(), nullable=True),
|
||||
sa.Column("target_equipment_id", sa.Integer(), nullable=True),
|
||||
sa.Column("source_room_id", sa.Integer(), nullable=True),
|
||||
sa.Column("target_room_id", sa.Integer(), nullable=False),
|
||||
sa.Column("quantity", sa.Integer(), nullable=False),
|
||||
sa.Column("reason", sa.String(length=255), nullable=True),
|
||||
sa.Column("moved_by_id", sa.Integer(), nullable=True),
|
||||
sa.Column("moved_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["root_equipment_id"], ["equipments.id"]),
|
||||
sa.ForeignKeyConstraint(["source_equipment_id"], ["equipments.id"]),
|
||||
sa.ForeignKeyConstraint(["target_equipment_id"], ["equipments.id"]),
|
||||
sa.ForeignKeyConstraint(["source_room_id"], ["rooms.id"]),
|
||||
sa.ForeignKeyConstraint(["target_room_id"], ["rooms.id"]),
|
||||
sa.ForeignKeyConstraint(["moved_by_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.CheckConstraint("quantity > 0", name="ck_equipment_quantity_movement_positive"),
|
||||
)
|
||||
op.create_index("ix_equipment_quantity_movements_root_equipment_id", "equipment_quantity_movements", ["root_equipment_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("equipment_quantity_movements")
|
||||
op.drop_index("ix_equipments_serial_number", table_name="equipments")
|
||||
op.drop_constraint("fk_equipments_supplier", "equipments", type_="foreignkey")
|
||||
for name in (
|
||||
"recurrence_monitoring", "disposal_reason", "disposal_date", "planned_disposal_date",
|
||||
"unavailable_since", "lifecycle_status", "purchase_date", "purchase_price",
|
||||
"supplier_reference", "supplier_id", "model_reference", "manufacturer", "serial_number", "mobility",
|
||||
):
|
||||
op.drop_column("equipments", name)
|
||||
op.drop_index("ix_buildings_site_id", table_name="buildings")
|
||||
op.drop_constraint("fk_buildings_site", "buildings", type_="foreignkey")
|
||||
op.drop_column("buildings", "site_id")
|
||||
op.drop_table("sites")
|
||||
93
tests/integration/test_equipment_lifecycle_and_transfers.py
Normal file
93
tests/integration/test_equipment_lifecycle_and_transfers.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from app_new import db
|
||||
from app_new.core.models.college import Building, Room, Site
|
||||
from app_new.core.models.equipment import Equipment, EquipmentQuantityMovement
|
||||
from app_new.core.models.user import User
|
||||
from app_new.equipments.main import complete_repair, move_to_room, start_repair
|
||||
from flask_login import login_user
|
||||
|
||||
|
||||
def _rooms(app):
|
||||
with app.app_context():
|
||||
building = Building(name='Bâtiment transfert')
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
source = Room(name='Salle source', building_id=building.id)
|
||||
target = Room(name='Salle cible', building_id=building.id)
|
||||
db.session.add_all([source, target])
|
||||
db.session.commit()
|
||||
return source.id, target.id
|
||||
|
||||
|
||||
def test_partial_group_transfer_updates_both_rooms(authenticated_client, app):
|
||||
source_room_id, target_room_id = _rooms(app)
|
||||
with app.app_context():
|
||||
root = Equipment(name='Chaises', is_group=True, quantity=30, mobility='mobile')
|
||||
db.session.add(root)
|
||||
db.session.flush()
|
||||
source = Equipment(
|
||||
name='Chaises', parent_id=root.id, room_id=source_room_id,
|
||||
is_group=True, quantity=10, mobility='mobile',
|
||||
)
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
source_id, root_id = source.id, root.id
|
||||
assert db.session.get(Room, target_room_id) is not None
|
||||
assert db.session.get(Equipment, source_id) is not None
|
||||
|
||||
admin = User.query.filter_by(role='admin').first()
|
||||
with app.test_request_context(method='POST', data={'room_id': target_room_id, 'quantity': 4, 'reason': 'Réorganisation'}):
|
||||
login_user(admin)
|
||||
response = move_to_room.__wrapped__(source_id)
|
||||
assert response.status_code == 302
|
||||
|
||||
with app.app_context():
|
||||
source = db.session.get(Equipment, source_id)
|
||||
target = Equipment.query.filter_by(parent_id=root_id, room_id=target_room_id).one()
|
||||
movement = EquipmentQuantityMovement.query.filter_by(root_equipment_id=root_id).one()
|
||||
assert source.quantity == 6
|
||||
assert target.quantity == 4
|
||||
assert movement.quantity == 4
|
||||
|
||||
|
||||
def test_fixed_equipment_cannot_be_moved(authenticated_client, app):
|
||||
source_room_id, target_room_id = _rooms(app)
|
||||
with app.app_context():
|
||||
equipment = Equipment(name='Luminaire fixe', room_id=source_room_id, mobility='fixe')
|
||||
db.session.add(equipment)
|
||||
db.session.commit()
|
||||
equipment_id = equipment.id
|
||||
admin = User.query.filter_by(role='admin').first()
|
||||
with app.test_request_context(method='POST', data={'room_id': target_room_id}):
|
||||
login_user(admin)
|
||||
response = move_to_room.__wrapped__(equipment_id)
|
||||
assert response.status_code == 302
|
||||
with app.app_context():
|
||||
assert db.session.get(Equipment, equipment_id).room_id == source_room_id
|
||||
|
||||
|
||||
def test_repair_workflow(authenticated_client, app):
|
||||
with app.app_context():
|
||||
equipment = Equipment(name='Photocopieur', mobility='mobile')
|
||||
db.session.add(equipment)
|
||||
db.session.commit()
|
||||
equipment_id = equipment.id
|
||||
admin = User.query.filter_by(role='admin').first()
|
||||
with app.test_request_context(method='POST'):
|
||||
login_user(admin)
|
||||
assert start_repair.__wrapped__(equipment_id).status_code == 302
|
||||
assert db.session.get(Equipment, equipment_id).lifecycle_status == 'en_reparation'
|
||||
with app.test_request_context(method='POST'):
|
||||
login_user(admin)
|
||||
assert complete_repair.__wrapped__(equipment_id).status_code == 302
|
||||
equipment = db.session.get(Equipment, equipment_id)
|
||||
assert equipment.lifecycle_status == 'en_service'
|
||||
assert equipment.unavailable_since is None
|
||||
|
||||
|
||||
def test_site_can_represent_staff_housing(authenticated_client, app):
|
||||
response = authenticated_client.post('/equipments/buildings/sites', data={
|
||||
'name': 'Logements de fonction', 'site_type': 'logements',
|
||||
})
|
||||
assert response.status_code == 302
|
||||
with app.app_context():
|
||||
assert Site.query.filter_by(name='Logements de fonction', site_type='logements').one()
|
||||
Loading…
Reference in a new issue