diff --git a/loko/assets/admin.py b/loko/assets/admin.py index 6d81ed6..475f3ab 100644 --- a/loko/assets/admin.py +++ b/loko/assets/admin.py @@ -682,16 +682,16 @@ from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument @admin.register(PublicBuilding) class PublicBuildingAdmin(admin.GISModelAdmin): - list_display = ("code", "name_fr", "location_type", "parent_location", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating") - list_filter = ("location_type", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") - search_fields = ("code", "name_fr", "name_nl", "address", "owner") + list_display = ("code", "name_fr", "location_type", "parent_location", "room_number", "level_number", "usage_type", "building_status", "is_heated", "is_erp", "dpe_rating") + list_filter = ("location_type", "floor_type", "is_heated", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") + search_fields = ("code", "name_fr", "name_nl", "address", "owner", "room_number") raw_id_fields = ("parent_location", "manager") @admin.register(BuildingAsset) class BuildingAssetAdmin(admin.GISModelAdmin): - list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "status", "obsolescence") - list_filter = ("asset_type", "status", "obsolescence") - search_fields = ("code", "name_fr", "name_nl", "serial_number") + list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "material", "power_kw", "status", "obsolescence", "next_control_date") + list_filter = ("asset_type", "energy_source", "glazing_type", "opening_type", "status", "obsolescence") + search_fields = ("code", "name_fr", "name_nl", "serial_number", "material", "maintenance_contract_ref", "contractor_name") raw_id_fields = ("building", "location", "parent_asset") admin.site.register(PublicBuildingDocument, AssetAdmin) diff --git a/loko/assets/forms.py b/loko/assets/forms.py index 6ad072a..a711705 100644 --- a/loko/assets/forms.py +++ b/loko/assets/forms.py @@ -287,6 +287,7 @@ class PublicBuildingForm(forms.ModelForm): 'usage_type', 'building_status', 'construction_year', 'floors_count', 'basement_count', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number', + 'room_number', 'ceiling_height_m', 'is_heated', 'fire_compartment', 'floor_type', 'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible', 'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag', 'lat', 'lon', @@ -312,6 +313,11 @@ class PublicBuildingForm(forms.ModelForm): 'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}), 'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}), 'level_number': forms.NumberInput(attrs={'class': 'form-control'}), + 'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: B-204, RDC-01'}), + 'ceiling_height_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + 'is_heated': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}), + 'floor_type': forms.Select(attrs={'class': 'form-select'}), 'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'erp_category': forms.Select(attrs={'class': 'form-select'}), 'erp_type': forms.Select(attrs={'class': 'form-select'}), @@ -327,29 +333,15 @@ class PublicBuildingForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['name_fr'].required = False - self.fields['name_nl'].required = False - self.fields['parent_location'].required = False - self.fields['location_type'].required = False - self.fields['address'].required = False - self.fields['description'].required = False - self.fields['owner'].required = False - self.fields['manager'].required = False - self.fields['occupant_entity'].required = False - self.fields['construction_year'].required = False - self.fields['floors_count'].required = False - self.fields['basement_count'].required = False - self.fields['total_surface_m2'].required = False - self.fields['usable_surface_m2'].required = False - self.fields['surface_m2'].required = False - self.fields['level_number'].required = False - self.fields['erp_category'].required = False - self.fields['erp_type'].required = False - self.fields['max_capacity'].required = False - self.fields['dpe_rating'].required = False - self.fields['ges_rating'].required = False - self.fields['lat'].required = False - self.fields['lon'].required = False + for field_name in [ + 'name_fr', 'name_nl', 'parent_location', 'location_type', 'address', 'description', + 'owner', 'manager', 'occupant_entity', 'construction_year', 'floors_count', + 'basement_count', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number', + 'room_number', 'ceiling_height_m', 'fire_compartment', 'floor_type', + 'erp_category', 'erp_type', 'max_capacity', 'dpe_rating', 'ges_rating', 'lat', 'lon' + ]: + if field_name in self.fields: + self.fields[field_name].required = False class BuildingAssetForm(forms.ModelForm): @@ -358,8 +350,14 @@ class BuildingAssetForm(forms.ModelForm): model = BuildingAsset fields = [ 'code', 'name_fr', 'name_nl', 'building', 'location', 'parent_asset', - 'asset_type', 'category', 'status', 'obsolescence', 'installation_date', - 'serial_number', 'last_control_date', 'next_control_date', 'note' + 'asset_type', 'category', 'status', 'obsolescence', + 'dimensions', 'surface_m2', 'material', + 'power_kw', 'energy_source', 'refrigerant_type', 'refrigerant_charge_kg', + 'glazing_type', 'fire_rating', 'opening_type', + 'installation_date', 'serial_number', + 'maintenance_contract_ref', 'contractor_name', + 'last_control_date', 'next_control_date', 'regulatory_periodicity_months', + 'replacement_cost_estimate', 'note' ] widgets = { 'code': forms.TextInput(attrs={'class': 'form-control'}), @@ -372,16 +370,37 @@ class BuildingAssetForm(forms.ModelForm): 'category': forms.Select(attrs={'class': 'form-select'}), 'status': forms.Select(attrs={'class': 'form-select'}), 'obsolescence': forms.Select(attrs={'class': 'form-select'}), + 'dimensions': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: 120x140 cm, 350 m²'}), + 'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + 'material': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: Aluminium, PVC, Zinc, Acier'}), + 'power_kw': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}), + 'energy_source': forms.Select(attrs={'class': 'form-select'}), + 'refrigerant_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: R32, R410A'}), + 'refrigerant_charge_kg': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + 'glazing_type': forms.Select(attrs={'class': 'form-select'}), + 'fire_rating': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: EI30, EI60, Coupe-feu 1h'}), + 'opening_type': forms.Select(attrs={'class': 'form-select'}), 'installation_date': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}), 'serial_number': forms.TextInput(attrs={'class': 'form-control'}), + 'maintenance_contract_ref': forms.TextInput(attrs={'class': 'form-control'}), + 'contractor_name': forms.TextInput(attrs={'class': 'form-control'}), 'last_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'next_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + 'regulatory_periodicity_months': forms.NumberInput(attrs={'class': 'form-control'}), + 'replacement_cost_estimate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), 'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for field_name in ['name_fr', 'name_nl', 'location', 'parent_asset', 'category', 'installation_date', 'serial_number', 'last_control_date', 'next_control_date', 'note']: + for field_name in [ + 'name_fr', 'name_nl', 'location', 'parent_asset', 'category', + 'dimensions', 'surface_m2', 'material', 'power_kw', 'energy_source', + 'refrigerant_type', 'refrigerant_charge_kg', 'glazing_type', 'fire_rating', 'opening_type', + 'installation_date', 'serial_number', 'maintenance_contract_ref', 'contractor_name', + 'last_control_date', 'next_control_date', 'regulatory_periodicity_months', + 'replacement_cost_estimate', 'note' + ]: if field_name in self.fields: self.fields[field_name].required = False from common.models import Thematic diff --git a/loko/assets/migrations/0028_buildingasset_contractor_name_and_more.py b/loko/assets/migrations/0028_buildingasset_contractor_name_and_more.py new file mode 100644 index 0000000..928f88e --- /dev/null +++ b/loko/assets/migrations/0028_buildingasset_contractor_name_and_more.py @@ -0,0 +1,118 @@ +# Generated by Django 6.0.7 on 2026-10-05 07:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0027_alter_structure_structure_type'), + ] + + operations = [ + migrations.AddField( + model_name='buildingasset', + name='contractor_name', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Prestataire / Mainteneur'), + ), + migrations.AddField( + model_name='buildingasset', + name='dimensions', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Dimensions / Format'), + ), + migrations.AddField( + model_name='buildingasset', + name='energy_source', + field=models.CharField(blank=True, choices=[('natural_gas', 'Gaz naturel'), ('electricity', 'Électricité'), ('fuel_oil', 'Fioul domestique'), ('biomass_wood', 'Biomasse / Bois / Pellets'), ('heat_network', 'Réseau de chaleur urbain'), ('heat_pump_air', 'Aérothermie (PAC air-eau / air-air)'), ('heat_pump_geo', 'Géothermie (PAC eau-eau / sol-eau)'), ('solar_thermal', 'Solaire thermique'), ('other', 'Autre source')], max_length=50, null=True, verbose_name="Source d'énergie"), + ), + migrations.AddField( + model_name='buildingasset', + name='fire_rating', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Classement au feu / Coupe-feu'), + ), + migrations.AddField( + model_name='buildingasset', + name='glazing_type', + field=models.CharField(blank=True, choices=[('single', 'Simple vitrage'), ('double_standard', 'Double vitrage standard'), ('double_low_e', 'Double vitrage ITR / Haute isolation'), ('triple', 'Triple vitrage'), ('laminated_safety', 'Vitrage feuilleté de sécurité'), ('other', 'Autre vitrage')], max_length=50, null=True, verbose_name='Type de vitrage'), + ), + migrations.AddField( + model_name='buildingasset', + name='maintenance_contract_ref', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Réf. contrat maintenance'), + ), + migrations.AddField( + model_name='buildingasset', + name='material', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Matériau principal'), + ), + migrations.AddField( + model_name='buildingasset', + name='opening_type', + field=models.CharField(blank=True, choices=[('hinged', 'Battant / À la française'), ('sliding', 'Coulissant'), ('tilt_and_turn', 'Oscillo-battant'), ('double_action', 'Va-et-vient'), ('sectional_overhead', 'Sectionnel / Basculant'), ('motorized_automatic', 'Automatique / Motorisé'), ('fixed', 'Châssis fixe / Non ouvrant'), ('other', 'Autre type')], max_length=50, null=True, verbose_name="Type d'ouverture"), + ), + migrations.AddField( + model_name='buildingasset', + name='power_kw', + field=models.FloatField(blank=True, null=True, verbose_name='Puissance nominale (kW)'), + ), + migrations.AddField( + model_name='buildingasset', + name='refrigerant_charge_kg', + field=models.FloatField(blank=True, null=True, verbose_name='Charge fluide frigorigène (kg)'), + ), + migrations.AddField( + model_name='buildingasset', + name='refrigerant_type', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Fluide frigorigène'), + ), + migrations.AddField( + model_name='buildingasset', + name='regulatory_periodicity_months', + field=models.IntegerField(blank=True, null=True, verbose_name='Périodicité contrôle réglementaire (mois)'), + ), + migrations.AddField( + model_name='buildingasset', + name='replacement_cost_estimate', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Coût estimé de renouvellement (€)'), + ), + migrations.AddField( + model_name='buildingasset', + name='surface_m2', + field=models.FloatField(blank=True, null=True, verbose_name='Surface unitaire (m²)'), + ), + migrations.AddField( + model_name='publicbuilding', + name='ceiling_height_m', + field=models.FloatField(blank=True, null=True, verbose_name='Hauteur sous plafond (m)'), + ), + migrations.AddField( + model_name='publicbuilding', + name='fire_compartment', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Secteur / Compartiment coupe-feu'), + ), + migrations.AddField( + model_name='publicbuilding', + name='floor_type', + field=models.CharField(blank=True, choices=[('tiles', 'Carrelage / Faïence'), ('parquet', 'Parquet / Bois'), ('linoleum_pvc', 'Linoléum / Vinyle / PVC'), ('concrete', 'Béton lissé / Chape / Résine'), ('carpet', 'Moquette / Textile'), ('other', 'Autre revêtement')], max_length=50, null=True, verbose_name='Revêtement de sol dominant'), + ), + migrations.AddField( + model_name='publicbuilding', + name='is_heated', + field=models.BooleanField(default=True, verbose_name='Local chauffé'), + ), + migrations.AddField( + model_name='publicbuilding', + name='room_number', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Numéro de local / porte'), + ), + migrations.AlterField( + model_name='buildingasset', + name='asset_type', + field=models.CharField(choices=[('roof', 'Toiture / Couverture / Étanchéité'), ('roof_drainage', 'Zinguerie & Évacuation EP (Gouttière, chéneau)'), ('facade', 'Façade / Bardage / Mur extérieur'), ('window', 'Fenêtre / Baie vitrée / Châssis'), ('skylight', 'Lanterneau / Verrière / Puits de lumière'), ('external_door', "Porte extérieure / Porte d'entrée / Portail"), ('shutter_blind', 'Volet / Store / Brise-soleil (BSO)'), ('insulation', 'Isolation thermique (ITE / ITI)'), ('wall', 'Mur intérieur / Cloison'), ('interior_door', 'Porte intérieure / Bloc-porte'), ('fire_door', 'Porte coupe-feu / Bloc pare-flamme'), ('ceiling', 'Faux-plafond / Plafond suspendu'), ('flooring', 'Revêtement de sol'), ('stairs', 'Escalier / Garde-corps / Main courante'), ('boiler', 'Chaudière (Gaz / Fioul / Biomasse / Pellets)'), ('heat_pump', 'Pompe à chaleur (PAC) / Climatisation réversible'), ('heat_substation', "Sous-station d'échangeur thermique / Réseau urbain"), ('radiator_emitter', 'Émetteur de chaleur (Radiateur, convecteur, plancher)'), ('air_handling_unit', "Centrale de traitement d'air (CTA) / VMC"), ('air_conditioning', 'Climatisation / Groupe froid / Chiller / VRV'), ('water_heater', 'Production Eau Chaude Sanitaire (Ballon ECS)'), ('hvac', 'Chauffage / CVC / Régulation générale'), ('electricity', 'Électricité / TGBT / Tableau divisionnaire'), ('inverter_generator', 'Onduleur / Groupe électrogène de secours'), ('lighting', 'Éclairage intérieur / Luminaires'), ('emergency_lighting', 'Éclairage de sécurité / BAES / BAEH'), ('meter', 'Compteur (Eau / Gaz / Électricité / Calories)'), ('solar_panel', 'Panneaux solaires (Photovoltaïque / Thermique)'), ('ev_charging', 'Borne de recharge véhicule électrique'), ('sanitary', 'Plomberie / Sanitaires (WC, lavabo, douche)'), ('plumbing_piping', "Réseau d'eau / Vanne d'arrêt / Disconnecteur"), ('sump_pump', 'Pompe de relevage (Eaux usées / Eaux pluviales)'), ('rainwater_harvesting', "Récupération d'eau de pluie / Cuve de stockage"), ('fire_safety', 'Sécurité incendie / Centrale SSI / CMSI'), ('smoke_detector', 'Détecteur de fumée / Déclencheur manuel (DM)'), ('fire_extinguisher', "Extincteur / Robinet d'Incendie Armé (RIA)"), ('smoke_exhaust', 'Désenfumage / Trappe / Skydome'), ('access_control', "Contrôle d'accès (Badge, interphone, gâche)"), ('alarm', 'Alarme anti-intrusion / Détecteur de présence'), ('cctv_surveillance', 'Vidéosurveillance / Caméra CCTV'), ('elevator', 'Ascenseur / Monte-charge'), ('disabled_lift', 'Élévateur PMR / Plateforme élévatrice'), ('network_rack', 'Baie de brassage / Rack informatique / RJ45'), ('bms_system', 'Gestion Technique du Bâtiment (GTB / GTC)'), ('civil_engineering', 'Génie civil / Bâti / Gros œuvre'), ('other', 'Autre équipement / asset')], default='civil_engineering', max_length=50, verbose_name="Type d'asset / Équipement"), + ), + migrations.AlterField( + model_name='publicbuilding', + name='location_type', + field=models.CharField(choices=[('site', 'Site / Complexe'), ('building', 'Bâtiment / Bloc'), ('wing', 'Aile / Corps de bâtiment'), ('level', 'Niveau / Étage'), ('basement', 'Sous-sol / Cave'), ('attic', 'Combles / Grenier / Toiture-terrasse'), ('mezzanine', 'Mezzanine'), ('stairwell', "Cage d'escalier"), ('corridor', 'Couloir / Coursive / Dégagement'), ('lobby', "Hall d'accueil / Entrée / Sas"), ('emergency_exit', "Issue de secours / Sas d'évacuation"), ('elevator_shaft', "Gaine / Palier d'ascenseur"), ('technical_room', 'Local technique général'), ('boiler_room', 'Chaufferie / Sous-station thermique'), ('electrical_room', 'Local TGBT / Électrique'), ('ventilation_room', 'Local ventilation / CTA'), ('elevator_machine_room', 'Local machinerie ascenseur'), ('server_room', 'Salle serveurs / Baie informatique'), ('room', 'Pièce / Local polyvalent'), ('office', 'Bureau / Espace de travail'), ('meeting_room', 'Salle de réunion / Conférence'), ('classroom', 'Salle de classe / Formation'), ('workshop', 'Atelier / Laboratoire'), ('sanitary_room', 'Sanitaires / Vestiaires / Douches'), ('storage_room', 'Réserve / Stockage / Archives'), ('canteen_kitchen', 'Cuisine / Réfectoire / Restauration'), ('cleaning_room', 'Local ménage / Entretien'), ('waste_room', 'Local poubelles / Déchets'), ('indoor_parking', 'Parking couvert / Garage'), ('outdoor_area', 'Abords / Cour / Parvis / Terrasse'), ('other', 'Autre localisation')], default='building', max_length=50, verbose_name='Type de localisation'), + ), + ] diff --git a/loko/assets/models/buildings.py b/loko/assets/models/buildings.py index 08fd988..9734b47 100644 --- a/loko/assets/models/buildings.py +++ b/loko/assets/models/buildings.py @@ -77,31 +77,148 @@ ERP_TYPE_CHOICES = ( ) LOCATION_TYPE_CHOICES = ( + # Ensembles & Bâtiments ('site', _('Site / Complexe')), ('building', _('Bâtiment / Bloc')), + ('wing', _('Aile / Corps de bâtiment')), + # Niveaux & Hauteurs ('level', _('Niveau / Étage')), - ('room', _('Pièce / Local')), + ('basement', _('Sous-sol / Cave')), + ('attic', _('Combles / Grenier / Toiture-terrasse')), + ('mezzanine', _('Mezzanine')), + # Circulations & Dégagements + ('stairwell', _('Cage d\'escalier')), + ('corridor', _('Couloir / Coursive / Dégagement')), + ('lobby', _('Hall d\'accueil / Entrée / Sas')), + ('emergency_exit', _('Issue de secours / Sas d\'évacuation')), + ('elevator_shaft', _('Gaine / Palier d\'ascenseur')), + # Locaux techniques + ('technical_room', _('Local technique général')), + ('boiler_room', _('Chaufferie / Sous-station thermique')), + ('electrical_room', _('Local TGBT / Électrique')), + ('ventilation_room', _('Local ventilation / CTA')), + ('elevator_machine_room', _('Local machinerie ascenseur')), + ('server_room', _('Salle serveurs / Baie informatique')), + # Locaux de travail, d'accueil & services + ('room', _('Pièce / Local polyvalent')), + ('office', _('Bureau / Espace de travail')), + ('meeting_room', _('Salle de réunion / Conférence')), + ('classroom', _('Salle de classe / Formation')), + ('workshop', _('Atelier / Laboratoire')), + ('sanitary_room', _('Sanitaires / Vestiaires / Douches')), + ('storage_room', _('Réserve / Stockage / Archives')), + ('canteen_kitchen', _('Cuisine / Réfectoire / Restauration')), + ('cleaning_room', _('Local ménage / Entretien')), + ('waste_room', _('Local poubelles / Déchets')), + ('indoor_parking', _('Parking couvert / Garage')), + # Espaces extérieurs immédiats + ('outdoor_area', _('Abords / Cour / Parvis / Terrasse')), + # Autre ('other', _('Autre localisation')), ) +FLOOR_TYPE_CHOICES = ( + ('tiles', _('Carrelage / Faïence')), + ('parquet', _('Parquet / Bois')), + ('linoleum_pvc', _('Linoléum / Vinyle / PVC')), + ('concrete', _('Béton lissé / Chape / Résine')), + ('carpet', _('Moquette / Textile')), + ('other', _('Autre revêtement')), +) + BUILDING_ASSET_TYPE_CHOICES = ( - ('civil_engineering', _('Génie civil / Bâti')), - ('facade', _('Façade / Mur extérieur')), + # Clos, couvert & enveloppe + ('roof', _('Toiture / Couverture / Étanchéité')), + ('roof_drainage', _('Zinguerie & Évacuation EP (Gouttière, chéneau)')), + ('facade', _('Façade / Bardage / Mur extérieur')), + ('window', _('Fenêtre / Baie vitrée / Châssis')), + ('skylight', _('Lanterneau / Verrière / Puits de lumière')), + ('external_door', _('Porte extérieure / Porte d\'entrée / Portail')), + ('shutter_blind', _('Volet / Store / Brise-soleil (BSO)')), + ('insulation', _('Isolation thermique (ITE / ITI)')), + # Second œuvre & aménagements intérieurs ('wall', _('Mur intérieur / Cloison')), - ('roof', _('Toiture / Couverture')), - ('hvac', _('Chauffage / CVC / Chaudière')), + ('interior_door', _('Porte intérieure / Bloc-porte')), + ('fire_door', _('Porte coupe-feu / Bloc pare-flamme')), + ('ceiling', _('Faux-plafond / Plafond suspendu')), + ('flooring', _('Revêtement de sol')), + ('stairs', _('Escalier / Garde-corps / Main courante')), + # Génie climatique & thermique (CVC) + ('boiler', _('Chaudière (Gaz / Fioul / Biomasse / Pellets)')), + ('heat_pump', _('Pompe à chaleur (PAC) / Climatisation réversible')), + ('heat_substation', _('Sous-station d\'échangeur thermique / Réseau urbain')), + ('radiator_emitter', _('Émetteur de chaleur (Radiateur, convecteur, plancher)')), + ('air_handling_unit', _('Centrale de traitement d\'air (CTA) / VMC')), + ('air_conditioning', _('Climatisation / Groupe froid / Chiller / VRV')), + ('water_heater', _('Production Eau Chaude Sanitaire (Ballon ECS)')), + ('hvac', _('Chauffage / CVC / Régulation générale')), + # Électricité, Énergie & Réseaux + ('electricity', _('Électricité / TGBT / Tableau divisionnaire')), + ('inverter_generator', _('Onduleur / Groupe électrogène de secours')), + ('lighting', _('Éclairage intérieur / Luminaires')), + ('emergency_lighting', _('Éclairage de sécurité / BAES / BAEH')), + ('meter', _('Compteur (Eau / Gaz / Électricité / Calories)')), + ('solar_panel', _('Panneaux solaires (Photovoltaïque / Thermique)')), + ('ev_charging', _('Borne de recharge véhicule électrique')), + # Plomberie & Sanitaires + ('sanitary', _('Plomberie / Sanitaires (WC, lavabo, douche)')), + ('plumbing_piping', _('Réseau d\'eau / Vanne d\'arrêt / Disconnecteur')), + ('sump_pump', _('Pompe de relevage (Eaux usées / Eaux pluviales)')), + ('rainwater_harvesting', _('Récupération d\'eau de pluie / Cuve de stockage')), + # Sécurité incendie, sûreté & contrôle d'accès + ('fire_safety', _('Sécurité incendie / Centrale SSI / CMSI')), + ('smoke_detector', _('Détecteur de fumée / Déclencheur manuel (DM)')), + ('fire_extinguisher', _('Extincteur / Robinet d\'Incendie Armé (RIA)')), + ('smoke_exhaust', _('Désenfumage / Trappe / Skydome')), + ('access_control', _('Contrôle d\'accès (Badge, interphone, gâche)')), + ('alarm', _('Alarme anti-intrusion / Détecteur de présence')), + ('cctv_surveillance', _('Vidéosurveillance / Caméra CCTV')), + # Mobilité verticale ('elevator', _('Ascenseur / Monte-charge')), - ('electricity', _('Électricité / TGBT')), - ('fire_safety', _('Sécurité incendie / Extincteur / RIA')), - ('alarm', _('Alarme / Contrôle d\'accès')), - ('meter', _('Compteur (Eau / Gaz / Électricité)')), - ('sanitary', _('Plomberie / Sanitaires')), + ('disabled_lift', _('Élévateur PMR / Plateforme élévatrice')), + # Réseaux, GTB & Automatismes + ('network_rack', _('Baie de brassage / Rack informatique / RJ45')), + ('bms_system', _('Gestion Technique du Bâtiment (GTB / GTC)')), + # Gros œuvre & Divers + ('civil_engineering', _('Génie civil / Bâti / Gros œuvre')), ('other', _('Autre équipement / asset')), ) # For backward compatibility EQUIPMENT_TYPE_CHOICES = BUILDING_ASSET_TYPE_CHOICES +ENERGY_SOURCE_CHOICES = ( + ('natural_gas', _('Gaz naturel')), + ('electricity', _('Électricité')), + ('fuel_oil', _('Fioul domestique')), + ('biomass_wood', _('Biomasse / Bois / Pellets')), + ('heat_network', _('Réseau de chaleur urbain')), + ('heat_pump_air', _('Aérothermie (PAC air-eau / air-air)')), + ('heat_pump_geo', _('Géothermie (PAC eau-eau / sol-eau)')), + ('solar_thermal', _('Solaire thermique')), + ('other', _('Autre source')), +) + +GLAZING_TYPE_CHOICES = ( + ('single', _('Simple vitrage')), + ('double_standard', _('Double vitrage standard')), + ('double_low_e', _('Double vitrage ITR / Haute isolation')), + ('triple', _('Triple vitrage')), + ('laminated_safety', _('Vitrage feuilleté de sécurité')), + ('other', _('Autre vitrage')), +) + +OPENING_TYPE_CHOICES = ( + ('hinged', _('Battant / À la française')), + ('sliding', _('Coulissant')), + ('tilt_and_turn', _('Oscillo-battant')), + ('double_action', _('Va-et-vient')), + ('sectional_overhead', _('Sectionnel / Basculant')), + ('motorized_automatic', _('Automatique / Motorisé')), + ('fixed', _('Châssis fixe / Non ouvrant')), + ('other', _('Autre type')), +) + class PublicBuilding(AbstractLocation): """ @@ -133,6 +250,11 @@ class PublicBuilding(AbstractLocation): usable_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface utile (m²)")) surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface spécifique (m²)")) level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (ex: 0=RDC, 1=1er, -1=Sous-sol)")) + room_number = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Numéro de local / porte")) + ceiling_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur sous plafond (m)")) + is_heated = models.BooleanField(default=True, verbose_name=_("Local chauffé")) + fire_compartment = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Secteur / Compartiment coupe-feu")) + floor_type = models.CharField(max_length=50, choices=FLOOR_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Revêtement de sol dominant")) floor_plan = models.FileField(upload_to='buildings/plans/%Y/%m/', storage=private_storage, null=True, blank=True, verbose_name=_("Plan d'étage")) # Réglementation & Accueil du Public @@ -166,6 +288,44 @@ class PublicBuilding(AbstractLocation): return f"Administratif ({terms['admin_building']})" return dict(USAGE_TYPE_CHOICES).get(self.usage_type, self.usage_type) + def get_icon_class(self): + """ + Retourne la classe d'icône Bootstrap adaptée au type de localisation. + """ + mapping = { + 'site': 'bi-diagram-3', + 'building': 'bi-building', + 'wing': 'bi-building-add', + 'level': 'bi-layers', + 'basement': 'bi-layer-backward', + 'attic': 'bi-triangle', + 'mezzanine': 'bi-border-inner', + 'stairwell': 'bi-stairs', + 'corridor': 'bi-arrow-left-right', + 'lobby': 'bi-door-open-fill', + 'emergency_exit': 'bi-box-arrow-right', + 'elevator_shaft': 'bi-arrow-down-up', + 'technical_room': 'bi-tools', + 'boiler_room': 'bi-fire', + 'electrical_room': 'bi-lightning-charge', + 'ventilation_room': 'bi-fan', + 'elevator_machine_room': 'bi-gear-wide-connected', + 'server_room': 'bi-hdd-network', + 'room': 'bi-door-open', + 'office': 'bi-briefcase', + 'meeting_room': 'bi-people', + 'classroom': 'bi-mortarboard', + 'workshop': 'bi-hammer', + 'sanitary_room': 'bi-badge-wc', + 'storage_room': 'bi-archive', + 'canteen_kitchen': 'bi-cup-hot', + 'cleaning_room': 'bi-bucket', + 'waste_room': 'bi-trash', + 'indoor_parking': 'bi-car-front', + 'outdoor_area': 'bi-tree', + } + return mapping.get(self.location_type, 'bi-geo-alt') + def get_child_locations(self): return self.child_locations.all() @@ -202,14 +362,36 @@ class PublicBuilding(AbstractLocation): class BuildingAsset(AbstractGeoAsset): """ Modèle représentant un asset ou équipement physique du bâtiment (Génie civil / Bâti, Façades, Toitures, CVC, Ascenseurs...). - Les assets peuvent former une arborescence parent-enfant (ex: Génie civil -> Façade Nord, Toiture, Ascenseur). + Les assets peuvent former une arborescence parent-enfant (ex: Bâti -> Façade -> Châssis, ou Chaudière -> Brûleur). """ building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="all_building_assets", verbose_name=_("Bâtiment / Site principal")) location = models.ForeignKey(PublicBuilding, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique")) parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent")) asset_type = models.CharField(max_length=50, choices=BUILDING_ASSET_TYPE_CHOICES, default='civil_engineering', verbose_name=_("Type d'asset / Équipement")) + + # Caractéristiques physiques & dimensionnelles + dimensions = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Dimensions / Format")) + surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface unitaire (m²)")) + material = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Matériau principal")) + + # Caractéristiques thermiques, énergétiques & techniques + power_kw = models.FloatField(null=True, blank=True, verbose_name=_("Puissance nominale (kW)")) + energy_source = models.CharField(max_length=50, choices=ENERGY_SOURCE_CHOICES, null=True, blank=True, verbose_name=_("Source d'énergie")) + refrigerant_type = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Fluide frigorigène")) + refrigerant_charge_kg = models.FloatField(null=True, blank=True, verbose_name=_("Charge fluide frigorigène (kg)")) + + # Menuiseries, ouvrants & protection incendie + glazing_type = models.CharField(max_length=50, choices=GLAZING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type de vitrage")) + fire_rating = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Classement au feu / Coupe-feu")) + opening_type = models.CharField(max_length=50, choices=OPENING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type d'ouverture")) + + # Maintenance, exploitation & suivi réglementaire + maintenance_contract_ref = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Réf. contrat maintenance")) + contractor_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Prestataire / Mainteneur")) last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle réglementaire")) next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire")) + regulatory_periodicity_months = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité contrôle réglementaire (mois)")) + replacement_cost_estimate = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name=_("Coût estimé de renouvellement (€)")) def clean(self): super().clean() @@ -246,6 +428,123 @@ class BuildingAsset(AbstractGeoAsset): def get_child_assets(self): return self.child_assets.all() + def get_macro_lot(self): + """ + Retourne le macro-lot / domaine technique de l'équipement. + """ + lots = { + # Clos & Couvert + 'roof': _('Clos & Couvert'), + 'roof_drainage': _('Clos & Couvert'), + 'facade': _('Clos & Couvert'), + 'window': _('Clos & Couvert'), + 'skylight': _('Clos & Couvert'), + 'external_door': _('Clos & Couvert'), + 'shutter_blind': _('Clos & Couvert'), + 'insulation': _('Clos & Couvert'), + # Aménagements & Second œuvre + 'wall': _('Aménagements & Second œuvre'), + 'interior_door': _('Aménagements & Second œuvre'), + 'fire_door': _('Aménagements & Second œuvre'), + 'ceiling': _('Aménagements & Second œuvre'), + 'flooring': _('Aménagements & Second œuvre'), + 'stairs': _('Aménagements & Second œuvre'), + # Génie climatique & CVC + 'boiler': _('Génie climatique & CVC'), + 'heat_pump': _('Génie climatique & CVC'), + 'heat_substation': _('Génie climatique & CVC'), + 'radiator_emitter': _('Génie climatique & CVC'), + 'air_handling_unit': _('Génie climatique & CVC'), + 'air_conditioning': _('Génie climatique & CVC'), + 'water_heater': _('Génie climatique & CVC'), + 'hvac': _('Génie climatique & CVC'), + # Électricité & Énergie + 'electricity': _('Électricité & Énergie'), + 'inverter_generator': _('Électricité & Énergie'), + 'lighting': _('Électricité & Éclairage'), + 'emergency_lighting': _('Électricité & Sécurité'), + 'meter': _('Fluides & Comptage'), + 'solar_panel': _('Énergies renouvelables'), + 'ev_charging': _('Mobilité électrique'), + # Plomberie & Sanitaires + 'sanitary': _('Plomberie & Sanitaires'), + 'plumbing_piping': _('Plomberie & Réseaux'), + 'sump_pump': _('Assainissement & Relevage'), + 'rainwater_harvesting': _('Gestion des eaux pluviales'), + # Sécurité incendie & Sûreté + 'fire_safety': _('Sécurité incendie'), + 'smoke_detector': _('Sécurité incendie'), + 'fire_extinguisher': _('Sécurité incendie'), + 'smoke_exhaust': _('Sécurité incendie'), + 'access_control': _('Sûreté & Contrôle d\'accès'), + 'alarm': _('Sûreté & Contrôle d\'accès'), + 'cctv_surveillance': _('Vidéosurveillance'), + # Mobilité verticale + 'elevator': _('Ascenseurs & Élévateurs'), + 'disabled_lift': _('Ascenseurs & Élévateurs'), + # Réseaux & Numérique + 'network_rack': _('Réseaux & Télécom'), + 'bms_system': _('Gestion Technique du Bâtiment (GTB)'), + # Gros œuvre & Divers + 'civil_engineering': _('Génie civil & Structure'), + 'other': _('Autre équipement'), + } + return lots.get(self.asset_type, _('Autre')) + + def get_icon_class(self): + """ + Retourne l'icône Bootstrap adaptée au type d'asset. + """ + icons = { + 'roof': 'bi-house-gear', + 'roof_drainage': 'bi-water', + 'facade': 'bi-border-style', + 'window': 'bi-window', + 'skylight': 'bi-sun', + 'external_door': 'bi-door-closed', + 'shutter_blind': 'bi-blinds', + 'insulation': 'bi-shield', + 'wall': 'bi-bricks', + 'interior_door': 'bi-door-open', + 'fire_door': 'bi-shield-shaded', + 'ceiling': 'bi-grid-3x3', + 'flooring': 'bi-square', + 'stairs': 'bi-stairs', + 'boiler': 'bi-fire', + 'heat_pump': 'bi-snow', + 'heat_substation': 'bi-shuffle', + 'radiator_emitter': 'bi-thermometer-high', + 'air_handling_unit': 'bi-fan', + 'air_conditioning': 'bi-wind', + 'water_heater': 'bi-droplet-half', + 'hvac': 'bi-thermometer-half', + 'electricity': 'bi-lightning-charge', + 'inverter_generator': 'bi-battery-charging', + 'lighting': 'bi-lightbulb', + 'emergency_lighting': 'bi-exclamation-triangle', + 'meter': 'bi-speedometer2', + 'solar_panel': 'bi-sun-fill', + 'ev_charging': 'bi-ev-station', + 'sanitary': 'bi-badge-wc', + 'plumbing_piping': 'bi-pipette', + 'sump_pump': 'bi-arrow-up-circle', + 'rainwater_harvesting': 'bi-cloud-rain', + 'fire_safety': 'bi-shield-shaded', + 'smoke_detector': 'bi-bell', + 'fire_extinguisher': 'bi-cone-striped', + 'smoke_exhaust': 'bi-cloud-arrow-up', + 'access_control': 'bi-key', + 'alarm': 'bi-shield-exclamation', + 'cctv_surveillance': 'bi-camera-video', + 'elevator': 'bi-arrow-down-up', + 'disabled_lift': 'bi-person-wheelchair', + 'network_rack': 'bi-hdd-rack', + 'bms_system': 'bi-cpu', + 'civil_engineering': 'bi-building-gear', + 'other': 'bi-gear', + } + return icons.get(self.asset_type, 'bi-gear') + # Backward compatibility alias BuildingEquipment = BuildingAsset @@ -266,3 +565,4 @@ class PublicBuildingDocument(Document): class Meta: verbose_name = _("Document de bâtiment") verbose_name_plural = _("Documents de bâtiment") + diff --git a/loko/assets/templates/assets/buildings/publicbuilding_asset_tree.html b/loko/assets/templates/assets/buildings/publicbuilding_asset_tree.html index 989e22b..1c0b7af 100644 --- a/loko/assets/templates/assets/buildings/publicbuilding_asset_tree.html +++ b/loko/assets/templates/assets/buildings/publicbuilding_asset_tree.html @@ -17,13 +17,14 @@ {% else %} {% endif %} - + {{ asset_node.asset.code }} {{ asset_node.asset.get_name }} + {{ asset_node.asset.get_macro_lot }} {{ asset_node.asset.get_asset_type_display }} diff --git a/loko/assets/templates/assets/buildings/publicbuilding_detail.html b/loko/assets/templates/assets/buildings/publicbuilding_detail.html index c9fb2b2..8fb7eb5 100644 --- a/loko/assets/templates/assets/buildings/publicbuilding_detail.html +++ b/loko/assets/templates/assets/buildings/publicbuilding_detail.html @@ -133,6 +133,40 @@ {% translate "Surface Utile / Brut" %} : {{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m² + {% if building.room_number %} + + {% translate "Numéro de local / porte" %} : + {{ building.room_number }} + + {% endif %} + {% if building.surface_m2 %} + + {% translate "Surface spécifique" %} : + {{ building.surface_m2 }} m² + + {% endif %} + {% if building.ceiling_height_m %} + + {% translate "Hauteur sous plafond" %} : + {{ building.ceiling_height_m }} m + + {% endif %} + + {% translate "Chauffage du local" %} : + {% if building.is_heated %}{% translate "Chauffé" %}{% else %}{% translate "Non chauffé" %}{% endif %} + + {% if building.floor_type %} + + {% translate "Revêtement de sol" %} : + {{ building.get_floor_type_display }} + + {% endif %} + {% if building.fire_compartment %} + + {% translate "Compartiment coupe-feu" %} : + {{ building.fire_compartment }} + + {% endif %} {{ regional_terms.public_access_category }} : {{ building.erp_category|default:"-" }} @@ -178,8 +212,11 @@
- + {{ building.code }} + {% if building.room_number %} + {{ building.room_number }} + {% endif %} - {{ building.get_name }} {{ building.get_location_type_display }} @@ -299,11 +336,13 @@ {% translate "Code / Nom" %} - {% translate "Type d'asset" %} + {% translate "Lot / Type d'asset" %} + {% translate "Caractéristiques & Spécifications" %} {% translate "Localisation spécifique" %} {% translate "Asset Parent" %} {% translate "Statut" %} {% translate "Prochain contrôle" %} + {% translate "Maintenance & Prestataire" %} {% translate "Actions" %} @@ -311,18 +350,39 @@ {% for eq in equipments %} - {{ eq.code }} + + {{ eq.code }} +
{{ eq.get_name }}
- + {{ eq.get_macro_lot }} + {{ eq.get_asset_type_display }} + + {% if eq.dimensions or eq.surface_m2 %} +
{% if eq.dimensions %}{{ eq.dimensions }}{% endif %}{% if eq.surface_m2 %} ({{ eq.surface_m2 }} m²){% endif %}
+ {% endif %} + {% if eq.power_kw or eq.energy_source %} +
+ {% if eq.power_kw %}{{ eq.power_kw }} kW{% endif %} + {% if eq.energy_source %}{{ eq.get_energy_source_display }}{% endif %} +
+ {% endif %} + {% if eq.material or eq.fire_rating or eq.glazing_type %} +
+ {% if eq.material %}{{ eq.material }}{% endif %} + {% if eq.glazing_type %} - {{ eq.get_glazing_type_display }}{% endif %} + {% if eq.fire_rating %}{{ eq.fire_rating }}{% endif %} +
+ {% endif %} + {% if eq.location %} - {{ eq.location.code }} - {{ eq.location.get_name }} + {{ eq.location.code }} - {{ eq.location.get_name }} {% else %} {{ eq.building.code }} @@ -331,7 +391,7 @@ {% if eq.parent_asset %} - {{ eq.parent_asset.code }} + {{ eq.parent_asset.code }} {% else %} - @@ -343,6 +403,16 @@
{{ eq.next_control_date|date:"d/m/Y"|default:"-" }} + + {% if eq.contractor_name %} +
{{ eq.contractor_name }}
+ {% endif %} + {% if eq.maintenance_contract_ref %} +
Réf: {{ eq.maintenance_contract_ref }}
+ {% elif not eq.contractor_name %} + - + {% endif %} + {% translate "Détail" %} @@ -351,7 +421,7 @@ {% empty %} - {% translate "Aucun asset ou équipement technique enregistré." %} + {% translate "Aucun asset ou équipement technique enregistré." %} {% endfor %} diff --git a/loko/assets/templates/assets/buildings/publicbuilding_form.html b/loko/assets/templates/assets/buildings/publicbuilding_form.html index 3ad48d7..7c89f98 100644 --- a/loko/assets/templates/assets/buildings/publicbuilding_form.html +++ b/loko/assets/templates/assets/buildings/publicbuilding_form.html @@ -157,6 +157,47 @@ {{ form.usable_surface_m2 }} {% if form.usable_surface_m2.errors %}
{{ form.usable_surface_m2.errors.0 }}
{% endif %}
+
+
{% translate "Spécificités du local ou du niveau (si applicable)" %}
+
+
+ + {{ form.room_number }} + {% if form.room_number.errors %}
{{ form.room_number.errors.0 }}
{% endif %} +
+
+ + {{ form.level_number }} + {% if form.level_number.errors %}
{{ form.level_number.errors.0 }}
{% endif %} +
+
+ + {{ form.surface_m2 }} + {% if form.surface_m2.errors %}
{{ form.surface_m2.errors.0 }}
{% endif %} +
+
+ + {{ form.ceiling_height_m }} + {% if form.ceiling_height_m.errors %}
{{ form.ceiling_height_m.errors.0 }}
{% endif %} +
+
+ + {{ form.floor_type }} + {% if form.floor_type.errors %}
{{ form.floor_type.errors.0 }}
{% endif %} +
+
+ + {{ form.fire_compartment }} + {% if form.fire_compartment.errors %}
{{ form.fire_compartment.errors.0 }}
{% endif %} +
+
+
+ {{ form.is_heated }} + +
+
diff --git a/loko/assets/templates/assets/buildings/publicbuilding_list.html b/loko/assets/templates/assets/buildings/publicbuilding_list.html index ea41912..0582fab 100644 --- a/loko/assets/templates/assets/buildings/publicbuilding_list.html +++ b/loko/assets/templates/assets/buildings/publicbuilding_list.html @@ -38,6 +38,15 @@ {% endfor %} +
+ + +
-
- +
+
-
+ @@ -86,8 +95,11 @@ - {{ b.code }} + {{ b.code }} + {% if b.room_number %} + {{ b.room_number }} + {% endif %}
{{ b.get_name }}
diff --git a/loko/assets/templates/assets/buildings/publicbuilding_location_tree.html b/loko/assets/templates/assets/buildings/publicbuilding_location_tree.html index 56ddceb..42f11d1 100644 --- a/loko/assets/templates/assets/buildings/publicbuilding_location_tree.html +++ b/loko/assets/templates/assets/buildings/publicbuilding_location_tree.html @@ -18,20 +18,15 @@ {% endif %} - {% if child_node.loc.location_type == 'building' or child_node.loc.location_type == 'site' %} - - {% elif child_node.loc.location_type == 'level' %} - - {% elif child_node.loc.location_type == 'room' %} - - {% else %} - - {% endif %} + {{ child_node.loc.code }} + {% if child_node.loc.room_number %} + {{ child_node.loc.room_number }} + {% endif %} - {{ child_node.loc.get_name }} {{ child_node.loc.get_location_type_display }} diff --git a/loko/assets/tests.py b/loko/assets/tests.py index 7016db8..b867ad5 100644 --- a/loko/assets/tests.py +++ b/loko/assets/tests.py @@ -4043,6 +4043,119 @@ class PublicBuildingTestCase(TestCase): self.assertIn(facade_asset, civil_asset.get_child_assets()) self.assertIn(hvac_asset, civil_asset.get_child_assets()) + def test_extended_building_locations(self): + # Cage d'escalier + stairwell = PublicBuilding.objects.create( + code="ESC-01", + name_fr="Cage d'escalier Nord", + parent_location=self.bloc_a, + location_type="stairwell", + level_number=0 + ) + # Chaufferie / Local technique + boiler_room = PublicBuilding.objects.create( + code="CHAUFF-01", + name_fr="Chaufferie Centrale", + parent_location=self.bloc_a, + location_type="boiler_room", + level_number=-1, + floor_type="concrete", + fire_compartment="Zone-CF-01", + is_heated=True + ) + # Bureau avec numéro de porte et hauteur + office = PublicBuilding.objects.create( + code="BUR-101", + name_fr="Bureau Directeur", + parent_location=self.etage_0, + location_type="office", + room_number="B-101", + ceiling_height_m=2.85, + surface_m2=24.5, + floor_type="parquet", + is_heated=True + ) + + self.assertEqual(stairwell.location_type, "stairwell") + self.assertEqual(stairwell.get_icon_class(), "bi-stairs") + self.assertEqual(boiler_room.location_type, "boiler_room") + self.assertEqual(boiler_room.get_icon_class(), "bi-fire") + self.assertEqual(boiler_room.floor_type, "concrete") + self.assertEqual(office.room_number, "B-101") + self.assertEqual(office.ceiling_height_m, 2.85) + self.assertEqual(office.get_icon_class(), "bi-briefcase") + + def test_extended_building_assets_envelope_and_technical(self): + # Toiture + roof = BuildingAsset.objects.create( + building=self.complex, + location=self.bloc_a, + code="TOIT-01", + name_fr="Toiture bac acier et zinc", + asset_type="roof", + surface_m2=450.0, + material="Zinc & Bac acier" + ) + # Fenêtre + window = BuildingAsset.objects.create( + building=self.complex, + location=self.etage_0, + parent_asset=roof, + code="FEN-101", + name_fr="Châssis double vitrage", + asset_type="window", + dimensions="120x140 cm", + material="Aluminium", + glazing_type="double_low_e", + opening_type="tilt_and_turn" + ) + # Chaudière + boiler = BuildingAsset.objects.create( + building=self.complex, + location=self.etage_0, + code="CHAUD-01", + name_fr="Chaudière à condensation", + asset_type="boiler", + power_kw=65.0, + energy_source="natural_gas", + contractor_name="Maintenance Thermique SA", + maintenance_contract_ref="CTR-2026-CHAUD", + regulatory_periodicity_months=12 + ) + # Pompe à chaleur + heat_pump = BuildingAsset.objects.create( + building=self.complex, + location=self.etage_0, + code="PAC-01", + name_fr="Pompe à chaleur réversible", + asset_type="heat_pump", + power_kw=22.0, + energy_source="heat_pump_air", + refrigerant_type="R32", + refrigerant_charge_kg=4.5 + ) + # Ascenseur + elevator = BuildingAsset.objects.create( + building=self.complex, + location=self.bloc_a, + code="ASC-01", + name_fr="Ascenseur 8 personnes PMR", + asset_type="elevator", + contractor_name="Otis", + regulatory_periodicity_months=6 + ) + + # Vérifications macro-lots et icônes + self.assertEqual(roof.get_macro_lot(), "Clos & Couvert") + self.assertEqual(roof.get_icon_class(), "bi-house-gear") + self.assertEqual(window.get_macro_lot(), "Clos & Couvert") + self.assertEqual(window.get_icon_class(), "bi-window") + self.assertEqual(boiler.get_macro_lot(), "Génie climatique & CVC") + self.assertEqual(boiler.get_icon_class(), "bi-fire") + self.assertEqual(heat_pump.refrigerant_type, "R32") + self.assertEqual(elevator.get_macro_lot(), "Ascenseurs & Élévateurs") + self.assertEqual(elevator.get_icon_class(), "bi-arrow-down-up") + class PublicBuildingEditViewTest(TestCase): def setUp(self):