feat(planning): préparer patrimoine et tâches fractionnables
This commit is contained in:
parent
7378991f2e
commit
e15ecdf46e
25 changed files with 428 additions and 9 deletions
|
|
@ -117,7 +117,14 @@ def create():
|
||||||
def detail(id):
|
def detail(id):
|
||||||
"""Detail d'un contrat."""
|
"""Detail d'un contrat."""
|
||||||
contract = Contract.query.get_or_404(id)
|
contract = Contract.query.get_or_404(id)
|
||||||
return render_template('contracts/detail.html', contract=contract)
|
from app_new.core.models.maintenance import LotTask
|
||||||
|
from app_new.core.models.planning import ScheduledTask
|
||||||
|
covered_tasks = LotTask.query.filter_by(contract_id=contract.id).order_by(LotTask.tache).all()
|
||||||
|
covered_task_ids = [task.id for task in covered_tasks]
|
||||||
|
covered_occurrences = (ScheduledTask.query.filter(ScheduledTask.lot_task_id.in_(covered_task_ids))
|
||||||
|
.order_by(ScheduledTask.scheduled_date.desc()).all()) if covered_task_ids else []
|
||||||
|
return render_template('contracts/detail.html', contract=contract,
|
||||||
|
covered_tasks=covered_tasks, covered_occurrences=covered_occurrences)
|
||||||
|
|
||||||
|
|
||||||
@contracts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
@contracts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6><i class="bi bi-shield-check text-success"></i> Maintenance couverte par ce contrat</h6>
|
||||||
|
{% if covered_tasks %}
|
||||||
|
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Tâche / lot</th><th>Périodicité</th><th>Équipements / périmètre</th><th>Échéances</th></tr></thead><tbody>
|
||||||
|
{% for task in covered_tasks %}<tr><td><strong>{{ task.tache or 'Tâche sans nom' }}</strong><div class="small text-muted">{{ task.lot.name if task.lot else 'Lot non défini' }}</div></td><td>{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'À définir') }}</td><td>{% if task.scope_mode == 'manual' %}Sélection manuelle ({{ task.equipment_scope|length }}){% else %}Équipements actifs du lot{% endif %}</td><td>{% set occurrences = covered_occurrences|selectattr('lot_task_id','equalto',task.id)|list %}{{ occurrences|length }} occurrence(s){% if occurrences %}<div class="small text-muted">Dernière : {{ occurrences[0].scheduled_date.strftime('%d/%m/%Y') if occurrences[0].scheduled_date else '—' }}</div>{% endif %}</td></tr>{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
{% else %}<p class="text-muted mb-0">Aucune tâche préventive n'est encore rattachée à ce contrat.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm mt-3">
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
<div class="card-header d-flex justify-content-between"><span><i class="bi bi-calendar-check"></i> Visites du prestataire</span>
|
<div class="card-header d-flex justify-content-between"><span><i class="bi bi-calendar-check"></i> Visites du prestataire</span>
|
||||||
<form method="post" action="{{ url_for('contracts.generate_visits', id=contract.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Générer sur 12 mois</button></form>
|
<form method="post" action="{{ url_for('contracts.generate_visits', id=contract.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Générer sur 12 mois</button></form>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from .company import Company, Part, PartStockMovement, Alert, Service
|
||||||
from .planning import (
|
from .planning import (
|
||||||
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
||||||
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
|
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
|
||||||
PreventiveTask, PreventiveTaskConsumable, ScheduledTask,
|
PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment,
|
||||||
TechnicianAvailability, AdminTask, ZoneAccessRule
|
TechnicianAvailability, AdminTask, ZoneAccessRule
|
||||||
)
|
)
|
||||||
from .settings import AppSettings
|
from .settings import AppSettings
|
||||||
|
|
@ -37,7 +37,7 @@ __all__ = [
|
||||||
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
|
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
|
||||||
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
||||||
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
||||||
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
|
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment',
|
||||||
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
||||||
'AppSettings', 'AuditLog', 'TemplateAuditMark',
|
'AppSettings', 'AuditLog', 'TemplateAuditMark',
|
||||||
'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry',
|
'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry',
|
||||||
|
|
|
||||||
|
|
@ -459,6 +459,13 @@ class LotTask(db.Model):
|
||||||
unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend")
|
unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend")
|
||||||
after_repair_policy = db.Column(db.String(20), nullable=False, default="preserve")
|
after_repair_policy = db.Column(db.String(20), nullable=False, default="preserve")
|
||||||
is_safety_critical = db.Column(db.Boolean, nullable=False, default=False)
|
is_safety_critical = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
# Phase 2.1c : nature métier et exécution fractionnable.
|
||||||
|
task_nature = db.Column(db.String(30), nullable=False, default="unknown")
|
||||||
|
interruptible = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
splittable = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
minimum_segment_minutes = db.Column(db.Integer, nullable=True)
|
||||||
|
allow_next_day_resume = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
requires_safe_pause = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
company = db.relationship("Company")
|
company = db.relationship("Company")
|
||||||
contract = db.relationship("Contract")
|
contract = db.relationship("Contract")
|
||||||
equipment_scope = db.relationship(
|
equipment_scope = db.relationship(
|
||||||
|
|
@ -479,16 +486,50 @@ class LotTask(db.Model):
|
||||||
from .equipment import Equipment
|
from .equipment import Equipment
|
||||||
if self.scope_mode == "manual":
|
if self.scope_mode == "manual":
|
||||||
return [e for e in self.equipment_scope if not e.is_deleted]
|
return [e for e in self.equipment_scope if not e.is_deleted]
|
||||||
# `effective_lot_id` gère aussi les groupes imbriqués. Cette résolution
|
# Résolution par niveaux : un équipement est rattaché directement au
|
||||||
# reste volontairement explicite pour ne pas perdre un descendant lors
|
# lot ou hérite du lot d'un parent. On évite ainsi le N+1 de
|
||||||
# d'un déplacement de patrimoine.
|
# ``effective_lot_id`` (qui chargeait chaque parent en Python).
|
||||||
return [e for e in Equipment.query.filter(Equipment.is_deleted.is_(False)).all()
|
result = []
|
||||||
if e.effective_lot_id == self.lot_id]
|
frontier = [self.lot_id]
|
||||||
|
while frontier:
|
||||||
|
query = Equipment.query.filter(
|
||||||
|
Equipment.is_deleted.is_(False), Equipment.lot_id.in_(frontier)
|
||||||
|
)
|
||||||
|
batch = query.all()
|
||||||
|
result.extend(batch)
|
||||||
|
parent_ids = [item.id for item in batch]
|
||||||
|
if not parent_ids:
|
||||||
|
break
|
||||||
|
# Les enfants sans lot héritent du lot de leur parent. Les enfants
|
||||||
|
# ayant déjà un lot propre sont volontairement exclus de la chaîne.
|
||||||
|
inherited = Equipment.query.filter(
|
||||||
|
Equipment.is_deleted.is_(False),
|
||||||
|
Equipment.parent_id.in_(parent_ids),
|
||||||
|
Equipment.lot_id.is_(None),
|
||||||
|
).all()
|
||||||
|
result.extend(inherited)
|
||||||
|
frontier = [item.id for item in inherited]
|
||||||
|
return result
|
||||||
|
|
||||||
def duration_for_equipment(self, equipment):
|
def duration_for_equipment(self, equipment):
|
||||||
"""Durée calculée pour une cible, en tenant compte de sa quantité."""
|
"""Durée calculée pour une cible, en tenant compte de sa quantité."""
|
||||||
if not self.duration_is_configured:
|
if not self.duration_is_configured:
|
||||||
return 0
|
return 0
|
||||||
|
if self.duration_mode == "zone":
|
||||||
|
room = getattr(equipment, "room", None)
|
||||||
|
zone = getattr(room, "zone", None) if room else None
|
||||||
|
if not zone:
|
||||||
|
return 0
|
||||||
|
label = (self.tache or "").lower()
|
||||||
|
if "tonte" in label:
|
||||||
|
return int(zone.mowing_minutes or 0)
|
||||||
|
if "bordure" in label:
|
||||||
|
return int(zone.edging_minutes or 0)
|
||||||
|
if "haie" in label:
|
||||||
|
return int(zone.hedge_minutes or 0)
|
||||||
|
if any(word in label for word in ("ramassage", "évacuation", "branches")):
|
||||||
|
return int(zone.branch_removal_minutes or 0)
|
||||||
|
return 0
|
||||||
if self.duration_mode != "unit":
|
if self.duration_mode != "unit":
|
||||||
return self.duree_minutes
|
return self.duree_minutes
|
||||||
quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1)
|
quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1)
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,10 @@ class ScheduledTask(db.Model):
|
||||||
company = db.relationship("Company")
|
company = db.relationship("Company")
|
||||||
contract = db.relationship("Contract")
|
contract = db.relationship("Contract")
|
||||||
intervention = db.relationship("Intervention")
|
intervention = db.relationship("Intervention")
|
||||||
|
execution_segments = db.relationship(
|
||||||
|
"TaskExecutionSegment", back_populates="scheduled_task",
|
||||||
|
cascade="all, delete-orphan", order_by="TaskExecutionSegment.started_at",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_overdue(self):
|
def is_overdue(self):
|
||||||
|
|
@ -523,6 +527,16 @@ class ScheduledTask(db.Model):
|
||||||
|
|
||||||
def can_be_ignored(self):
|
def can_be_ignored(self):
|
||||||
return self.status == 'planned'
|
return self.status == 'planned'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def realized_minutes(self):
|
||||||
|
"""Temps réellement saisi dans les segments d'exécution."""
|
||||||
|
return sum(int(segment.duration_minutes or 0) for segment in self.execution_segments)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_minutes(self):
|
||||||
|
estimated = int(self.estimated_duration or 0)
|
||||||
|
return max(estimated - self.realized_minutes, 0) if estimated else 0
|
||||||
|
|
||||||
def ignore(self, reason=''):
|
def ignore(self, reason=''):
|
||||||
"""Marque la tache comme ignoree."""
|
"""Marque la tache comme ignoree."""
|
||||||
|
|
@ -723,3 +737,33 @@ class PlanningItem(db.Model):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<PlanningItem {self.item_type}: {self.title}>"
|
return f"<PlanningItem {self.item_type}: {self.title}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskExecutionSegment(db.Model):
|
||||||
|
"""Un segment de réalisation d'une tâche planifiée.
|
||||||
|
|
||||||
|
Plusieurs segments peuvent appartenir à la même obligation. Cela permet
|
||||||
|
de mettre en pause une tonte ou un contrôle sans perdre le temps déjà
|
||||||
|
réalisé et sans créer une fausse tâche indépendante.
|
||||||
|
"""
|
||||||
|
__tablename__ = "task_execution_segments"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
scheduled_task_id = db.Column(db.Integer, db.ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
technician_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
started_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
ended_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
duration_minutes = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="active")
|
||||||
|
pause_reason = db.Column(db.String(80), nullable=True)
|
||||||
|
note = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
scheduled_task = db.relationship("ScheduledTask", back_populates="execution_segments")
|
||||||
|
technician = db.relationship("User")
|
||||||
|
|
||||||
|
def close(self, ended_at, reason=None):
|
||||||
|
self.ended_at = ended_at
|
||||||
|
self.status = "paused" if reason else "completed"
|
||||||
|
self.pause_reason = reason
|
||||||
|
self.duration_minutes = max(int((ended_at - self.started_at).total_seconds() // 60), 0)
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,8 @@ class DayPlanner:
|
||||||
title=title,
|
title=title,
|
||||||
task_type=task_type, priority=2 if task.lot_task_id else 4,
|
task_type=task_type, priority=2 if task.lot_task_id else 4,
|
||||||
constraint="fixed" if task.scheduled_start and task.scheduled_end else "flexible",
|
constraint="fixed" if task.scheduled_start and task.scheduled_end else "flexible",
|
||||||
target_date=day, duration_minutes=int(task.estimated_duration or 30),
|
target_date=day,
|
||||||
|
duration_minutes=(task.remaining_minutes if task.execution_segments else int(task.estimated_duration or 0)),
|
||||||
room_id=task.room_id or (room.id if room else None), room_name=room.name if room else None,
|
room_id=task.room_id or (room.id if room else None), room_name=room.name if room else None,
|
||||||
zone_name=room.zone.name if room and room.zone else None,
|
zone_name=room.zone.name if room and room.zone else None,
|
||||||
building_name=room.building.name if room and room.building else None,
|
building_name=room.building.name if room and room.building else None,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,10 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
|
||||||
today = today or date.today()
|
today = today or date.today()
|
||||||
if not task.is_active:
|
if not task.is_active:
|
||||||
return []
|
return []
|
||||||
|
# Une action conditionnelle/curative est déclenchée par un constat ou une
|
||||||
|
# intervention, jamais par une échéance calendaire silencieuse.
|
||||||
|
if getattr(task, "task_nature", "unknown") == "conditional_curative" and event is None:
|
||||||
|
return []
|
||||||
if task.trigger_type in ("event", "weather") and task.trigger_event != event:
|
if task.trigger_type in ("event", "weather") and task.trigger_event != event:
|
||||||
return []
|
return []
|
||||||
if task.trigger_type == "season":
|
if task.trigger_type == "season":
|
||||||
|
|
|
||||||
71
app_new/core/services/task_execution.py
Normal file
71
app_new/core/services/task_execution.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Règles minimales d'exécution des tâches interrompables/fractionnables."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ...extensions import db
|
||||||
|
from ..models.planning import ScheduledTask, TaskExecutionSegment
|
||||||
|
|
||||||
|
|
||||||
|
PAUSE_REASONS = {
|
||||||
|
"eleves": "Présence d'élèves",
|
||||||
|
"urgence": "Urgence",
|
||||||
|
"local": "Local devenu occupé",
|
||||||
|
"materiel": "Attente de matériel",
|
||||||
|
"entreprise": "Entreprise/personne indisponible",
|
||||||
|
"meteo": "Météo",
|
||||||
|
"securite": "Sécurité",
|
||||||
|
"autre": "Autre",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def start_task(task, technician_id=None, now=None):
|
||||||
|
if not task or task.status in ("completed", "terminée"):
|
||||||
|
raise ValueError("Cette tâche est déjà terminée ou introuvable.")
|
||||||
|
if task.execution_segments and task.execution_segments[-1].status == "active":
|
||||||
|
raise ValueError("Cette tâche est déjà en cours.")
|
||||||
|
if task.lot_task and task.lot_task.minimum_segment_minutes and task.remaining_minutes < task.lot_task.minimum_segment_minutes:
|
||||||
|
raise ValueError("Le temps restant est inférieur au segment minimum.")
|
||||||
|
segment = TaskExecutionSegment(
|
||||||
|
scheduled_task=task, technician_id=technician_id,
|
||||||
|
started_at=now or _now(), status="active",
|
||||||
|
)
|
||||||
|
task.status = "in_progress"
|
||||||
|
db.session.add(segment)
|
||||||
|
return segment
|
||||||
|
|
||||||
|
|
||||||
|
def pause_task(task, reason="autre", now=None, note=None):
|
||||||
|
if not task or not task.execution_segments or task.execution_segments[-1].status != "active":
|
||||||
|
raise ValueError("Aucun segment actif à mettre en pause.")
|
||||||
|
if task.lot_task and not task.lot_task.interruptible:
|
||||||
|
raise ValueError("Cette tâche ne peut pas être interrompue.")
|
||||||
|
if reason not in PAUSE_REASONS:
|
||||||
|
raise ValueError("Motif de pause inconnu.")
|
||||||
|
segment = task.execution_segments[-1]
|
||||||
|
segment.close(now or _now(), reason=reason)
|
||||||
|
segment.note = note
|
||||||
|
task.status = "paused"
|
||||||
|
return segment
|
||||||
|
|
||||||
|
|
||||||
|
def resume_task(task, technician_id=None, now=None):
|
||||||
|
if not task or not task.lot_task or not task.lot_task.interruptible:
|
||||||
|
raise ValueError("Cette tâche ne peut pas être reprise après interruption.")
|
||||||
|
if task.remaining_minutes <= 0:
|
||||||
|
task.status = "completed"
|
||||||
|
return None
|
||||||
|
return start_task(task, technician_id=technician_id, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_task(task, now=None):
|
||||||
|
if not task or not task.execution_segments or task.execution_segments[-1].status != "active":
|
||||||
|
raise ValueError("Aucun segment actif à terminer.")
|
||||||
|
segment = task.execution_segments[-1]
|
||||||
|
segment.close(now or _now())
|
||||||
|
task.actual_duration = task.realized_minutes
|
||||||
|
task.completed_at = now or _now()
|
||||||
|
task.status = "completed"
|
||||||
|
return segment
|
||||||
|
|
@ -146,6 +146,38 @@ def create():
|
||||||
return render_template('equipments/room_form.html', **context)
|
return render_template('equipments/room_form.html', **context)
|
||||||
|
|
||||||
|
|
||||||
|
@rooms_bp.route('/bulk', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def bulk_create():
|
||||||
|
"""Création rapide de locaux similaires, sans imposer d'équipement."""
|
||||||
|
context = _room_form_context()
|
||||||
|
if request.method == 'POST':
|
||||||
|
building_id = request.form.get('building_id', type=int)
|
||||||
|
zone_id = request.form.get('zone_id', type=int)
|
||||||
|
building = db.session.get(Building, building_id) if building_id else None
|
||||||
|
zone = db.session.get(Zone, zone_id) if zone_id else None
|
||||||
|
names = [line.strip() for line in (request.form.get('names') or '').splitlines() if line.strip()]
|
||||||
|
if not building or (zone and zone.building_id != building.id) or not names:
|
||||||
|
flash('Choisissez un bâtiment valide et indiquez au moins un local.', 'danger')
|
||||||
|
return render_template('equipments/rooms_bulk.html', **context), 400
|
||||||
|
room_type_id = request.form.get('room_type_id', type=int)
|
||||||
|
created = []
|
||||||
|
existing = {room.name for room in Room.query.filter_by(building_id=building.id).all()}
|
||||||
|
for name in names:
|
||||||
|
if name in existing:
|
||||||
|
continue
|
||||||
|
created.append(Room(name=name, code=name, building_id=building.id,
|
||||||
|
zone_id=zone.id if zone else None,
|
||||||
|
room_type_id=room_type_id,
|
||||||
|
floor=request.form.get('floor', 0, type=int)))
|
||||||
|
existing.add(name)
|
||||||
|
db.session.add_all(created)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'{len(created)} local(aux) créé(s). Aucun équipement n’a été ajouté automatiquement.', 'success')
|
||||||
|
return redirect(url_for('rooms.index'))
|
||||||
|
return render_template('equipments/rooms_bulk.html', **context)
|
||||||
|
|
||||||
|
|
||||||
@rooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
@rooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit(id):
|
def edit(id):
|
||||||
|
|
|
||||||
5
app_new/equipments/templates/equipments/rooms_bulk.html
Normal file
5
app_new/equipments/templates/equipments/rooms_bulk.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Créer plusieurs locaux{% endblock %}
|
||||||
|
{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Un nom par ligne. Le profil de local reste modifiable ensuite ; aucun équipement n'est imposé.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Bâtiment</label><select class="form-select" name="building_id" required><option value="">Choisir</option>{% for building in buildings %}<option value="{{ building.id }}">{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Zone</label><select class="form-select" name="zone_id"><option value="">Aucune zone</option>{% for zone in zones %}<option value="{{ zone.id }}">{{ zone.building.name }} — {{ zone.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Profil / type de local</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for room_type in room_types %}<option value="{{ room_type.id }}">{{ room_type.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Étage</label><input class="form-control" type="number" name="floor" value="0"></div><div class="col-12"><label class="form-label">Noms des locaux</label><textarea class="form-control" name="names" rows="8" required placeholder="B101
|
||||||
|
B102
|
||||||
|
B103"></textarea></div></div><div class="card-footer"><a class="btn btn-outline-secondary" href="{{ url_for('rooms.index') }}">Annuler</a> <button class="btn btn-primary">Créer les locaux</button></div></form></div>{% endblock %}
|
||||||
|
|
@ -98,6 +98,12 @@ def _apply_task_form(task):
|
||||||
task.company_id = request.form.get('company_id', type=int) or None
|
task.company_id = request.form.get('company_id', type=int) or None
|
||||||
task.contract_id = request.form.get('contract_id', type=int) or None
|
task.contract_id = request.form.get('contract_id', type=int) or None
|
||||||
task.executor_type = request.form.get('executor_type') or ('external_company' if task.company_id else 'undefined')
|
task.executor_type = request.form.get('executor_type') or ('external_company' if task.company_id else 'undefined')
|
||||||
|
task.task_nature = request.form.get('task_nature') or 'unknown'
|
||||||
|
task.interruptible = request.form.get('interruptible') == 'on'
|
||||||
|
task.splittable = request.form.get('splittable') == 'on'
|
||||||
|
task.minimum_segment_minutes = request.form.get('minimum_segment_minutes', type=int)
|
||||||
|
task.allow_next_day_resume = request.form.get('allow_next_day_resume') == 'on'
|
||||||
|
task.requires_safe_pause = request.form.get('requires_safe_pause') == 'on'
|
||||||
if task.scope_mode == 'manual':
|
if task.scope_mode == 'manual':
|
||||||
selected = {int(value) for value in request.form.getlist('equipment_ids') if value.isdigit()}
|
selected = {int(value) for value in request.form.getlist('equipment_ids') if value.isdigit()}
|
||||||
task.equipment_scope = Equipment.query.filter(Equipment.id.in_(selected)).all() if selected else []
|
task.equipment_scope = Equipment.query.filter(Equipment.id.in_(selected)).all() if selected else []
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ def my_day():
|
||||||
for item in candidates
|
for item in candidates
|
||||||
if item.status in ('à replanifier', 'proposé', 'conflit')
|
if item.status in ('à replanifier', 'proposé', 'conflit')
|
||||||
}
|
}
|
||||||
|
execution_states = {}
|
||||||
|
for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all():
|
||||||
|
execution_states[task.id] = {
|
||||||
|
'status': task.status,
|
||||||
|
'realized': task.realized_minutes,
|
||||||
|
'remaining': task.remaining_minutes,
|
||||||
|
}
|
||||||
return render_template(
|
return render_template(
|
||||||
'planning/my_day.html',
|
'planning/my_day.html',
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
|
@ -48,6 +55,7 @@ def my_day():
|
||||||
next_date=target_date + timedelta(days=1),
|
next_date=target_date + timedelta(days=1),
|
||||||
hours_configured=bool(windows),
|
hours_configured=bool(windows),
|
||||||
alternatives=alternatives,
|
alternatives=alternatives,
|
||||||
|
execution_states=execution_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,6 +123,69 @@ def my_day_reschedule():
|
||||||
return redirect(url_for('planning.my_day', date=target_date.isoformat()))
|
return redirect(url_for('planning.my_day', date=target_date.isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_redirect(task):
|
||||||
|
return redirect(url_for('planning.my_day', date=(task.scheduled_date or date.today()).isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
@planning_bp.route('/scheduled/<int:task_id>/start', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def scheduled_start(task_id):
|
||||||
|
task = ScheduledTask.query.get_or_404(task_id)
|
||||||
|
if task.assigned_to_id not in (None, current_user.id):
|
||||||
|
flash('Cette tâche ne vous est pas attribuée.', 'danger')
|
||||||
|
return _execution_redirect(task)
|
||||||
|
from ..core.services.task_execution import start_task
|
||||||
|
try:
|
||||||
|
start_task(task, technician_id=current_user.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Tâche démarrée.', 'success')
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), 'warning')
|
||||||
|
return _execution_redirect(task)
|
||||||
|
|
||||||
|
|
||||||
|
@planning_bp.route('/scheduled/<int:task_id>/pause', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def scheduled_pause(task_id):
|
||||||
|
task = ScheduledTask.query.get_or_404(task_id)
|
||||||
|
from ..core.services.task_execution import pause_task
|
||||||
|
try:
|
||||||
|
pause_task(task, reason=request.form.get('reason', 'autre'), note=request.form.get('note'))
|
||||||
|
db.session.commit()
|
||||||
|
flash('Tâche mise en pause ; le temps réalisé est conservé.', 'success')
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), 'warning')
|
||||||
|
return _execution_redirect(task)
|
||||||
|
|
||||||
|
|
||||||
|
@planning_bp.route('/scheduled/<int:task_id>/resume', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def scheduled_resume(task_id):
|
||||||
|
task = ScheduledTask.query.get_or_404(task_id)
|
||||||
|
from ..core.services.task_execution import resume_task
|
||||||
|
try:
|
||||||
|
resume_task(task, technician_id=current_user.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Tâche reprise.', 'success')
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), 'warning')
|
||||||
|
return _execution_redirect(task)
|
||||||
|
|
||||||
|
|
||||||
|
@planning_bp.route('/scheduled/<int:task_id>/complete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def scheduled_complete(task_id):
|
||||||
|
task = ScheduledTask.query.get_or_404(task_id)
|
||||||
|
from ..core.services.task_execution import complete_task
|
||||||
|
try:
|
||||||
|
complete_task(task)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Tâche terminée.', 'success')
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), 'warning')
|
||||||
|
return _execution_redirect(task)
|
||||||
|
|
||||||
|
|
||||||
@planning_bp.route('/rooms')
|
@planning_bp.route('/rooms')
|
||||||
@login_required
|
@login_required
|
||||||
def room_schedules():
|
def room_schedules():
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
<div class="col-md-6"><label class="form-label">Entreprise exécutante</label><select class="form-select" name="company_id"><option value="">Interne / non définie</option>{% for company in companies %}<option value="{{ company.id }}" {% if task.company_id == company.id %}selected{% endif %}>{{ company.name }}</option>{% endfor %}</select></div>
|
<div class="col-md-6"><label class="form-label">Entreprise exécutante</label><select class="form-select" name="company_id"><option value="">Interne / non définie</option>{% for company in companies %}<option value="{{ company.id }}" {% if task.company_id == company.id %}selected{% endif %}>{{ company.name }}</option>{% endfor %}</select></div>
|
||||||
<div class="col-md-6"><label class="form-label">Contrat associé</label><select class="form-select" name="contract_id"><option value="">Aucun contrat</option>{% for contract in contracts %}<option value="{{ contract.id }}" {% if task.contract_id == contract.id %}selected{% endif %}>{{ contract.name }}{% if contract.contract_number %} — {{ contract.contract_number }}{% endif %}</option>{% endfor %}</select></div>
|
<div class="col-md-6"><label class="form-label">Contrat associé</label><select class="form-select" name="contract_id"><option value="">Aucun contrat</option>{% for contract in contracts %}<option value="{{ contract.id }}" {% if task.contract_id == contract.id %}selected{% endif %}>{{ contract.name }}{% if contract.contract_number %} — {{ contract.contract_number }}{% endif %}</option>{% endfor %}</select></div>
|
||||||
<div class="col-md-6"><label class="form-label">Exécutant</label><select class="form-select" name="executor_type"><option value="undefined" {% if task.executor_type == 'undefined' %}selected{% endif %}>Non défini</option><option value="internal" {% if task.executor_type == 'internal' %}selected{% endif %}>Équipe du collège</option><option value="external_company" {% if task.executor_type == 'external_company' %}selected{% endif %}>Entreprise extérieure</option><option value="department" {% if task.executor_type == 'department' %}selected{% endif %}>Département / autre service</option><option value="mixed" {% if task.executor_type == 'mixed' %}selected{% endif %}>Mixte</option></select></div>
|
<div class="col-md-6"><label class="form-label">Exécutant</label><select class="form-select" name="executor_type"><option value="undefined" {% if task.executor_type == 'undefined' %}selected{% endif %}>Non défini</option><option value="internal" {% if task.executor_type == 'internal' %}selected{% endif %}>Équipe du collège</option><option value="external_company" {% if task.executor_type == 'external_company' %}selected{% endif %}>Entreprise extérieure</option><option value="department" {% if task.executor_type == 'department' %}selected{% endif %}>Département / autre service</option><option value="mixed" {% if task.executor_type == 'mixed' %}selected{% endif %}>Mixte</option></select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Nature de la tâche</label><select class="form-select" name="task_nature"><option value="unknown" {% if task.task_nature == 'unknown' %}selected{% endif %}>À confirmer</option><option value="preventive" {% if task.task_nature == 'preventive' %}selected{% endif %}>Préventive</option><option value="conditional_curative" {% if task.task_nature == 'conditional_curative' %}selected{% endif %}>Conditionnelle / curative</option><option value="regulatory" {% if task.task_nature == 'regulatory' %}selected{% endif %}>Réglementaire</option><option value="contractual" {% if task.task_nature == 'contractual' %}selected{% endif %}>Contractuelle</option></select></div>
|
||||||
|
<div class="col-12"><fieldset class="border rounded p-3"><legend class="float-none w-auto px-2 fs-6">Exécution interrompable</legend><div class="row g-2"><div class="col-md-3 form-check"><input class="form-check-input" type="checkbox" name="interruptible" id="interruptible" {% if task.interruptible %}checked{% endif %}><label class="form-check-label" for="interruptible">Pause autorisée</label></div><div class="col-md-3 form-check"><input class="form-check-input" type="checkbox" name="splittable" id="splittable" {% if task.splittable %}checked{% endif %}><label class="form-check-label" for="splittable">Fractionnable</label></div><div class="col-md-3"><label class="form-label" for="minimum_segment_minutes">Segment minimum (min)</label><input class="form-control" type="number" min="1" name="minimum_segment_minutes" id="minimum_segment_minutes" value="{{ task.minimum_segment_minutes or '' }}"></div><div class="col-md-3 form-check"><input class="form-check-input" type="checkbox" name="allow_next_day_resume" id="allow_next_day_resume" {% if task.allow_next_day_resume %}checked{% endif %}><label class="form-check-label" for="allow_next_day_resume">Reprise le lendemain</label></div><div class="col-md-4 form-check"><input class="form-check-input" type="checkbox" name="requires_safe_pause" id="requires_safe_pause" {% if task.requires_safe_pause %}checked{% endif %}><label class="form-check-label" for="requires_safe_pause">Remise en sécurité avant pause</label></div></div></fieldset></div>
|
||||||
<div class="col-md-3 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_safety_critical" id="safety" {% if task.is_safety_critical %}checked{% endif %}><label class="form-check-label" for="safety">Tâche de sécurité</label></div>
|
<div class="col-md-3 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_safety_critical" id="safety" {% if task.is_safety_critical %}checked{% endif %}><label class="form-check-label" for="safety">Tâche de sécurité</label></div>
|
||||||
<div class="col-md-2 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_active" id="active" {% if task.is_active %}checked{% endif %}><label class="form-check-label" for="active">Active</label></div>
|
<div class="col-md-2 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_active" id="active" {% if task.is_active %}checked{% endif %}><label class="form-check-label" for="active">Active</label></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %}
|
{% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %}
|
||||||
|
{% if item.source_type == 'scheduled_task' and execution_states.get(item.source_id) %}
|
||||||
|
{% set execution = execution_states.get(item.source_id) %}
|
||||||
|
<div class="small mt-2">Temps réalisé : {{ execution.realized }} min · restant : {{ execution.remaining }} min</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mt-2">
|
||||||
|
{% if execution.status in ('planned', 'suspended', 'postponed') %}<form method="post" action="{{ url_for('planning.scheduled_start', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-success">Démarrer</button></form>{% endif %}
|
||||||
|
{% if execution.status == 'in_progress' %}<form method="post" action="{{ url_for('planning.scheduled_pause', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="reason" value="securite"><button class="btn btn-sm btn-outline-warning">Mettre en pause</button></form><form method="post" action="{{ url_for('planning.scheduled_complete', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Terminer</button></form>{% endif %}
|
||||||
|
{% if execution.status == 'paused' %}<form method="post" action="{{ url_for('planning.scheduled_resume', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-success">Reprendre</button></form>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% set item_alternatives = alternatives.get((item.source_type, item.source_id), []) %}
|
{% set item_alternatives = alternatives.get((item.source_type, item.source_id), []) %}
|
||||||
{% if item_alternatives and item.status != 'conflit' %}
|
{% if item_alternatives and item.status != 'conflit' %}
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
|
|
|
||||||
|
|
@ -571,3 +571,7 @@ d'intégration n'a été modifié. Nouveau total : 76/76.
|
||||||
doit pas être comblé par une valeur arbitraire.
|
doit pas être comblé par une valeur arbitraire.
|
||||||
- La sélection en masse filtrée des équipements et le raccord automatique des
|
- La sélection en masse filtrée des équipements et le raccord automatique des
|
||||||
durées espaces verts aux tâches de lot restent à faire.
|
durées espaces verts aux tâches de lot restent à faire.
|
||||||
|
### Phase 2.1c
|
||||||
|
|
||||||
|
- La configuration des profils d'équipements et leur proposition en masse reste progressive : la création de locaux ne crée volontairement aucun équipement sans validation.
|
||||||
|
- Le moteur de fractionnement propose encore des segments au niveau de l'exécution ; l'algorithme de placement multi-segments est documenté mais non généralisé à toutes les tâches.
|
||||||
|
|
|
||||||
|
|
@ -177,3 +177,6 @@ dans ce dépôt.
|
||||||
|
|
||||||
Aucune donnée `TEST_UI_PHASE21B_*` n'a été créée dans cette passe : les
|
Aucune donnée `TEST_UI_PHASE21B_*` n'a été créée dans cette passe : les
|
||||||
scénarios entreprise doivent être rejoués depuis l'interface avant validation.
|
scénarios entreprise doivent être rejoués depuis l'interface avant validation.
|
||||||
|
### Phase 2.1c
|
||||||
|
|
||||||
|
Le scénario `TEST_UI_PHASE21C_*` est prévu pour la validation locale : locaux standards, sanitaires avec flash SSI, salle musique, couloir/PCF, espace d'attente sécurisé, VDI, VMC, local électrique et espaces verts. Aucune donnée n'est supprimée par cette passe ; la validation UI de création en masse doit être rejouée par un administrateur de test.
|
||||||
|
|
|
||||||
26
docs/ui-audit/IMPLEMENTATION_PHASE21C.md
Normal file
26
docs/ui-audit/IMPLEMENTATION_PHASE21C.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Implémentation Phase 2.1c — patrimoine et exécution progressive
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- Version : 0.1.0-dev.11
|
||||||
|
- Migration de départ : i4d5e6f7a8b9
|
||||||
|
- Phase 2.1b : partiellement validée ; suite pytest connue : 159 pass, 1 échec et 1 erreur historiques (lookback/configuration GMAO).
|
||||||
|
- Les intégrations externes et l'ancien menu (76/76, administrateurs) sont hors modification.
|
||||||
|
|
||||||
|
## Clôture technique 2.1b
|
||||||
|
|
||||||
|
Le périmètre dynamique ne résout plus `effective_lot_id` équipement par équipement : il parcourt les rattachements directs et les descendants par niveaux SQL. Les groupes dont le lot est hérité restent pris en compte. Le scénario de charge 100 équipements doit être mesuré sur l'installation locale ; la résolution est désormais bornée par le nombre de niveaux de parenté et non par le nombre d'équipements.
|
||||||
|
|
||||||
|
Les tâches en mode `zone` utilisent les durées explicites de la zone (tonte, bordures, haies, branches). Une valeur absente donne 0 et produit `needs_duration`, sans durée générique.
|
||||||
|
|
||||||
|
La fiche contrat expose maintenant les LotTask couvertes, leurs périmètres et leurs occurrences planifiées, en complément de ContractVisit.
|
||||||
|
|
||||||
|
## Patrimoine progressif
|
||||||
|
|
||||||
|
`RoomType` est conservé comme profil de local extensible. La route `/equipments/rooms/bulk` permet de créer plusieurs locaux à partir d'une liste, avec bâtiment, zone, étage et profil facultatif. Aucun équipement n'est créé implicitement : l'administrateur valide ensuite les équipements proposés. La cartographie technique avancée reste facultative.
|
||||||
|
|
||||||
|
## Tâches interrompables
|
||||||
|
|
||||||
|
LotTask porte désormais la nature métier (`preventive`, `conditional_curative`, `regulatory`, `contractual`, `unknown`) séparément de l'exécutant, ainsi que les indicateurs d'interruption/fractionnement et le segment minimum. Les `TaskExecutionSegment` conservent le temps réalisé, les pauses et leurs motifs. Une tâche conditionnelle n'est pas générée périodiquement par le moteur.
|
||||||
|
|
||||||
|
La migration est non destructive et conserve les valeurs par défaut sûres. Les actions d'exécution complète et l'édition avancée des profils d'équipements restent à finaliser après validation UI.
|
||||||
7
docs/ui-audit/INTERRUPTIBLE_TASKS_ARCHITECTURE.md
Normal file
7
docs/ui-audit/INTERRUPTIBLE_TASKS_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Tâches interrompables et fractionnables
|
||||||
|
|
||||||
|
LotTask distingue `interruptible`, `splittable`, `minimum_segment_minutes`, `allow_next_day_resume` et `requires_safe_pause`. Une obligation ScheduledTask possède plusieurs `TaskExecutionSegment` au lieu d'un seul compteur opaque.
|
||||||
|
|
||||||
|
Le temps réalisé est la somme des segments fermés ; le temps restant est la durée estimée moins cette somme. Une pause conserve son motif (élèves, urgence, sécurité, météo, etc.) et ne termine pas la tâche. La réalisation complète passe seulement à `completed` lorsque le dernier segment est terminé.
|
||||||
|
|
||||||
|
Le moteur de proposition doit chercher un bloc continu pour une tâche non fractionnable. Pour une tâche fractionnable, il pourra proposer plusieurs segments compatibles ; la validation reste humaine. Les passages entreprise fixes restent des contraintes indépendantes.
|
||||||
|
|
@ -150,3 +150,6 @@ navigateur instrumenté sera disponible.
|
||||||
| `/lots/<id>/tasks/new` | durée et périodicité d'une action préventive | route existante |
|
| `/lots/<id>/tasks/new` | durée et périodicité d'une action préventive | route existante |
|
||||||
| assistant équipement | créer un équipement et choisir/créer son lot | route UI existante |
|
| assistant équipement | créer un équipement et choisir/créer son lot | route UI existante |
|
||||||
| `/planning/my-day` | vérifier les prérequis et la journée | aide horaires/planning ajoutée |
|
| `/planning/my-day` | vérifier les prérequis et la journée | aide horaires/planning ajoutée |
|
||||||
|
| `/equipments/rooms/bulk` | Créer plusieurs locaux similaires | Utilisateur connecté | Implémenté Phase 2.1c |
|
||||||
|
| `/contracts/<id>` | Maintenance couverte par contrat | Utilisateur connecté | Enrichi Phase 2.1c |
|
||||||
|
| `/planning/scheduled/<id>/{start,pause,resume,complete}` | Exécuter une tâche par segments | Utilisateur connecté | Implémenté Phase 2.1c |
|
||||||
|
|
|
||||||
7
docs/ui-audit/PATRIMONY_PROFILE_ARCHITECTURE.md
Normal file
7
docs/ui-audit/PATRIMONY_PROFILE_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Architecture patrimoine et profils de locaux
|
||||||
|
|
||||||
|
`Building → Zone → Room (Local / emplacement) → Equipment` reste la chaîne de base. RoomType sert de profil métier extensible : salle de classe, circulation, sanitaires, local technique, espaces verts, etc. Un profil est une proposition ; il ne crée pas automatiquement un inventaire sans validation.
|
||||||
|
|
||||||
|
La création en masse `/equipments/rooms/bulk` couvre les locaux similaires et évite la saisie répétitive. Les ouvrages (murs, sol, plafond) et les quantités d'équipements pourront être enrichis progressivement sans rendre la cartographie obligatoire.
|
||||||
|
|
||||||
|
Les équipements groupés conservent leur quantité et leur mode de gestion. Les associations aux lots restent modifiables depuis les workflows existants.
|
||||||
|
|
@ -515,3 +515,11 @@ aux administrateurs.
|
||||||
non-venue, date réelle et accompagnement ; page `Passages & contrôles`.
|
non-venue, date réelle et accompagnement ; page `Passages & contrôles`.
|
||||||
- Phase partiellement validée : sélection en masse avancée et scénario complet
|
- Phase partiellement validée : sélection en masse avancée et scénario complet
|
||||||
UI restent à compléter.
|
UI restent à compléter.
|
||||||
|
### Phase 2.1c — patrimoine et exécution progressive (en cours)
|
||||||
|
|
||||||
|
- migration `j5e6f7a8b9c0` appliquée localement ;
|
||||||
|
- résolution du périmètre dynamique par niveaux SQL ;
|
||||||
|
- durées espaces verts raccordées au calcul d'une cible ;
|
||||||
|
- fiche contrat enrichie avec la maintenance couverte ;
|
||||||
|
- création en masse de locaux disponible via `/equipments/rooms/bulk` ;
|
||||||
|
- nature métier et segments d'exécution ajoutés ; validation UI complète encore à rejouer.
|
||||||
|
|
|
||||||
|
|
@ -384,3 +384,6 @@ modifiables. Les passages entreprise disposent de trois modes et d'un suivi
|
||||||
réalisé/non-venue. La phase reste **PARTIELLEMENT VALIDÉE** : la sélection en
|
réalisé/non-venue. La phase reste **PARTIELLEMENT VALIDÉE** : la sélection en
|
||||||
masse et le scénario de bout en bout n'ont pas encore été rejoués entièrement
|
masse et le scénario de bout en bout n'ont pas encore été rejoués entièrement
|
||||||
depuis l'interface.
|
depuis l'interface.
|
||||||
|
### Phase 2.1c (état intermédiaire)
|
||||||
|
|
||||||
|
La clôture technique ajoute la migration `j5e6f7a8b9c0` : nature de tâche, propriétés interruptible/fractionnable et segments d'exécution. La création en masse de locaux et l'affichage de la maintenance couverte par contrat sont disponibles. La validation visuelle et le scénario UI complet restent à effectuer avant toute qualification définitive.
|
||||||
|
|
|
||||||
5
docs/ui-audit/TECHNICAL_MAPPING_ARCHITECTURE.md
Normal file
5
docs/ui-audit/TECHNICAL_MAPPING_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Préparation de la cartographie technique
|
||||||
|
|
||||||
|
La Phase 2.1c ne construit pas la cartographie complète. Le patrimoine garde toutefois un point d'extension pour un graphe de relations génériques : `alimente`, `protège`, `commande`, `dessert`, `connecté à`, `report de`, `asservi à`.
|
||||||
|
|
||||||
|
Cette future relation pourra relier un TGBT à un tableau puis à un sous-tableau, ainsi que les réseaux eau, gaz, chauffage, CVC, SSI et informatique. Les relations partielles et les points de coupure inconnus devront être signalés comme aide, jamais bloquer l'utilisation de Ma journée.
|
||||||
|
|
@ -378,3 +378,6 @@ de bord. La timeline affiche heure, durée, localisation, type, contrainte,
|
||||||
statut et une explication en français. Une alerte indique explicitement quand
|
statut et une explication en français. Une alerte indique explicitement quand
|
||||||
les horaires du technicien ne sont pas configurés ; aucune plage par défaut
|
les horaires du technicien ne sont pas configurés ; aucune plage par défaut
|
||||||
n'est inventée.
|
n'est inventée.
|
||||||
|
### Phase 2.1c
|
||||||
|
|
||||||
|
Présenter les locaux comme « Local / emplacement », proposer un profil sans imposer les équipements, et privilégier une création multi-locaux. Sur Ma journée, afficher le temps réalisé/restant et les actions Démarrer, Mettre en pause, Reprendre, Terminer uniquement lorsqu'elles sont supportées.
|
||||||
|
|
|
||||||
46
migrations/versions/j5e6f7a8b9c0_phase21c_execution.py
Normal file
46
migrations/versions/j5e6f7a8b9c0_phase21c_execution.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Phase 2.1c: nature métier et segments d'exécution."""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "j5e6f7a8b9c0"
|
||||||
|
down_revision = "i4d5e6f7a8b9"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
columns = [
|
||||||
|
sa.Column("task_nature", sa.String(30), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("interruptible", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("splittable", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("minimum_segment_minutes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("allow_next_day_resume", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("requires_safe_pause", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
]
|
||||||
|
for column in columns:
|
||||||
|
op.add_column("lot_tasks", column)
|
||||||
|
op.create_table(
|
||||||
|
"task_execution_segments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("scheduled_task_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("technician_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("started_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("ended_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("duration_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("pause_reason", sa.String(80), nullable=True),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["scheduled_task_id"], ["scheduled_tasks.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["technician_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_task_execution_segments_scheduled_task_id", "task_execution_segments", ["scheduled_task_id"])
|
||||||
|
op.create_index("ix_task_execution_segments_technician_id", "task_execution_segments", ["technician_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_task_execution_segments_technician_id", table_name="task_execution_segments")
|
||||||
|
op.drop_index("ix_task_execution_segments_scheduled_task_id", table_name="task_execution_segments")
|
||||||
|
op.drop_table("task_execution_segments")
|
||||||
|
for name in ("requires_safe_pause", "allow_next_day_resume", "minimum_segment_minutes", "splittable", "interruptible", "task_nature"):
|
||||||
|
op.drop_column("lot_tasks", name)
|
||||||
Loading…
Reference in a new issue