846 lines
No EOL
37 KiB
Python
846 lines
No EOL
37 KiB
Python
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.utils import timezone
|
|
from django.db.models.functions import Coalesce, TruncDate
|
|
from django.db.models import DateTimeField, Q, Count, OuterRef, Subquery
|
|
from django.urls import reverse
|
|
from django.http import JsonResponse
|
|
from django.contrib.auth.decorators import login_required, login_not_required
|
|
|
|
from datetime import timedelta
|
|
from collections import defaultdict
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
# logger = logging.getLogger('home.perf')
|
|
|
|
|
|
# def _t(label, t0, splits):
|
|
# """Enregistre le temps écoulé depuis t0 et renvoie le nouveau t0."""
|
|
# now = time.perf_counter()
|
|
# splits.append((label, now - t0))
|
|
# return now
|
|
def _t(label, t0, splits):
|
|
return t0
|
|
|
|
from interventions.models import Intervention, InterventionTimeLine, STATUS_ORDERS, Symptom
|
|
from common.models import UserConfig, UserThematics, UserContractAccess
|
|
from contracts.models import CompanyMember, Contract
|
|
from contracts.permissions import get_allowed_contracts_for_user
|
|
from home.models import HomeWidget
|
|
from observations.models import Observation
|
|
from stock.models import PreparationOrder, ProductionOrder, StockAlert, PurchaseOrderItem, StockMovement, StockMovementBatch, PurchaseOrder
|
|
|
|
from interventions.permissions import filter_viewable_interventions_for_user
|
|
|
|
def index(request):
|
|
_perf_splits = []
|
|
_perf_total_start = time.perf_counter()
|
|
t = time.perf_counter()
|
|
|
|
# Vérifier les vues accessibles par l'utilisateur
|
|
user_config = get_object_or_404(UserConfig, user=request.user)
|
|
# Évaluer les vues accessibles une seule fois pour éviter des requêtes multiples
|
|
accessible_views_list = list(user_config.get_accessible_views())
|
|
t = _t('user_config + accessible_views', t, _perf_splits)
|
|
|
|
# S'il y a des vues accessibles définies, vérifier si home est accessible
|
|
if accessible_views_list:
|
|
# Vérifier si l'utilisateur a accès à la vue home
|
|
has_home_access = any(v.code == 'asset_management' for v in accessible_views_list)
|
|
|
|
if not has_home_access:
|
|
# Rediriger vers la première vue accessible valide
|
|
for first_view in accessible_views_list:
|
|
if first_view and first_view.url_name:
|
|
try:
|
|
return redirect(first_view.url_name)
|
|
except NoReverseMatch:
|
|
continue
|
|
|
|
# Resolve widgets for the user
|
|
company_member = CompanyMember.objects.filter(
|
|
user=request.user
|
|
).prefetch_related('teams').first()
|
|
user_teams = company_member.teams.all() if company_member else None
|
|
user_team = user_teams.first() if user_teams else None
|
|
user_contracts = get_allowed_contracts_for_user(request.user)
|
|
t = _t('company_member + user_contracts', t, _perf_splits)
|
|
|
|
# Contrats explicites de l'utilisateur pour la limitation de la page d'accueil
|
|
if user_config.limit_shortcuts_to_contracts:
|
|
home_contract_ids = list(
|
|
UserContractAccess.objects.filter(user_config=user_config)
|
|
.values_list('contract_id', flat=True)
|
|
)
|
|
else:
|
|
home_contract_ids = None
|
|
|
|
resolved_widgets = []
|
|
for widget in HomeWidget.objects.filter(is_active=True).prefetch_related(
|
|
'condition_groups__conditions'
|
|
):
|
|
result = widget.resolve_for_user(user_config, user_teams, user_contracts)
|
|
if result:
|
|
resolved_widgets.append(result)
|
|
|
|
resolved_widgets.sort(key=lambda r: (r['order'], r['col_order']))
|
|
|
|
# Group widgets by order into rows; within each row sort by col_order.
|
|
widget_rows = []
|
|
for w in resolved_widgets:
|
|
if widget_rows and widget_rows[-1][0]['order'] == w['order']:
|
|
widget_rows[-1].append(w)
|
|
else:
|
|
widget_rows.append([w])
|
|
t = _t('widgets_resolve', t, _perf_splits)
|
|
|
|
# Si aucune vue n'est définie ou si home est accessible, afficher la page d'accueil
|
|
today = timezone.now().date()
|
|
start_date = today - timedelta(days=29)
|
|
|
|
# Initialisation des tableaux
|
|
date_labels = [(start_date + timedelta(days=i)).strftime('%d/%m') for i in range(30)]
|
|
counters = {
|
|
'corrective': [0] * 30,
|
|
'preventive': [0] * 30,
|
|
'ameliorative': [0] * 30,
|
|
}
|
|
|
|
interventions = filter_viewable_interventions_for_user(request.user)
|
|
t = _t('filter_viewable_interventions', t, _perf_splits)
|
|
|
|
# Restreindre les interventions de la page d'accueil aux contrats explicitement
|
|
# autorisés quand la limitation interventions->contrats est active.
|
|
if user_config.limit_interventions_to_contracts:
|
|
intervention_contract_ids = list(
|
|
UserContractAccess.objects.filter(
|
|
user_config=user_config,
|
|
can_view_interventions=True,
|
|
).values_list('contract_id', flat=True)
|
|
)
|
|
interventions = interventions.filter(contract_id__in=intervention_contract_ids)
|
|
|
|
# Restreindre les widgets home aux contrats explicites si l'option dédiée est active
|
|
if home_contract_ids is not None:
|
|
interventions = interventions.filter(contract_id__in=home_contract_ids)
|
|
|
|
# Filtrer d'abord sur les thématiques avec droits d'édition
|
|
user_thematics_with_edit = UserThematics.objects.filter(
|
|
user_config=user_config,
|
|
can_edit_interventions=True
|
|
).select_related('thematic')
|
|
|
|
editable_thematics = [ut.thematic for ut in user_thematics_with_edit]
|
|
|
|
if editable_thematics:
|
|
interventions = interventions.filter(thematic__in=editable_thematics)
|
|
active_thematics = editable_thematics
|
|
elif user_config.default_thematic:
|
|
interventions = interventions.filter(thematic_id=user_config.default_thematic.id)
|
|
active_thematics = [user_config.default_thematic]
|
|
else:
|
|
active_thematics = []
|
|
t = _t('user_thematics_edit', t, _perf_splits)
|
|
|
|
# GROUP BY (day, maintain_type) côté DB : retourne au max 90 lignes au lieu de N interventions
|
|
for _row in (
|
|
interventions
|
|
.filter(maintain_type__in=['corrective', 'preventive', 'ameliorative'])
|
|
.annotate(day=TruncDate(Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField())))
|
|
.filter(day__gte=start_date, day__lte=today)
|
|
.values('day', 'maintain_type')
|
|
.annotate(cnt=Count('id'))
|
|
.order_by()
|
|
):
|
|
if _row['day'] is not None and _row['maintain_type'] in counters:
|
|
_idx = (_row['day'] - start_date).days
|
|
if 0 <= _idx < 30:
|
|
counters[_row['maintain_type']][_idx] += _row['cnt']
|
|
|
|
# Préparer les données pour le template
|
|
series_data = {
|
|
'dates': date_labels,
|
|
'Correctif': counters['corrective'],
|
|
'Préventif': counters['preventive'],
|
|
'Amélioratif': counters['ameliorative'],
|
|
}
|
|
t = _t('chart_series_data', t, _perf_splits)
|
|
|
|
# Compteur des interventions à traiter
|
|
# count_to_be_processed_future = _count_to_be_processed_future(interventions)
|
|
|
|
# Détermine les droits de création d'interventions par type
|
|
user_roles = set(user_config.roles.values_list('name', flat=True))
|
|
can_create_preventive = bool(user_roles & {'admin', 'manager', 'controller', 'top_manager'})
|
|
can_create_ameliorative = bool(user_roles & {'admin', 'manager', 'controller', 'external_manager', 'operator', 'top_manager'})
|
|
is_operator = 'operator' in user_roles
|
|
is_admin = 'admin' in user_roles
|
|
t = _t('user_roles', t, _perf_splits)
|
|
|
|
# Symptômes raccourcis pour les opérateurs (basés sur le champ code)
|
|
shortcut_symptoms = []
|
|
if is_operator or is_admin:
|
|
# Codes des symptômes à afficher en raccourci
|
|
shortcut_codes = ['TU_ELECTROMECA_DEFECT', 'TL_ORANGE_FLASH', 'URBAN_FURNITURE_POLE_DAMAGED', 'LI_LIGHT_OFF']
|
|
symptoms_qs = Symptom.objects.filter(
|
|
code__in=shortcut_codes,
|
|
is_active=True
|
|
).select_related('thematic')
|
|
|
|
# Filtrer sur les thématiques autorisées si elles existent
|
|
if active_thematics:
|
|
symptoms_qs = symptoms_qs.filter(thematic__in=active_thematics)
|
|
|
|
# Restreindre aux thématiques des contrats explicites si l'option est active
|
|
if home_contract_ids is not None:
|
|
contract_thematic_ids = Contract.objects.filter(
|
|
id__in=home_contract_ids
|
|
).values_list('thematics__id', flat=True)
|
|
symptoms_qs = symptoms_qs.filter(thematic_id__in=contract_thematic_ids)
|
|
|
|
for symptom in symptoms_qs:
|
|
shortcut_symptoms.append({
|
|
'id': symptom.id,
|
|
'name': symptom.get_name(),
|
|
'thematic_code': symptom.thematic.code,
|
|
'icon': symptom.thematic.icon or 'bi-wrench',
|
|
})
|
|
t = _t('shortcut_symptoms', t, _perf_splits)
|
|
|
|
# ========== Statistiques observations pour les observateurs ==========
|
|
from observations.permissions import get_observation_access_context
|
|
|
|
is_observer = 'observer' in user_roles
|
|
obs_counts_by_type = {}
|
|
obs_access_ctx = get_observation_access_context(request.user, user_config=user_config)
|
|
t = _t('obs_access_context', t, _perf_splits)
|
|
|
|
if obs_access_ctx is not None:
|
|
obs_statuses = [s for s, _ in Observation.STATUS_CHOICES]
|
|
obs_types = ['incident', 'inventory', 'remark']
|
|
visible_obs_qs = obs_access_ctx.filter_queryset(Observation.objects.all())
|
|
agg_kwargs = {}
|
|
for obs_type in obs_types:
|
|
for obs_status in obs_statuses:
|
|
key = f'obs_{obs_type}_{obs_status}'
|
|
agg_kwargs[key] = Count('id', filter=Q(observation_type=obs_type, status=obs_status))
|
|
obs_agg = visible_obs_qs.aggregate(**agg_kwargs)
|
|
for obs_type in obs_types:
|
|
obs_counts_by_type[obs_type] = {
|
|
obs_status: obs_agg.get(f'obs_{obs_type}_{obs_status}', 0)
|
|
for obs_status in obs_statuses
|
|
}
|
|
t = _t('obs_aggregate', t, _perf_splits)
|
|
|
|
# ========== Compteur incidents à traiter (widget incident_counter) ==========
|
|
if obs_access_ctx is not None:
|
|
_incident_base_qs = obs_access_ctx.filter_queryset(
|
|
Observation.objects.filter(observation_type='incident', status='to_process')
|
|
)
|
|
|
|
# Filtrer par équipe de l'utilisateur : ne montrer que les observations
|
|
# dont le symptôme est attribué à l'équipe de l'utilisateur.
|
|
if user_teams:
|
|
_incident_base_qs = _incident_base_qs.filter(symptom__teams__in=user_teams).distinct()
|
|
else:
|
|
# L'utilisateur n'appartient à aucune équipe : exclure les incidents
|
|
# dont le symptôme a au moins une équipe désignée.
|
|
_incident_base_qs = _incident_base_qs.exclude(symptom__teams__isnull=False)
|
|
|
|
incident_to_process_count = _incident_base_qs.count()
|
|
_incident_qs = _incident_base_qs.select_related('thematic', 'category').order_by('-created_at')
|
|
incident_to_process_list = list(_incident_qs[:5])
|
|
else:
|
|
incident_to_process_count = 0
|
|
incident_to_process_list = []
|
|
t = _t('incident_list', t, _perf_splits)
|
|
|
|
# Vérifier permission structures repair intervention
|
|
can_add_structures_repair = False
|
|
categories = []
|
|
|
|
if user_config.default_thematic and user_config.default_thematic.code == 'structures':
|
|
is_technician = user_config.roles.filter(name='technician').exists()
|
|
has_edit_rights = UserThematics.objects.filter(
|
|
user_config=user_config,
|
|
thematic=user_config.default_thematic,
|
|
can_edit_interventions=True
|
|
).exists()
|
|
can_add_structures_repair = is_technician and has_edit_rights
|
|
|
|
if can_add_structures_repair:
|
|
from assets.models import AssetCategory
|
|
categories = list(AssetCategory.objects.filter(thematic=user_config.default_thematic).order_by('name_fr'))
|
|
t = _t('structures_repair_perms', t, _perf_splits)
|
|
|
|
# Construire les codes thématiques pour les liens
|
|
thematic_codes = [t_obj.code for t_obj in active_thematics] if active_thematics else []
|
|
thematic_filter_param = '&'.join([f'thematic={code}' for code in thematic_codes]) if thematic_codes else ''
|
|
|
|
# Vérifier si l'utilisateur peut générer le rapport journalier
|
|
# Un utilisateur peut être membre de plusieurs équipes ; il suffit qu'une seule
|
|
# ait can_generate_daily_report=True pour afficher le bouton.
|
|
can_generate_daily_report = CompanyMember.objects.filter(
|
|
user=request.user,
|
|
teams__can_generate_daily_report=True,
|
|
).exists()
|
|
t = _t('daily_report_flag', t, _perf_splits)
|
|
|
|
# Compteurs par type de maintenance — GROUP BY (maintain_type, status, pause_reason) retourne ~50 lignes
|
|
# au lieu de 43 COUNT FILTER sur l'ensemble complet du queryset.
|
|
_CORRECTIVE_ACTIVE = frozenset([
|
|
'in_preparation', 'to_be_approved', 'to_be_planned',
|
|
'to_be_processed', 'in_progress', 'on_pause', 'finished',
|
|
])
|
|
_MT_PFX = {'corrective': 'corr', 'preventive': 'prev', 'ameliorative': 'amel'}
|
|
_PER_TYPE_STATUSES = [
|
|
'to_be_approved', 'to_be_processed', 'to_be_corrected',
|
|
'assigned', 'finished', 'processed', 'corrected', 'to_be_planned',
|
|
'in_preparation', 'in_progress',
|
|
]
|
|
_PAUSE_REASONS = ['reschedule', 'order_material']
|
|
_OTHER_PAUSE_REASONS = ['treated_in_next_maintenance', 'other']
|
|
_GLOBAL_STATUSES = [
|
|
'to_be_approved', 'to_be_processed', 'assigned', 'to_be_planned',
|
|
'processed', 'finished', 'to_be_corrected', 'corrected',
|
|
]
|
|
# Pré-initialisation : garantit que toutes les clés existent même si le queryset
|
|
# ne contient aucune ligne pour une combinaison donnée (sinon le template reçoit ''
|
|
# au lieu de 0 et blocktranslate lève une TemplateSyntaxError).
|
|
mt_counts = defaultdict(int)
|
|
for _pfx in _MT_PFX.values():
|
|
for _s in _PER_TYPE_STATUSES:
|
|
mt_counts[f'count_{_pfx}_{_s}'] = 0
|
|
for _r in _PAUSE_REASONS:
|
|
mt_counts[f'count_{_pfx}_on_pause_{_r}'] = 0
|
|
mt_counts[f'count_{_pfx}_on_pause_other_reasons'] = 0
|
|
for _s in _GLOBAL_STATUSES:
|
|
mt_counts[f'count_{_s}'] = 0
|
|
for _r in _PAUSE_REASONS:
|
|
mt_counts[f'count_on_pause_{_r}'] = 0
|
|
mt_counts['count_corrective'] = 0
|
|
|
|
for _row in (
|
|
interventions
|
|
.values('maintain_type', 'status', 'pause_reason')
|
|
.annotate(cnt=Count('id'))
|
|
.order_by()
|
|
):
|
|
_mt, _st, _pr = _row['maintain_type'], _row['status'], _row['pause_reason'] or ''
|
|
_n = _row['cnt']
|
|
_pfx = _MT_PFX.get(_mt)
|
|
_is_pause = _st == 'on_pause' and bool(_pr)
|
|
|
|
if _pfx:
|
|
if _is_pause:
|
|
mt_counts[f'count_{_pfx}_on_pause_{_pr}'] += _n
|
|
else:
|
|
mt_counts[f'count_{_pfx}_{_st}'] += _n
|
|
if _mt == 'corrective' and _st in _CORRECTIVE_ACTIVE:
|
|
mt_counts['count_corrective'] += _n
|
|
|
|
if _is_pause:
|
|
mt_counts[f'count_on_pause_{_pr}'] += _n
|
|
else:
|
|
mt_counts[f'count_{_st}'] += _n
|
|
|
|
# Compteurs "en pause (autre)" : toutes les raisons hors reschedule et order_material
|
|
for _pfx in _MT_PFX.values():
|
|
mt_counts[f'count_{_pfx}_on_pause_other_reasons'] = sum(
|
|
mt_counts[f'count_{_pfx}_on_pause_{_r}'] for _r in _OTHER_PAUSE_REASONS
|
|
)
|
|
|
|
t = _t('mt_counts_aggregate', t, _perf_splits)
|
|
|
|
# Compteurs non traitées (critère élargi) :
|
|
# - planifiée en retard (to_be_processed/assigned avec planned_begin_time dépassé)
|
|
# - démarrée depuis > 7 jours
|
|
# - prise en charge depuis > 7 jours
|
|
_now = timezone.now()
|
|
_1_day_ago = _now - timedelta(days=1)
|
|
_7_days_ago = _now - timedelta(days=7)
|
|
|
|
_latest_in_progress_sq = InterventionTimeLine.objects.filter(
|
|
intervention=OuterRef('pk'),
|
|
to_status='in_progress',
|
|
).order_by('-event_time').values('event_time')[:1]
|
|
|
|
_latest_assigned_sq = InterventionTimeLine.objects.filter(
|
|
intervention=OuterRef('pk'),
|
|
to_status='assigned',
|
|
).order_by('-event_time').values('event_time')[:1]
|
|
|
|
overdue_agg = interventions.annotate(
|
|
_nt_eff_begin=Coalesce('planned_begin_time', 'expected_begin_time', output_field=DateTimeField()),
|
|
_nt_last_in_progress=Coalesce(Subquery(_latest_in_progress_sq), 'begin_time', output_field=DateTimeField()),
|
|
_nt_last_assigned=Coalesce(Subquery(_latest_assigned_sq), 'transmission_time', 'creation_time', output_field=DateTimeField()),
|
|
).filter(
|
|
# Planifiée ou prévisionnelle en retard
|
|
Q(status__in=['to_be_processed', 'assigned'], _nt_eff_begin__isnull=False, _nt_eff_begin__lt=_1_day_ago)
|
|
# Démarrée depuis > 7 jours
|
|
| Q(status='in_progress', _nt_last_in_progress__isnull=False, _nt_last_in_progress__lt=_7_days_ago)
|
|
# Prise en charge depuis > 7 jours
|
|
| Q(status='assigned', _nt_last_assigned__isnull=False, _nt_last_assigned__lt=_7_days_ago)
|
|
).aggregate(
|
|
total=Count('id'),
|
|
corr=Count('id', filter=Q(maintain_type='corrective')),
|
|
prev=Count('id', filter=Q(maintain_type='preventive')),
|
|
amel=Count('id', filter=Q(maintain_type='ameliorative')),
|
|
)
|
|
count_overdue = overdue_agg['total']
|
|
count_corr_overdue = overdue_agg['corr']
|
|
count_prev_overdue = overdue_agg['prev']
|
|
count_amel_overdue = overdue_agg['amel']
|
|
t = _t('overdue_aggregate', t, _perf_splits)
|
|
|
|
_three_months = _now + timedelta(days=90)
|
|
planned_soon_agg = interventions.filter(
|
|
status='to_be_planned',
|
|
).filter(
|
|
Q(expected_begin_time__isnull=True) | Q(expected_begin_time__lt=_three_months)
|
|
).aggregate(
|
|
total=Count('id'),
|
|
corr=Count('id', filter=Q(maintain_type='corrective')),
|
|
prev=Count('id', filter=Q(maintain_type='preventive')),
|
|
amel=Count('id', filter=Q(maintain_type='ameliorative')),
|
|
)
|
|
count_to_be_planned_soon = planned_soon_agg['total']
|
|
count_corr_to_be_planned_soon = planned_soon_agg['corr']
|
|
count_prev_to_be_planned_soon = planned_soon_agg['prev']
|
|
count_amel_to_be_planned_soon = planned_soon_agg['amel']
|
|
t = _t('planned_soon_aggregate', t, _perf_splits)
|
|
|
|
# Vérifier si l'utilisateur doit voir les compteurs "à approuver" scindés (vérifiées / non vérifiées)
|
|
show_checked_split = UserContractAccess.objects.filter(
|
|
user_config=user_config,
|
|
# can_check_interventions=True,
|
|
contract__needs_checking=True,
|
|
).exists()
|
|
|
|
if show_checked_split:
|
|
split_checked_counts = interventions.filter(status='to_be_approved').aggregate(
|
|
count_corr_to_be_approved_checked=Count('id', filter=Q(maintain_type='corrective', is_checked=True)),
|
|
count_corr_to_be_approved_unchecked=Count('id', filter=Q(maintain_type='corrective', is_checked=False)),
|
|
count_prev_to_be_approved_checked=Count('id', filter=Q(maintain_type='preventive', is_checked=True)),
|
|
count_prev_to_be_approved_unchecked=Count('id', filter=Q(maintain_type='preventive', is_checked=False)),
|
|
count_amel_to_be_approved_checked=Count('id', filter=Q(maintain_type='ameliorative', is_checked=True)),
|
|
count_amel_to_be_approved_unchecked=Count('id', filter=Q(maintain_type='ameliorative', is_checked=False)),
|
|
)
|
|
else:
|
|
split_checked_counts = {}
|
|
t = _t('checked_split', t, _perf_splits)
|
|
|
|
# ========== Compteurs interventions à approuver (widgets d'approbation) ==========
|
|
# Récupérer les contrats sur lesquels l'utilisateur a le droit d'approuver
|
|
approve_contract_ids = UserContractAccess.objects.filter(
|
|
user_config=user_config,
|
|
can_approve=True,
|
|
).values_list('contract_id', flat=True)
|
|
|
|
approval_qs = filter_viewable_interventions_for_user(request.user)
|
|
|
|
if active_thematics:
|
|
approval_qs = approval_qs.filter(thematic__in=active_thematics)
|
|
elif user_config.default_thematic:
|
|
approval_qs = approval_qs.filter(thematic_id=user_config.default_thematic.id)
|
|
|
|
if home_contract_ids is not None:
|
|
approval_qs = approval_qs.filter(
|
|
Q(contract_id__in=home_contract_ids) | Q(contract__isnull=True)
|
|
)
|
|
|
|
approval_qs = approval_qs.filter(
|
|
status='to_be_approved',
|
|
).filter(
|
|
Q(contract__in=approve_contract_ids) | Q(contract__isnull=True)
|
|
)
|
|
|
|
# Filtrer par catégories d'approbation si elles sont définies pour l'utilisateur
|
|
approval_categories_list = list(user_config.approval_categories.all())
|
|
if approval_categories_list:
|
|
approval_qs = approval_qs.filter(asset_category__in=approval_categories_list)
|
|
|
|
approval_counts = approval_qs.aggregate(
|
|
total=Count('id'),
|
|
checked=Count('id', filter=Q(is_checked=True)),
|
|
)
|
|
intervention_to_approve_count = approval_counts['total']
|
|
intervention_checked_to_approve_count = approval_counts['checked']
|
|
intervention_to_approve_list = list(
|
|
approval_qs.select_related('thematic', 'asset_category', 'contract', 'contract__company').order_by('contract__contract_number', '-code')
|
|
)
|
|
intervention_checked_to_approve_list = list(
|
|
approval_qs.filter(is_checked=True).select_related('thematic', 'asset_category', 'contract', 'contract__company').order_by('contract__contract_number', '-code')
|
|
)
|
|
t = _t('approval_counts', t, _perf_splits)
|
|
|
|
# ========== Données widget stock_summary ==========
|
|
_prep_qs = PreparationOrder.objects.filter(status__in=['to_process', 'pending'])
|
|
preparation_order_to_process_count = _prep_qs.count()
|
|
preparation_order_to_process_list = list(_prep_qs.order_by('-date')[:5])
|
|
|
|
_prod_qs = ProductionOrder.objects.filter(status__in=['to_process', 'in_production'])
|
|
production_order_to_process_count = _prod_qs.count()
|
|
production_order_to_process_list = list(
|
|
_prod_qs.select_related('warehouse').order_by('-created_at')[:5]
|
|
)
|
|
|
|
_products_with_active_po = PurchaseOrderItem.objects.filter(
|
|
purchase_order__status__in=['pending', 'ordered', 'partial']
|
|
).values('product_id')
|
|
stock_low_alerts_list = list(
|
|
StockAlert.objects.filter(is_active=True)
|
|
.exclude(product_id__in=_products_with_active_po)
|
|
.select_related('product', 'warehouse')
|
|
.order_by('-triggered_at')[:5]
|
|
)
|
|
|
|
# ── Articles en prêt non retournés ────────────────────────────────────
|
|
_now = timezone.now()
|
|
_cutoff_1m = _now - timedelta(days=30)
|
|
_cutoff_3m = _now - timedelta(days=90)
|
|
_cutoff_6m = _now - timedelta(days=180)
|
|
|
|
# 1. Tous les mouvements issus de sorties "loan"
|
|
_loan_movements = list(
|
|
StockMovement.objects
|
|
.filter(
|
|
batch__movement_type=StockMovementBatch.TYPE_OUT,
|
|
batch__movement_subtype='loan',
|
|
)
|
|
.select_related('product', 'batch', 'batch__person', 'batch__company')
|
|
.order_by('batch__date')
|
|
)
|
|
|
|
# 2. Collect product/person/company IDs pour les retours potentiels
|
|
_loan_product_ids = {lm.product_id for lm in _loan_movements}
|
|
_loan_person_ids = {lm.batch.person_id for lm in _loan_movements if lm.batch.person_id}
|
|
_loan_company_ids = {lm.batch.company_id for lm in _loan_movements if lm.batch.company_id}
|
|
|
|
# 3. Tous les retours potentiels en une seule requête
|
|
_returns_by_product_person = defaultdict(list)
|
|
_returns_by_product_company = defaultdict(list)
|
|
if _loan_product_ids and (_loan_person_ids or _loan_company_ids):
|
|
_return_filter = Q()
|
|
if _loan_person_ids:
|
|
_return_filter |= Q(batch__person_id__in=_loan_person_ids)
|
|
if _loan_company_ids:
|
|
_return_filter |= Q(batch__company_id__in=_loan_company_ids)
|
|
for rm in StockMovement.objects.filter(
|
|
product_id__in=_loan_product_ids,
|
|
destination_location__isnull=False,
|
|
batch__movement_type=StockMovementBatch.TYPE_IN,
|
|
).filter(_return_filter).values('product_id', 'batch__person_id', 'batch__company_id', 'date'):
|
|
if rm['batch__person_id']:
|
|
_returns_by_product_person[(rm['product_id'], rm['batch__person_id'])].append(rm['date'])
|
|
if rm['batch__company_id']:
|
|
_returns_by_product_company[(rm['product_id'], rm['batch__company_id'])].append(rm['date'])
|
|
|
|
# 4. Identifier les prêts non retournés (dédoublonnés par batch + produit)
|
|
_unreturned_loans = []
|
|
_seen_loan_keys = set()
|
|
for lm in _loan_movements:
|
|
_key = (lm.batch_id, lm.product_id)
|
|
if _key in _seen_loan_keys:
|
|
continue
|
|
_seen_loan_keys.add(_key)
|
|
person_id = lm.batch.person_id
|
|
company_id = lm.batch.company_id
|
|
loan_date = lm.batch.date
|
|
if person_id:
|
|
is_returned = any(rd > loan_date for rd in _returns_by_product_person.get((lm.product_id, person_id), []))
|
|
elif company_id:
|
|
is_returned = any(rd > loan_date for rd in _returns_by_product_company.get((lm.product_id, company_id), []))
|
|
else:
|
|
continue
|
|
if not is_returned:
|
|
_unreturned_loans.append({
|
|
'product': lm.product,
|
|
'batch': lm.batch,
|
|
'loan_date': loan_date,
|
|
'quantity': abs(lm.quantity),
|
|
})
|
|
|
|
_unreturned_loans.sort(key=lambda x: x['loan_date'])
|
|
for _u in _unreturned_loans:
|
|
_ld = _u['loan_date']
|
|
if _ld < _cutoff_6m:
|
|
_u['age_css'] = 'bg-dark'
|
|
elif _ld < _cutoff_3m:
|
|
_u['age_css'] = 'bg-danger'
|
|
elif _ld < _cutoff_1m:
|
|
_u['age_css'] = 'bg-warning text-dark'
|
|
else:
|
|
_u['age_css'] = 'bg-light text-muted border'
|
|
loan_items_total = len(_unreturned_loans)
|
|
loan_items_gt_1m = sum(1 for u in _unreturned_loans if u['loan_date'] < _cutoff_1m)
|
|
loan_items_gt_3m = sum(1 for u in _unreturned_loans if u['loan_date'] < _cutoff_3m)
|
|
loan_items_gt_6m = sum(1 for u in _unreturned_loans if u['loan_date'] < _cutoff_6m)
|
|
loan_items_list = _unreturned_loans[:8]
|
|
|
|
# ── Commandes en attente ──────────────────────────────────────────────
|
|
_po_qs = PurchaseOrder.objects.filter(status__in=['pending', 'ordered', 'partial'])
|
|
pending_purchase_orders_count = _po_qs.count()
|
|
pending_purchase_orders_list = list(
|
|
_po_qs.select_related('supplier', 'warehouse').order_by('-date_ordered')[:5]
|
|
)
|
|
|
|
t = _t('stock_data', t, _perf_splits)
|
|
|
|
# Raccourci "interventions à vérifier".
|
|
# Construit de zéro à partir des contrats où can_check_interventions=True.
|
|
# Ce filtre est TOUJOURS actif (quel que soit limit_interventions_to_contracts
|
|
# ou le rôle admin) car la permission de vérifier est fonctionnellement liée
|
|
# à can_check_interventions sur UserContractAccess.
|
|
# Les interventions sans contrat et celles des contrats non autorisés sont
|
|
# naturellement exclues (NULL n'est pas dans la liste).
|
|
verify_contract_ids = UserContractAccess.objects.filter(
|
|
user_config=user_config,
|
|
can_check_interventions=True,
|
|
).values_list('contract_id', flat=True)
|
|
|
|
from interventions.models import Intervention as _Intervention
|
|
intervention_to_verify_qs = _Intervention.objects.filter(
|
|
status='to_be_approved',
|
|
contract_id__in=verify_contract_ids,
|
|
)
|
|
|
|
intervention_to_verify_count = intervention_to_verify_qs.count()
|
|
intervention_to_verify_list = list(
|
|
intervention_to_verify_qs
|
|
.select_related('thematic')
|
|
.order_by('-code')[:5]
|
|
)
|
|
intervention_to_plan_list = list(
|
|
interventions.filter(status='to_be_planned')
|
|
.select_related('thematic')
|
|
.order_by('-code')[:5]
|
|
)
|
|
intervention_finished_qs = interventions.filter(status='finished').exclude(
|
|
Q(contract__uses_intervention_manager=True) &
|
|
~Q(intervention_manager=request.user)
|
|
)
|
|
intervention_finished_count = intervention_finished_qs.count()
|
|
intervention_finished_list = list(
|
|
intervention_finished_qs
|
|
.select_related('thematic')
|
|
.order_by('-code')[:5]
|
|
)
|
|
|
|
if home_contract_ids is not None:
|
|
is_manager_filter_applied = user_contracts.filter(
|
|
id__in=home_contract_ids, uses_intervention_manager=True
|
|
).exists()
|
|
else:
|
|
is_manager_filter_applied = user_contracts.filter(
|
|
uses_intervention_manager=True
|
|
).exists()
|
|
|
|
total_finished_count = interventions.filter(status='finished').count()
|
|
|
|
is_inspector = 'inspector' in user_roles
|
|
inspection_configs = []
|
|
if is_inspector:
|
|
from interventions.models import InspectionConfiguration
|
|
from interventions.permissions import can_user_create_inspection_for_contract
|
|
|
|
configs = list(
|
|
InspectionConfiguration.objects.select_related('thematic')
|
|
.prefetch_related('contracts_providers__contract', 'operation_templates')
|
|
)
|
|
|
|
try:
|
|
is_admin = user_config.roles.filter(name='admin').exists()
|
|
except Exception:
|
|
is_admin = False
|
|
|
|
for config in configs:
|
|
associated_contracts = [cp.contract for cp in config.contracts_providers.all()]
|
|
if not associated_contracts:
|
|
inspection_configs.append(config)
|
|
elif is_admin:
|
|
inspection_configs.append(config)
|
|
else:
|
|
for contract in associated_contracts:
|
|
if can_user_create_inspection_for_contract(request.user, contract):
|
|
inspection_configs.append(config)
|
|
break
|
|
t = _t('inspection_configs', t, _perf_splits)
|
|
|
|
context = {
|
|
'is_inspector': is_inspector,
|
|
'inspection_configs': inspection_configs,
|
|
'series_data': json.dumps(series_data),
|
|
**mt_counts,
|
|
'count_to_be_processed_future': _count_to_be_processed_future(interventions),
|
|
'count_to_be_planned_soon': count_to_be_planned_soon,
|
|
'count_corr_overdue': count_corr_overdue,
|
|
'count_prev_overdue': count_prev_overdue,
|
|
'count_amel_overdue': count_amel_overdue,
|
|
'count_corr_to_be_planned_soon': count_corr_to_be_planned_soon,
|
|
'count_prev_to_be_planned_soon': count_prev_to_be_planned_soon,
|
|
'count_amel_to_be_planned_soon': count_amel_to_be_planned_soon,
|
|
'count_overdue': count_overdue,
|
|
'is_intern': user_config.is_intern,
|
|
'default_thematic_code': user_config.default_thematic.code if user_config.default_thematic else None,
|
|
'thematic_filter_param': thematic_filter_param,
|
|
'active_thematics': active_thematics,
|
|
'can_create_preventive': can_create_preventive,
|
|
'can_create_ameliorative': can_create_ameliorative,
|
|
'is_operator': is_operator,
|
|
'shortcut_symptoms': shortcut_symptoms,
|
|
'can_add_structures_repair': can_add_structures_repair,
|
|
'categories': categories,
|
|
'can_generate_daily_report': can_generate_daily_report,
|
|
'widgets': resolved_widgets,
|
|
'widget_rows': widget_rows,
|
|
'user_contracts': user_contracts,
|
|
'user_team': user_team,
|
|
'is_observer': is_observer,
|
|
'obs_counts_by_type': obs_counts_by_type,
|
|
'incident_to_process_count': incident_to_process_count,
|
|
'incident_to_process_list': incident_to_process_list,
|
|
'intervention_to_verify_count': intervention_to_verify_count,
|
|
'intervention_to_verify_list': intervention_to_verify_list,
|
|
'intervention_to_plan_count': mt_counts['count_to_be_planned'],
|
|
'intervention_to_plan_list': intervention_to_plan_list,
|
|
'intervention_finished_count': intervention_finished_count,
|
|
'intervention_finished_list': intervention_finished_list,
|
|
'is_manager_filter_applied': is_manager_filter_applied,
|
|
'total_finished_count': total_finished_count,
|
|
'intervention_to_approve_count': intervention_to_approve_count,
|
|
'intervention_to_approve_list': intervention_to_approve_list,
|
|
'intervention_checked_to_approve_count': intervention_checked_to_approve_count,
|
|
'intervention_checked_to_approve_list': intervention_checked_to_approve_list,
|
|
'show_checked_split': show_checked_split,
|
|
**split_checked_counts,
|
|
'preparation_order_to_process_count': preparation_order_to_process_count,
|
|
'preparation_order_to_process_list': preparation_order_to_process_list,
|
|
'production_order_to_process_count': production_order_to_process_count,
|
|
'production_order_to_process_list': production_order_to_process_list,
|
|
'stock_low_alerts_list': stock_low_alerts_list,
|
|
'loan_items_total': loan_items_total,
|
|
'loan_items_gt_1m': loan_items_gt_1m,
|
|
'loan_items_gt_3m': loan_items_gt_3m,
|
|
'loan_items_gt_6m': loan_items_gt_6m,
|
|
'loan_items_list': loan_items_list,
|
|
'pending_purchase_orders_count': pending_purchase_orders_count,
|
|
'pending_purchase_orders_list': pending_purchase_orders_list,
|
|
}
|
|
|
|
# ── Logging de performance (désactivé) ────────────────────────────────
|
|
# total_ms = (time.perf_counter() - _perf_total_start) * 1000
|
|
# lines = [
|
|
# f"[home.perf] index() user={request.user.username!r} "
|
|
# f"total={total_ms:.0f}ms"
|
|
# ]
|
|
# for label, elapsed in _perf_splits:
|
|
# lines.append(f" {elapsed * 1000:6.1f}ms {label}")
|
|
# logger.info('\n'.join(lines))
|
|
# ────────────────────────────────────────────────────────────────────────
|
|
|
|
return render(request, "home/home_index.html", context)
|
|
|
|
|
|
def _count_overdue_interventions(interventions):
|
|
"""
|
|
Compte les interventions dont la date de début effective est dépassée
|
|
et qui sont dans un statut inférieur à "démarré" (in_progress).
|
|
|
|
La date de début effective est: planned_begin_time ou expected_begin_time.
|
|
Les statuts inférieurs à in_progress sont: in_preparation, to_be_approved, to_be_planned, to_be_processed, assigned.
|
|
"""
|
|
now = timezone.now()
|
|
|
|
# Statuts inférieurs à "in_progress" et différents de "in_preparation" (status_order < 21)
|
|
overdue_statuses = [
|
|
'to_be_processed', 'assigned'
|
|
]
|
|
|
|
# Interventions avec une date de début planifiée ou prévue dans le passé
|
|
# et qui sont dans un statut inférieur à "démarré"
|
|
return interventions.filter(
|
|
status__in=overdue_statuses
|
|
).annotate(
|
|
effective_begin=Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField())
|
|
).filter(
|
|
effective_begin__lt=now, planned_begin_time__isnull=False
|
|
).count()
|
|
|
|
|
|
def _count_to_be_processed_future(interventions):
|
|
"""
|
|
Compte les interventions au statut 'to_be_processed'
|
|
"""
|
|
now = timezone.now()
|
|
|
|
# Interventions à traiter
|
|
return interventions.filter(
|
|
status='to_be_processed'
|
|
).annotate(
|
|
effective_begin=Coalesce('planned_begin_time', 'expected_begin_time', output_field=DateTimeField())
|
|
).filter(
|
|
Q(effective_begin__gte=now) | Q(effective_begin__isnull=True)
|
|
).count()
|
|
|
|
|
|
def _count_to_be_planned_soon(interventions):
|
|
"""
|
|
Compte les interventions au statut 'to_be_planned' dont la date prévue (expected_begin_time)
|
|
est nulle ou inférieure à aujourd'hui + 3 mois.
|
|
"""
|
|
three_months_from_now = timezone.now() + timedelta(days=90)
|
|
|
|
return interventions.filter(
|
|
status='to_be_planned'
|
|
).filter(
|
|
Q(expected_begin_time__isnull=True) | Q(expected_begin_time__lt=three_months_from_now)
|
|
).count()
|
|
|
|
|
|
def privacy(request):
|
|
return render(request, "home/privacy.html")
|
|
|
|
|
|
@login_required
|
|
def incident_count_api(request):
|
|
"""JSON endpoint: count + first 5 'incident' observations with status 'to_process'
|
|
accessible to the current user. Used by the incident_counter widget for
|
|
auto-refresh every minute."""
|
|
from observations.permissions import get_observation_access_context
|
|
|
|
access_ctx = get_observation_access_context(request.user)
|
|
if access_ctx is None:
|
|
return JsonResponse({'count': 0, 'incidents': []})
|
|
|
|
qs = access_ctx.filter_queryset(
|
|
Observation.objects.filter(observation_type='incident', status='to_process')
|
|
.select_related('thematic', 'category')
|
|
.order_by('-created_at')
|
|
)
|
|
|
|
# Filtre par équipe : cohérent avec le compteur initial de la home page
|
|
user_teams = None
|
|
_cm = CompanyMember.objects.filter(user=request.user).prefetch_related('teams').first()
|
|
if _cm:
|
|
user_teams = _cm.teams.all()
|
|
if user_teams:
|
|
qs = qs.filter(symptom__teams__in=user_teams).distinct()
|
|
else:
|
|
# L'utilisateur n'appartient à aucune équipe : exclure les incidents
|
|
# dont le symptôme a au moins une équipe désignée.
|
|
qs = qs.exclude(symptom__teams__isnull=False)
|
|
|
|
incidents = [
|
|
{
|
|
'id': obs.id,
|
|
'description': obs.description or '',
|
|
'address': obs.address or '',
|
|
'category': obs.category.get_name() if obs.category else '',
|
|
'thematic': obs.thematic.name if obs.thematic else '',
|
|
'created_at': obs.created_at.strftime('%d/%m/%Y %H:%M'),
|
|
}
|
|
for obs in qs[:5]
|
|
]
|
|
return JsonResponse({'count': qs.count(), 'incidents': incidents}) |