feat: add stock management module including views, models, templates, and tests
This commit is contained in:
parent
001bee8ca9
commit
e00f9cf784
6 changed files with 653 additions and 92 deletions
|
|
@ -485,13 +485,67 @@ def stock_production_orders_mobile(request):
|
|||
"""Mobile list of production orders (bons de confection)."""
|
||||
from stock.models import ProductionOrder
|
||||
from stock.permissions import get_user_accessible_warehouses
|
||||
from django.db import models
|
||||
|
||||
search_q = request.GET.get('q', '').strip() or request.GET.get('prod_q', '').strip()
|
||||
selected_status = request.GET.get('status', '').strip() or request.GET.get('prod_status', '').strip()
|
||||
selected_warehouse = request.GET.get('warehouse', '').strip() or request.GET.get('prod_warehouse', '').strip()
|
||||
selected_destination = request.GET.get('destination', '').strip() or request.GET.get('prod_destination', '').strip()
|
||||
|
||||
accessible_warehouses = get_user_accessible_warehouses(request.user)
|
||||
production_orders = ProductionOrder.objects.filter(
|
||||
warehouse__id__in=accessible_warehouses
|
||||
).select_related('warehouse').order_by('-created_at')[:200]
|
||||
base_qs = ProductionOrder.objects.filter(warehouse__in=accessible_warehouses)
|
||||
|
||||
# Compteurs par statut (pour les filtres rapides mobiles)
|
||||
prod_counts = base_qs.aggregate(
|
||||
total=models.Count('id'),
|
||||
to_process=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_TO_PROCESS)),
|
||||
in_production=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_IN_PRODUCTION)),
|
||||
completed=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_COMPLETED)),
|
||||
draft=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_DRAFT)),
|
||||
cancelled=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_CANCELLED)),
|
||||
)
|
||||
|
||||
production_orders = base_qs.select_related(
|
||||
'warehouse', 'preparation_order', 'output_product', 'output_location',
|
||||
'preparation_order__intervention', 'preparation_order_other_item',
|
||||
).prefetch_related(
|
||||
'order_components__product',
|
||||
)
|
||||
|
||||
if selected_status:
|
||||
production_orders = production_orders.filter(status=selected_status)
|
||||
if selected_warehouse:
|
||||
production_orders = production_orders.filter(warehouse_id=selected_warehouse)
|
||||
if selected_destination:
|
||||
production_orders = production_orders.filter(destination=selected_destination)
|
||||
if search_q:
|
||||
q_filter = (
|
||||
models.Q(code__icontains=search_q) |
|
||||
models.Q(output_product__name__icontains=search_q) |
|
||||
models.Q(output_product_name__icontains=search_q) |
|
||||
models.Q(preparation_order__order_number__icontains=search_q) |
|
||||
models.Q(preparation_order__intervention__code__icontains=search_q) |
|
||||
models.Q(preparation_order__intervention__title__icontains=search_q) |
|
||||
models.Q(preparation_order_other_item__description__icontains=search_q) |
|
||||
models.Q(notes__icontains=search_q) |
|
||||
models.Q(order_components__product__name__icontains=search_q)
|
||||
)
|
||||
if search_q.isdigit():
|
||||
q_filter |= models.Q(id=int(search_q)) | models.Q(preparation_order__intervention__id=int(search_q))
|
||||
production_orders = production_orders.filter(q_filter).distinct()
|
||||
|
||||
production_orders = production_orders.order_by('-created_at')[:200]
|
||||
|
||||
return render(request, "stock/mobile/stock_production_orders_mobile.html", {
|
||||
'production_orders': production_orders,
|
||||
'search_q': search_q,
|
||||
'selected_status': selected_status,
|
||||
'selected_warehouse': selected_warehouse,
|
||||
'selected_destination': selected_destination,
|
||||
'status_choices': ProductionOrder.STATUS_CHOICES,
|
||||
'destination_choices': ProductionOrder.DESTINATION_CHOICES,
|
||||
'warehouses': accessible_warehouses,
|
||||
'prod_counts': prod_counts,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2237,6 +2237,19 @@ class ProductionOrder(models.Model):
|
|||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
|
||||
@property
|
||||
def display_output_product_name(self):
|
||||
"""Retourne le nom d'affichage du produit fini / confectionné."""
|
||||
if self.output_product:
|
||||
return self.output_product.name
|
||||
if self.output_product_name:
|
||||
return self.output_product_name
|
||||
if self.preparation_order_other_item and self.preparation_order_other_item.description:
|
||||
return self.preparation_order_other_item.description
|
||||
if self.notes and 'Issu de' in self.notes and ': ' in self.notes:
|
||||
return self.notes.split(': ', 1)[1].strip()
|
||||
return ""
|
||||
|
||||
def __str__(self):
|
||||
return _("Confection {code} x{qty}").format(
|
||||
code=self.code or f"#{self.pk}",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'mobile/mobile_base.html' %}
|
||||
{% extends 'mobile/mobile_base.html' %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
|
|
@ -31,44 +31,180 @@
|
|||
}
|
||||
.list-card-title { font-weight: 600; font-size: 1rem; }
|
||||
.list-card-meta { font-size: 0.83rem; color: #6c757d; }
|
||||
.status-pills-bar {
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
</style>
|
||||
{% endblock extra_head %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<!-- En-tête avec bouton Nouveau bon -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-bold fs-6 text-dark">
|
||||
<i class="bi bi-tools text-primary me-1"></i>{% translate "Bons de confection" %}
|
||||
</span>
|
||||
<a href="{% url 'mobile:production_order_create_mobile' %}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouveau bon" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filtres mobiles -->
|
||||
<div class="card mb-3 shadow-sm bg-light border-0">
|
||||
<div class="card-body p-2">
|
||||
<form method="get" class="row g-2">
|
||||
<div class="col-12">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" name="q" value="{{ search_q }}" placeholder="{% translate 'N° bon, produit, bon de prép...' %}">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<select class="form-select form-select-sm" name="status" onchange="this.form.submit()">
|
||||
<option value="">-- {% translate "Tous statuts" %} --</option>
|
||||
{% for val, label in status_choices %}
|
||||
<option value="{{ val }}" {% if selected_status == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{% if warehouses|length > 1 %}
|
||||
<select class="form-select form-select-sm" name="warehouse" onchange="this.form.submit()">
|
||||
<option value="">-- {% translate "Tous entrepôts" %} --</option>
|
||||
{% for wh in warehouses %}
|
||||
<option value="{{ wh.pk }}" {% if selected_warehouse == wh.pk|stringformat:"s" %}selected{% endif %}>{{ wh.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<select class="form-select form-select-sm" name="destination" onchange="this.form.submit()">
|
||||
<option value="">-- {% translate "Toutes destinations" %} --</option>
|
||||
{% for val, label in destination_choices %}
|
||||
<option value="{{ val }}" {% if selected_destination == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if selected_status or selected_warehouse or selected_destination or search_q %}
|
||||
<div class="col-12 text-center mt-1">
|
||||
<a href="{% url 'mobile:stock_production_orders_mobile' %}" class="btn btn-sm btn-outline-secondary py-0" style="font-size:0.75rem;">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres rapides par statut (Pills défilantes) -->
|
||||
<div class="d-flex gap-1 overflow-auto pb-2 mb-2 status-pills-bar" style="white-space: nowrap;">
|
||||
<a href="?{% if search_q %}q={{ search_q }}&{% endif %}{% if selected_warehouse %}warehouse={{ selected_warehouse }}&{% endif %}{% if selected_destination %}destination={{ selected_destination }}&{% endif %}"
|
||||
class="badge {% if not selected_status %}bg-primary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
|
||||
{% translate "Tous" %} ({{ prod_counts.total|default:0 }})
|
||||
</a>
|
||||
<a href="?status=to_process{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
|
||||
class="badge {% if selected_status == 'to_process' %}bg-danger text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
|
||||
<i class="bi bi-hourglass-split me-1"></i>{% translate "À traiter" %} ({{ prod_counts.to_process|default:0 }})
|
||||
</a>
|
||||
<a href="?status=in_production{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
|
||||
class="badge {% if selected_status == 'in_production' %}bg-primary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
|
||||
<i class="bi bi-gear-wide-connected me-1"></i>{% translate "En confection" %} ({{ prod_counts.in_production|default:0 }})
|
||||
</a>
|
||||
<a href="?status=completed{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
|
||||
class="badge {% if selected_status == 'completed' %}bg-success text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
|
||||
<i class="bi bi-check-circle me-1"></i>{% translate "Terminé" %} ({{ prod_counts.completed|default:0 }})
|
||||
</a>
|
||||
{% if prod_counts.draft %}
|
||||
<a href="?status=draft{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
|
||||
class="badge {% if selected_status == 'draft' %}bg-secondary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
|
||||
<i class="bi bi-pencil me-1"></i>{% translate "Brouillon" %} ({{ prod_counts.draft|default:0 }})
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if prod_counts.cancelled %}
|
||||
<a href="?status=cancelled{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
|
||||
class="badge {% if selected_status == 'cancelled' %}bg-secondary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Annulé" %} ({{ prod_counts.cancelled|default:0 }})
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Liste des bons -->
|
||||
{% if production_orders %}
|
||||
{% for order in production_orders %}
|
||||
<a href="{% url 'mobile:stock_production_order_detail_mobile' order.pk %}" class="list-card">
|
||||
<a href="{% url 'mobile:stock_production_order_detail_mobile' order.pk %}" class="list-card"
|
||||
style="border-left: 4px solid {% if order.status == 'to_process' %}#dc3545{% elif order.status == 'in_production' %}#0d6efd{% elif order.status == 'completed' %}#198754{% elif order.status == 'cancelled' %}#6c757d{% else %}#6c757d{% endif %} !important;">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="list-card-title">
|
||||
{% if order.code %}{{ order.code }}{% else %}#{{ order.id }}{% endif %}
|
||||
<div class="list-card-title text-primary">
|
||||
{{ order.code|default:order.id }}
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-end gap-1">
|
||||
<span class="badge {{ order.status_badge_class }}">
|
||||
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
||||
</span>
|
||||
{% if order.destination == 'internal' %}
|
||||
<span class="badge bg-light text-secondary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-house me-1"></i>{% translate "Interne" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-info border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Externe" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge {{ order.status_badge_class }}">
|
||||
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% if order.product_design %}
|
||||
<div class="list-card-meta mt-1">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ order.product_design.name }}
|
||||
|
||||
<!-- Produit confectionné -->
|
||||
<div class="mt-2 fw-semibold text-dark" style="font-size: 0.95rem;">
|
||||
<i class="bi bi-box-seam me-1 text-primary"></i>
|
||||
{% if order.output_product %}
|
||||
{{ order.output_product.name }}
|
||||
{% elif order.output_product_name %}
|
||||
{{ order.output_product_name }}
|
||||
<span class="badge bg-warning bg-opacity-25 text-dark border border-warning ms-1" style="font-size:0.7rem;">{% translate "Nouveau" %}</span>
|
||||
{% elif order.preparation_order_other_item %}
|
||||
{{ order.preparation_order_other_item.description }}
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic">{% translate "Produit non spécifié" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Bon de préparation lié si existant -->
|
||||
{% if order.preparation_order %}
|
||||
<div class="list-card-meta mt-1 text-primary">
|
||||
<i class="bi bi-clipboard-check me-1"></i>{% translate "Bon de prép." %} : <strong>#{{ order.preparation_order.order_number }}</strong>
|
||||
{% if order.preparation_order.intervention %}
|
||||
<span class="text-muted">({{ order.preparation_order.intervention.code|default:order.preparation_order.intervention.id }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="list-card-meta mt-1">
|
||||
<span class="me-3"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y" }}</span>
|
||||
<span><i class="bi bi-building me-1"></i>{{ order.warehouse.name }}</span>
|
||||
</div>
|
||||
<div class="list-card-meta mt-1">
|
||||
<i class="bi bi-layers me-1"></i>{% translate "Qté" %} : {{ order.quantity }}
|
||||
|
||||
<!-- Métadonnées : Entrepôt, Quantité, Composants, Date -->
|
||||
<div class="list-card-meta mt-2 d-flex justify-content-between align-items-center flex-wrap gap-1">
|
||||
<div>
|
||||
<span class="me-2"><i class="bi bi-building me-1"></i>{{ order.warehouse.name }}</span>
|
||||
<span class="badge bg-light text-dark border"><i class="bi bi-layers me-1"></i>{% translate "Qté" %} : <strong>{{ order.quantity }}</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
{% if order.order_components.count %}
|
||||
<span class="badge bg-light text-muted border me-1">
|
||||
<i class="bi bi-boxes me-1"></i>{{ order.order_components.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="small text-muted"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-tools fs-1 d-block mb-2"></i>
|
||||
{% translate "Aucun bon de confection." %}
|
||||
<div class="text-center text-muted py-5 card shadow-sm border-0">
|
||||
<i class="bi bi-tools fs-1 d-block mb-2 opacity-50"></i>
|
||||
<h6>{% translate "Aucun bon de confection." %}</h6>
|
||||
{% if selected_status or selected_warehouse or selected_destination or search_q %}
|
||||
<p class="small text-muted mb-2">{% translate "Aucun résultat pour cette recherche." %}</p>
|
||||
<a href="{% url 'mobile:stock_production_orders_mobile' %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -27,20 +27,25 @@
|
|||
<div class="container mt-2">
|
||||
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="preparation-orders-tab" data-bs-toggle="tab" data-bs-target="#preparation-orders-content" type="button" role="tab" aria-controls="preparation-orders" aria-selected="true"><i class="bi bi-clipboard-check"></i> {% translate "Bons de préparation" %}</button>
|
||||
<button class="nav-link {% if active_tab == 'preparation-orders' or not active_tab %}active{% endif %}" id="preparation-orders-tab" data-bs-toggle="tab" data-bs-target="#preparation-orders-content" type="button" role="tab" aria-controls="preparation-orders" aria-selected="{% if active_tab == 'preparation-orders' or not active_tab %}true{% else %}false{% endif %}"><i class="bi bi-clipboard-check"></i> {% translate "Bons de préparation" %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="production-orders-tab" data-bs-toggle="tab" data-bs-target="#production-orders-content" type="button" role="tab" aria-controls="production-orders" aria-selected="false"><i class="bi bi-pencil-square"></i> {% translate "Bons de confection" %}</button>
|
||||
<button class="nav-link {% if active_tab == 'production-orders' %}active{% endif %}" id="production-orders-tab" data-bs-toggle="tab" data-bs-target="#production-orders-content" type="button" role="tab" aria-controls="production-orders" aria-selected="{% if active_tab == 'production-orders' %}true{% else %}false{% endif %}">
|
||||
<i class="bi bi-tools"></i> {% translate "Bons de confection" %}
|
||||
{% if prod_counts.to_process %}
|
||||
<span class="badge bg-danger rounded-pill ms-1" title="{% translate 'À traiter' %}">{{ prod_counts.to_process }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="movements-tab" data-bs-toggle="tab" data-bs-target="#movements-content" type="button" role="tab" aria-controls="movements-table" aria-selected="false"><i class="bi bi-arrow-left-right"></i> {% translate "Mouvements de Stock" %}</button>
|
||||
<button class="nav-link {% if active_tab == 'movements' %}active{% endif %}" id="movements-tab" data-bs-toggle="tab" data-bs-target="#movements-content" type="button" role="tab" aria-controls="movements-table" aria-selected="{% if active_tab == 'movements' %}true{% else %}false{% endif %}"><i class="bi bi-arrow-left-right"></i> {% translate "Mouvements de Stock" %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="inventories-tab" data-bs-toggle="tab" data-bs-target="#inventories-content" type="button" role="tab" aria-controls="inventories-content" aria-selected="false"><i class="bi bi-journal-check"></i> {% translate "Inventaires" %}</button>
|
||||
<button class="nav-link {% if active_tab == 'inventories' %}active{% endif %}" id="inventories-tab" data-bs-toggle="tab" data-bs-target="#inventories-content" type="button" role="tab" aria-controls="inventories-content" aria-selected="{% if active_tab == 'inventories' %}true{% else %}false{% endif %}"><i class="bi bi-journal-check"></i> {% translate "Inventaires" %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content my-2" id="tablesTabContent">
|
||||
<div class="tab-pane fade show active" id="preparation-orders-content" role="tabpanel" aria-labelledby="preparation-orders-tab">
|
||||
<div class="tab-pane fade {% if active_tab == 'preparation-orders' or not active_tab %}show active{% endif %}" id="preparation-orders-content" role="tabpanel" aria-labelledby="preparation-orders-tab">
|
||||
<div id="preparation-orders-container">
|
||||
<!-- Filtres -->
|
||||
<div class="card mb-3 shadow-sm bg-light">
|
||||
|
|
@ -190,94 +195,283 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="production-orders-content" role="tabpanel" aria-labelledby="production-orders-tab">
|
||||
<div class="tab-pane fade {% if active_tab == 'production-orders' %}show active{% endif %}" id="production-orders-content" role="tabpanel" aria-labelledby="production-orders-tab">
|
||||
<div id="production-orders-container">
|
||||
{% if can_create_production_order %}
|
||||
<div class="d-flex justify-content-end align-items-center m-2">
|
||||
<a href="{% url 'stock:production_order_create' %}" class="btn btn-light">
|
||||
<i class="bi bi-plus-circle"></i> {% translate "Nouveau bon de confection" %}
|
||||
</a>
|
||||
<!-- Filtres Bons de confection -->
|
||||
<div class="card mb-3 shadow-sm bg-light">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-center">
|
||||
<!-- Préserver l'onglet actif -->
|
||||
<input type="hidden" name="tab" value="production-orders">
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" name="prod_q" value="{{ prod_search_q }}" placeholder="{% translate 'Code, produit, bon prép, composant...' %}">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<label class="input-group-text" for="filter-prod-status"><i class="bi bi-funnel me-1"></i>{% translate "Statut" %}</label>
|
||||
<select class="form-select" id="filter-prod-status" name="prod_status" onchange="this.form.submit()">
|
||||
<option value="">-- {% translate "Tous les statuts" %} --</option>
|
||||
{% for val, label in prod_status_choices %}
|
||||
<option value="{{ val }}" {% if prod_status == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<label class="input-group-text" for="filter-prod-warehouse"><i class="bi bi-building me-1"></i>{% translate "Entrepôt" %}</label>
|
||||
<select class="form-select" id="filter-prod-warehouse" name="prod_warehouse" onchange="this.form.submit()">
|
||||
<option value="">-- {% translate "Tous entrepôts" %} --</option>
|
||||
{% for wh in accessible_warehouses %}
|
||||
<option value="{{ wh.pk }}" {% if prod_warehouse == wh.pk|stringformat:"s" %}selected{% endif %}>{{ wh.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<label class="input-group-text" for="filter-prod-destination"><i class="bi bi-geo-alt me-1"></i>{% translate "Destination" %}</label>
|
||||
<select class="form-select" id="filter-prod-destination" name="prod_destination" onchange="this.form.submit()">
|
||||
<option value="">-- {% translate "Toutes" %} --</option>
|
||||
{% for val, label in prod_destination_choices %}
|
||||
<option value="{{ val }}" {% if prod_destination == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex justify-content-end align-items-center gap-1">
|
||||
{% if prod_status or prod_warehouse or prod_destination or prod_search_q %}
|
||||
<a href="?tab=production-orders" class="btn btn-sm btn-outline-secondary" title="{% translate 'Effacer les filtres' %}">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_create_production_order %}
|
||||
<a href="{% url 'stock:production_order_create' %}" class="btn btn-sm btn-primary text-nowrap">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouveau bon" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtres rapides par statut (Pills) -->
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||
<span class="small text-muted fw-semibold me-1"><i class="bi bi-funnel me-1"></i>{% translate "Vue rapide :" %}</span>
|
||||
<a href="?tab=production-orders{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
|
||||
class="btn btn-sm py-1 px-2 {% if not prod_status %}btn-primary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||
{% translate "Tous" %} <span class="badge {% if not prod_status %}bg-white text-primary{% else %}bg-secondary{% endif %} ms-1">{{ prod_counts.total|default:0 }}</span>
|
||||
</a>
|
||||
<a href="?tab=production-orders&prod_status=to_process{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
|
||||
class="btn btn-sm py-1 px-2 {% if prod_status == 'to_process' %}btn-danger text-white{% else %}btn-outline-danger{% endif %} rounded-pill">
|
||||
<i class="bi bi-hourglass-split me-1"></i>{% translate "À traiter" %} <span class="badge {% if prod_status == 'to_process' %}bg-white text-danger{% else %}bg-danger text-white{% endif %} ms-1">{{ prod_counts.to_process|default:0 }}</span>
|
||||
</a>
|
||||
<a href="?tab=production-orders&prod_status=in_production{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
|
||||
class="btn btn-sm py-1 px-2 {% if prod_status == 'in_production' %}btn-primary{% else %}btn-outline-primary{% endif %} rounded-pill">
|
||||
<i class="bi bi-gear-wide-connected me-1"></i>{% translate "En confection" %} <span class="badge {% if prod_status == 'in_production' %}bg-white text-primary{% else %}bg-primary text-white{% endif %} ms-1">{{ prod_counts.in_production|default:0 }}</span>
|
||||
</a>
|
||||
<a href="?tab=production-orders&prod_status=completed{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
|
||||
class="btn btn-sm py-1 px-2 {% if prod_status == 'completed' %}btn-success{% else %}btn-outline-success{% endif %} rounded-pill">
|
||||
<i class="bi bi-check-circle me-1"></i>{% translate "Terminé" %} <span class="badge {% if prod_status == 'completed' %}bg-white text-success{% else %}bg-success text-white{% endif %} ms-1">{{ prod_counts.completed|default:0 }}</span>
|
||||
</a>
|
||||
{% if prod_counts.draft %}
|
||||
<a href="?tab=production-orders&prod_status=draft{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
|
||||
class="btn btn-sm py-1 px-2 {% if prod_status == 'draft' %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||
<i class="bi bi-pencil me-1"></i>{% translate "Brouillon" %} <span class="badge {% if prod_status == 'draft' %}bg-white text-secondary{% else %}bg-secondary text-white{% endif %} ms-1">{{ prod_counts.draft|default:0 }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if prod_counts.cancelled %}
|
||||
<a href="?tab=production-orders&prod_status=cancelled{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
|
||||
class="btn btn-sm py-1 px-2 {% if prod_status == 'cancelled' %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Annulé" %} <span class="badge {% if prod_status == 'cancelled' %}bg-white text-secondary{% else %}bg-secondary text-white{% endif %} ms-1">{{ prod_counts.cancelled|default:0 }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% for order in production_orders %}
|
||||
<div class="card mb-3 shadow-sm
|
||||
{% if order.status == 'draft' %}border-secondary
|
||||
{% if order.status == 'to_process' %}border-danger
|
||||
{% elif order.status == 'in_production' %}border-primary
|
||||
{% elif order.status == 'completed' %}border-success
|
||||
{% elif order.status == 'cancelled' %}border-danger
|
||||
{% elif order.status == 'cancelled' %}border-secondary
|
||||
{% else %}border-secondary{% endif %}"
|
||||
style="border-left-width: 4px !important;">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="card-header d-flex align-items-center gap-2 flex-wrap py-2
|
||||
{% if order.status == 'draft' %}bg-secondary bg-opacity-10
|
||||
{% if order.status == 'to_process' %}bg-danger bg-opacity-10
|
||||
{% elif order.status == 'in_production' %}bg-primary bg-opacity-10
|
||||
{% elif order.status == 'completed' %}bg-success bg-opacity-10
|
||||
{% elif order.status == 'cancelled' %}bg-danger bg-opacity-10
|
||||
{% elif order.status == 'cancelled' %}bg-secondary bg-opacity-10
|
||||
{% else %}bg-secondary bg-opacity-10{% endif %}">
|
||||
|
||||
<a href="{% url 'stock:production_order_detail' order.pk %}" class="fw-bold text-decoration-none me-1">
|
||||
{{ order.code }}
|
||||
<a href="{% url 'stock:production_order_detail' order.pk %}" class="fw-bold text-decoration-none me-1 fs-6">
|
||||
{{ order.code|default:order.pk }}
|
||||
</a>
|
||||
|
||||
{% if order.status == 'draft' %}
|
||||
<span class="badge bg-secondary">{{ order.get_status_display }}</span>
|
||||
{% elif order.status == 'in_production' %}
|
||||
<span class="badge bg-primary"><i class="bi bi-gear me-1"></i>{{ order.get_status_display }}</span>
|
||||
{% elif order.status == 'completed' %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{{ order.get_status_display }}</span>
|
||||
{% elif order.status == 'cancelled' %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>{{ order.get_status_display }}</span>
|
||||
<span class="badge {{ order.status_badge_class }}">
|
||||
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if order.destination == 'internal' %}
|
||||
<span class="badge bg-light text-secondary border"><i class="bi bi-house me-1"></i>{% translate "Interne" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-info border"><i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Externe" %}</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-muted small ms-auto"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y" }}</span>
|
||||
{% if order.preparation_order %}
|
||||
<a href="{% url 'stock:preparation_order_detail' order.preparation_order.pk %}" class="badge bg-light text-dark border text-decoration-none" title="{% translate 'Bon de préparation lié' %}">
|
||||
<i class="bi bi-clipboard-check me-1 text-primary"></i>#{{ order.preparation_order.order_number }}
|
||||
</a>
|
||||
{% if order.preparation_order.intervention %}
|
||||
<a href="{% url 'interventions:intervention_detail' order.preparation_order.intervention.id %}" class="badge bg-light text-dark border text-decoration-none" title="{% translate 'Intervention liée' %}">
|
||||
<i class="bi bi-wrench me-1 text-primary"></i>{{ order.preparation_order.intervention.code|default:order.preparation_order.intervention.id }}{% if order.preparation_order.intervention.title %} — {{ order.preparation_order.intervention.title|truncatechars:30 }}{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="bi bi-building me-1 text-muted"></i>{{ order.warehouse.name }}
|
||||
</span>
|
||||
|
||||
{% if order.documents.count %}
|
||||
<span class="badge bg-light text-muted border" title="{% translate 'Documents attachés' %}">
|
||||
<i class="bi bi-paperclip me-1"></i>{{ order.documents.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-muted small ms-auto"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y H:i" }}</span>
|
||||
<a href="{% url 'stock:production_order_detail' order.pk %}" class="btn btn-sm btn-outline-primary ms-1" title="{% translate 'Voir le détail' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0" style="table-layout:fixed;width:100%;">
|
||||
<colgroup>
|
||||
<col style="width:40%">
|
||||
<col style="width:20%">
|
||||
<col style="width:40%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid rgba(0,0,0,0.1);">
|
||||
<th class="ps-3 text-muted small fw-semibold text-uppercase">{% translate "Entrepôt" %}</th>
|
||||
<th class="text-muted small fw-semibold text-uppercase">{% translate "Quantité" %}</th>
|
||||
<th class="text-muted small fw-semibold text-uppercase">{% translate "Bon de prép." %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 py-2">{{ order.warehouse.name }}</td>
|
||||
<td class="py-2 fw-semibold">{{ order.quantity }}</td>
|
||||
<td class="py-2">
|
||||
{% if order.preparation_order %}
|
||||
<a href="{% url 'stock:preparation_order_detail' order.preparation_order.pk %}">
|
||||
#{{ order.preparation_order.order_number }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
<!-- Corps du bon de confection -->
|
||||
<div class="card-body p-3">
|
||||
<div class="row align-items-center g-3">
|
||||
<!-- Produit fini -->
|
||||
<div class="col-md-5">
|
||||
<div class="small text-muted text-uppercase fw-semibold mb-1">
|
||||
<i class="bi bi-box-seam me-1"></i>{% translate "Produit confectionné" %}
|
||||
</div>
|
||||
<div class="fs-6 fw-bold text-dark">
|
||||
{% if order.output_product %}
|
||||
<a href="{% url 'stock:product_detail' order.output_product.pk %}" class="text-decoration-none text-dark">
|
||||
{{ order.output_product.name }}
|
||||
</a>
|
||||
{% if order.output_product.code %}
|
||||
<span class="badge bg-light text-muted border fw-normal ms-1">{{ order.output_product.code }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif order.output_product_name %}
|
||||
<span>{{ order.output_product_name }}</span>
|
||||
<span class="badge bg-warning bg-opacity-25 text-dark border border-warning fw-normal ms-1">{% translate "Nouveau produit" %}</span>
|
||||
{% elif order.preparation_order_other_item %}
|
||||
<span>{{ order.preparation_order_other_item.description }}</span>
|
||||
<span class="badge bg-light text-muted border fw-normal ms-1">{% translate "Article sur mesure" %}</span>
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic">{% translate "Non spécifié" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if order.output_location %}
|
||||
<div class="small text-muted mt-1">
|
||||
<i class="bi bi-geo-alt me-1"></i>{% translate "Emplacement cible :" %} {{ order.output_location.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Quantité & Destination -->
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<div>
|
||||
<span class="text-muted small">{% translate "Quantité à concevoir :" %}</span>
|
||||
<span class="badge bg-primary fs-6 ms-1">{{ order.quantity }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted small">{% translate "Entrepôt :" %}</span>
|
||||
<strong>{{ order.warehouse.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statut & Dates -->
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex flex-column gap-1 small">
|
||||
{% if order.completed_at %}
|
||||
<div class="text-success fw-semibold">
|
||||
<i class="bi bi-check-circle me-1"></i>{% translate "Terminé le" %} {{ order.completed_at|date:"d/m/Y H:i" }}
|
||||
</div>
|
||||
{% elif order.status == 'in_production' %}
|
||||
<div class="text-primary fw-semibold">
|
||||
<i class="bi bi-gear-wide-connected me-1"></i>{% translate "En cours de confection" %}
|
||||
</div>
|
||||
{% elif order.status == 'to_process' %}
|
||||
<div class="text-danger fw-semibold">
|
||||
<i class="bi bi-hourglass-split me-1"></i>{% translate "En attente de confection" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if order.notes %}
|
||||
<div class="text-muted fst-italic">
|
||||
<i class="bi bi-chat-left-text me-1"></i>{{ order.notes|truncatechars:90 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Composants associés -->
|
||||
{% if order.order_components.all %}
|
||||
<div class="mt-3 pt-2 border-top">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="small text-muted fw-semibold text-uppercase">
|
||||
<i class="bi bi-boxes me-1"></i>{% translate "Composants requis" %} ({{ order.order_components.count }}) :
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for comp in order.order_components.all %}
|
||||
<span class="badge bg-light text-dark border p-2 d-inline-flex align-items-center">
|
||||
<i class="bi bi-box me-1 text-muted"></i>
|
||||
{{ comp.product.name }}
|
||||
<span class="badge bg-secondary ms-2" title="{% translate 'Quantité requise' %}">
|
||||
x{{ comp.quantity }}
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-pencil-square fs-2 d-block mb-2 opacity-50"></i>
|
||||
{% translate "Aucun bon de confection." %}
|
||||
<div class="text-center text-muted py-5 card shadow-sm">
|
||||
<i class="bi bi-tools fs-1 d-block mb-2 text-secondary opacity-50"></i>
|
||||
<h5>{% translate "Aucun bon de confection trouvé." %}</h5>
|
||||
{% if prod_status or prod_warehouse or prod_destination or prod_search_q %}
|
||||
<p class="small text-muted">{% translate "Aucun résultat ne correspond aux filtres appliqués." %}</p>
|
||||
<a href="?tab=production-orders" class="btn btn-sm btn-outline-secondary mt-1">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="small text-muted">{% translate "Aucun bon de confection enregistré." %}</p>
|
||||
{% if can_create_production_order %}
|
||||
<a href="{% url 'stock:production_order_create' %}" class="btn btn-sm btn-primary mt-1">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer un bon de confection" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="movements-content" role="tabpanel" aria-labelledby="movements-tab">
|
||||
<div class="tab-pane fade {% if active_tab == 'movements' %}show active{% endif %}" id="movements-content" role="tabpanel" aria-labelledby="movements-tab">
|
||||
<div id="movements-container">
|
||||
{% if can_create_movement %}
|
||||
<div class="d-flex justify-content-end align-items-center gap-2 m-2 flex-wrap">
|
||||
|
|
@ -398,7 +592,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="inventories-content" role="tabpanel" aria-labelledby="inventories-tab">
|
||||
<div class="tab-pane fade {% if active_tab == 'inventories' %}show active{% endif %}" id="inventories-content" role="tabpanel" aria-labelledby="inventories-tab">
|
||||
<div id="inventories-container">
|
||||
{% if can_create_inventory %}
|
||||
<div class="d-flex justify-content-end align-items-center m-2">
|
||||
|
|
|
|||
|
|
@ -452,6 +452,104 @@ class PreparationOrderPriorityTests(TestCase):
|
|||
self.assertIn(po2, list(response.context["preparation_orders"]))
|
||||
self.assertNotIn(po1, list(response.context["preparation_orders"]))
|
||||
|
||||
def test_stock_movements_production_order_filters(self):
|
||||
from .models import ProductionOrder, Warehouse, Product
|
||||
wh1 = Warehouse.objects.create(name="Entrepôt Nord", code="WH-N-TEST")
|
||||
wh2 = Warehouse.objects.create(name="Entrepôt Sud", code="WH-S-TEST")
|
||||
prod = Product.objects.create(name="Armoire sur mesure", code="CONF-01-TEST", sku="SKU-CONF-01-TEST")
|
||||
|
||||
order1 = ProductionOrder.objects.create(
|
||||
code="CONF-2026-0001",
|
||||
warehouse=wh1,
|
||||
status="to_process",
|
||||
destination="internal",
|
||||
output_product=prod,
|
||||
quantity=2,
|
||||
notes="Confection spéciale pour bureau",
|
||||
)
|
||||
order2 = ProductionOrder.objects.create(
|
||||
code="CONF-2026-0002",
|
||||
warehouse=wh2,
|
||||
status="completed",
|
||||
destination="external",
|
||||
output_product_name="Table extérieure",
|
||||
quantity=1,
|
||||
notes="Chantier voirie",
|
||||
)
|
||||
|
||||
# 1. Test status filter
|
||||
response = self.client.get(reverse("stock:stock_movements") + "?tab=production-orders&prod_status=to_process")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order1, list(response.context["production_orders"]))
|
||||
self.assertNotIn(order2, list(response.context["production_orders"]))
|
||||
|
||||
# 2. Test search filter
|
||||
response = self.client.get(reverse("stock:stock_movements") + "?tab=production-orders&prod_q=extérieure")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order2, list(response.context["production_orders"]))
|
||||
self.assertNotIn(order1, list(response.context["production_orders"]))
|
||||
|
||||
# 3. Test destination filter
|
||||
response = self.client.get(reverse("stock:stock_movements") + "?tab=production-orders&prod_destination=internal")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order1, list(response.context["production_orders"]))
|
||||
self.assertNotIn(order2, list(response.context["production_orders"]))
|
||||
|
||||
# 4. Test warehouse filter
|
||||
response = self.client.get(reverse("stock:stock_movements") + f"?tab=production-orders&prod_warehouse={wh2.id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order2, list(response.context["production_orders"]))
|
||||
self.assertNotIn(order1, list(response.context["production_orders"]))
|
||||
|
||||
# 5. Check prod_counts in context
|
||||
self.assertIn("prod_counts", response.context)
|
||||
self.assertGreaterEqual(response.context["prod_counts"]["to_process"], 1)
|
||||
self.assertGreaterEqual(response.context["prod_counts"]["completed"], 1)
|
||||
|
||||
def test_mobile_production_orders_filters(self):
|
||||
from .models import ProductionOrder, Warehouse
|
||||
wh = Warehouse.objects.create(name="Atelier Mobile", code="WH-MOB-TEST")
|
||||
order1 = ProductionOrder.objects.create(
|
||||
code="CONF-MOB-01",
|
||||
warehouse=wh,
|
||||
status="to_process",
|
||||
destination="internal",
|
||||
output_product_name="Banc public",
|
||||
)
|
||||
order2 = ProductionOrder.objects.create(
|
||||
code="CONF-MOB-02",
|
||||
warehouse=wh,
|
||||
status="in_production",
|
||||
destination="external",
|
||||
output_product_name="Potelet spécial",
|
||||
)
|
||||
|
||||
# Status filter
|
||||
response = self.client.get(reverse("mobile:stock_production_orders_mobile") + "?status=to_process")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order1, list(response.context["production_orders"]))
|
||||
self.assertNotIn(order2, list(response.context["production_orders"]))
|
||||
|
||||
# Search filter
|
||||
response = self.client.get(reverse("mobile:stock_production_orders_mobile") + "?q=Potelet")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order2, list(response.context["production_orders"]))
|
||||
self.assertNotIn(order1, list(response.context["production_orders"]))
|
||||
|
||||
def test_production_order_display_output_product_name(self):
|
||||
from .models import ProductionOrder, Warehouse, Product
|
||||
wh = Warehouse.objects.create(name="WH-Disp", code="WH-D-TEST")
|
||||
p = Product.objects.create(name="Produit Existant", code="PR-D-TEST", sku="SKU-D-TEST")
|
||||
|
||||
o1 = ProductionOrder.objects.create(code="CONF-D1", warehouse=wh, output_product=p)
|
||||
self.assertEqual(o1.display_output_product_name, "Produit Existant")
|
||||
|
||||
o2 = ProductionOrder.objects.create(code="CONF-D2", warehouse=wh, output_product_name="Nouveau Produit")
|
||||
self.assertEqual(o2.display_output_product_name, "Nouveau Produit")
|
||||
|
||||
o3 = ProductionOrder.objects.create(code="CONF-D3", warehouse=wh, notes="Confection de : Panneau bois\nAutre note")
|
||||
self.assertEqual(o3.display_output_product_name, "Panneau bois")
|
||||
|
||||
def test_preparation_order_detail_shows_intervention_manager(self):
|
||||
from django.contrib.auth.models import User
|
||||
from interventions.models import Intervention
|
||||
|
|
|
|||
|
|
@ -588,9 +588,12 @@ def sign_panel_types_api(request):
|
|||
|
||||
|
||||
def stock_movements(request):
|
||||
selected_status = request.GET.get('status', '').strip()
|
||||
tab = request.GET.get('tab', 'preparation-orders').strip()
|
||||
|
||||
# Filtres Bons de préparation
|
||||
selected_status = request.GET.get('prep_status', '').strip() or (request.GET.get('status', '').strip() if tab != 'production-orders' else '')
|
||||
selected_priority = request.GET.get('priority', '').strip()
|
||||
search_q = request.GET.get('q', '').strip()
|
||||
search_q = request.GET.get('prep_q', '').strip() or (request.GET.get('q', '').strip() if tab != 'production-orders' else '')
|
||||
|
||||
preparation_orders = PreparationOrder.objects.select_related('intervention').all()
|
||||
if selected_status:
|
||||
|
|
@ -609,11 +612,59 @@ def stock_movements(request):
|
|||
preparation_orders = preparation_orders.filter(q_filter)
|
||||
preparation_orders = preparation_orders.order_by("-date")[:1000]
|
||||
|
||||
production_orders = ProductionOrder.objects.all().select_related(
|
||||
'warehouse', 'preparation_order'
|
||||
).order_by("-created_at")[:1000]
|
||||
# Filtrer les mouvements de stock et les inventaires selon les entrepôts accessibles
|
||||
# Filtrer selon les entrepôts accessibles
|
||||
accessible_warehouses = get_user_accessible_warehouses(request.user)
|
||||
|
||||
# Filtres Bons de confection
|
||||
prod_status = request.GET.get('prod_status', '').strip() or (request.GET.get('status', '').strip() if tab == 'production-orders' else '')
|
||||
prod_search_q = request.GET.get('prod_q', '').strip() or (request.GET.get('q', '').strip() if tab == 'production-orders' else '')
|
||||
prod_warehouse = request.GET.get('prod_warehouse', '').strip() or (request.GET.get('warehouse', '').strip() if tab == 'production-orders' else '')
|
||||
prod_destination = request.GET.get('prod_destination', '').strip() or (request.GET.get('destination', '').strip() if tab == 'production-orders' else '')
|
||||
|
||||
prod_base_qs = ProductionOrder.objects.filter(warehouse__in=accessible_warehouses)
|
||||
|
||||
# Compteurs par statut (pour les badges / filtres rapides)
|
||||
prod_counts = prod_base_qs.aggregate(
|
||||
total=models.Count('id'),
|
||||
to_process=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_TO_PROCESS)),
|
||||
in_production=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_IN_PRODUCTION)),
|
||||
completed=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_COMPLETED)),
|
||||
draft=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_DRAFT)),
|
||||
cancelled=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_CANCELLED)),
|
||||
)
|
||||
|
||||
production_orders = prod_base_qs.select_related(
|
||||
'warehouse', 'preparation_order', 'output_product', 'output_location',
|
||||
'preparation_order__intervention', 'preparation_order_other_item',
|
||||
).prefetch_related(
|
||||
'order_components__product',
|
||||
'documents',
|
||||
)
|
||||
|
||||
if prod_status:
|
||||
production_orders = production_orders.filter(status=prod_status)
|
||||
if prod_warehouse:
|
||||
production_orders = production_orders.filter(warehouse_id=prod_warehouse)
|
||||
if prod_destination:
|
||||
production_orders = production_orders.filter(destination=prod_destination)
|
||||
if prod_search_q:
|
||||
prod_q_filter = (
|
||||
models.Q(code__icontains=prod_search_q) |
|
||||
models.Q(output_product__name__icontains=prod_search_q) |
|
||||
models.Q(output_product_name__icontains=prod_search_q) |
|
||||
models.Q(preparation_order__order_number__icontains=prod_search_q) |
|
||||
models.Q(preparation_order__intervention__code__icontains=prod_search_q) |
|
||||
models.Q(preparation_order__intervention__title__icontains=prod_search_q) |
|
||||
models.Q(preparation_order_other_item__description__icontains=prod_search_q) |
|
||||
models.Q(notes__icontains=prod_search_q) |
|
||||
models.Q(order_components__product__name__icontains=prod_search_q)
|
||||
)
|
||||
if prod_search_q.isdigit():
|
||||
prod_q_filter |= models.Q(id=int(prod_search_q)) | models.Q(preparation_order__intervention__id=int(prod_search_q))
|
||||
production_orders = production_orders.filter(prod_q_filter).distinct()
|
||||
|
||||
production_orders = production_orders.order_by("-created_at")[:1000]
|
||||
|
||||
movements = StockMovement.objects.filter(
|
||||
models.Q(source_location__warehouse__id__in=accessible_warehouses) |
|
||||
models.Q(destination_location__warehouse__id__in=accessible_warehouses)
|
||||
|
|
@ -698,9 +749,18 @@ def stock_movements(request):
|
|||
"company_members": company_members,
|
||||
"companies": companies,
|
||||
"contracts": contracts,
|
||||
"active_tab": tab,
|
||||
"selected_status": selected_status,
|
||||
"selected_priority": selected_priority,
|
||||
"search_q": search_q,
|
||||
"prod_status": prod_status,
|
||||
"prod_search_q": prod_search_q,
|
||||
"prod_warehouse": prod_warehouse,
|
||||
"prod_destination": prod_destination,
|
||||
"prod_status_choices": ProductionOrder.STATUS_CHOICES,
|
||||
"prod_destination_choices": ProductionOrder.DESTINATION_CHOICES,
|
||||
"prod_counts": prod_counts,
|
||||
"accessible_warehouses": accessible_warehouses,
|
||||
"preparation_order_status_choices": [
|
||||
('to_process', _("A traiter")),
|
||||
('pending', _("En attente")),
|
||||
|
|
@ -5650,7 +5710,13 @@ def product_delete(request, product_id):
|
|||
def production_center(request):
|
||||
"""Redirige vers l'onglet Bons de confection de la page mouvements de stock."""
|
||||
from django.urls import reverse
|
||||
return redirect(reverse('stock:stock_movements') + '?tab=production-orders')
|
||||
url = reverse('stock:stock_movements') + '?tab=production-orders'
|
||||
params = request.GET.copy()
|
||||
if 'tab' in params:
|
||||
del params['tab']
|
||||
if params:
|
||||
url += '&' + params.urlencode()
|
||||
return redirect(url)
|
||||
|
||||
|
||||
def product_design_list(request):
|
||||
|
|
|
|||
Loading…
Reference in a new issue