chore: update localization files and templates for stock and intervention modules

This commit is contained in:
kdeterme 2026-09-11 11:37:51 +02:00
parent fd10b3f8f7
commit 261ce2dd14
25 changed files with 17939 additions and 16231 deletions

View file

@ -298,10 +298,8 @@ msgid "Autre"
msgstr "Other" msgstr "Other"
#: contracts/models.py:225 #: contracts/models.py:225
#, fuzzy
#| msgid "Date d'entrée"
msgid "Date d'absence" msgid "Date d'absence"
msgstr "Creation date" msgstr "Absence date"
#: contracts/models.py:231 contracts/templates/contracts/teams_index.html:888 #: contracts/models.py:231 contracts/templates/contracts/teams_index.html:888
msgid "Motif d'absence" msgid "Motif d'absence"
@ -314,22 +312,16 @@ msgid "Commentaire"
msgstr "Comment" msgstr "Comment"
#: contracts/models.py:240 #: contracts/models.py:240
#, fuzzy
#| msgid "Enregistrer"
msgid "Enregistré par" msgid "Enregistré par"
msgstr "Save" msgstr "Recorded by"
#: contracts/models.py:242 #: contracts/models.py:242
#, fuzzy
#| msgid "Créé par"
msgid "Créé le" msgid "Créé le"
msgstr "Created by" msgstr "Created on"
#: contracts/models.py:248 #: contracts/models.py:248
#, fuzzy
#| msgid "Aucun membre"
msgid "Absence d'un membre" msgid "Absence d'un membre"
msgstr "No member" msgstr "Member absence"
#: contracts/models.py:249 #: contracts/models.py:249
msgid "Absences des membres" msgid "Absences des membres"
@ -546,10 +538,8 @@ msgid "Raison du blocage temporaire du contrat."
msgstr "" msgstr ""
#: contracts/models.py:407 #: contracts/models.py:407
#, fuzzy
#| msgid "Date de commande"
msgid "Date de blocage" msgid "Date de blocage"
msgstr "Order date" msgstr "Blocking date"
#: contracts/models.py:408 #: contracts/models.py:408
msgid "Date et heure auxquelles le contrat a été marqué comme bloqué." msgid "Date et heure auxquelles le contrat a été marqué comme bloqué."

View file

@ -307,10 +307,8 @@ msgid "Autre"
msgstr "Autre" msgstr "Autre"
#: contracts/models.py:225 #: contracts/models.py:225
#, fuzzy
#| msgid "Date d'entrée"
msgid "Date d'absence" msgid "Date d'absence"
msgstr "Date d'entrée" msgstr "Date d'absence"
#: contracts/models.py:231 contracts/templates/contracts/teams_index.html:888 #: contracts/models.py:231 contracts/templates/contracts/teams_index.html:888
msgid "Motif d'absence" msgid "Motif d'absence"
@ -323,22 +321,16 @@ msgid "Commentaire"
msgstr "Commentaire" msgstr "Commentaire"
#: contracts/models.py:240 #: contracts/models.py:240
#, fuzzy
#| msgid "Enregistrer"
msgid "Enregistré par" msgid "Enregistré par"
msgstr "Enregistrer" msgstr "Enregistré par"
#: contracts/models.py:242 #: contracts/models.py:242
#, fuzzy
#| msgid "Créé par"
msgid "Créé le" msgid "Créé le"
msgstr "Créé par" msgstr "Créé le"
#: contracts/models.py:248 #: contracts/models.py:248
#, fuzzy
#| msgid "Aucun membre"
msgid "Absence d'un membre" msgid "Absence d'un membre"
msgstr "Aucun membre" msgstr "Absence d'un membre"
#: contracts/models.py:249 #: contracts/models.py:249
msgid "Absences des membres" msgid "Absences des membres"
@ -578,10 +570,8 @@ msgid "Raison du blocage temporaire du contrat."
msgstr "" msgstr ""
#: contracts/models.py:407 #: contracts/models.py:407
#, fuzzy
#| msgid "Date de commande"
msgid "Date de blocage" msgid "Date de blocage"
msgstr "Date de commande" msgstr "Date de blocage"
#: contracts/models.py:408 #: contracts/models.py:408
msgid "Date et heure auxquelles le contrat a été marqué comme bloqué." msgid "Date et heure auxquelles le contrat a été marqué comme bloqué."

View file

@ -299,10 +299,8 @@ msgid "Autre"
msgstr "Andere" msgstr "Andere"
#: contracts/models.py:225 #: contracts/models.py:225
#, fuzzy
#| msgid "Date d'entrée"
msgid "Date d'absence" msgid "Date d'absence"
msgstr "Aanmaakdatum" msgstr "Afwezigheidsdatum"
#: contracts/models.py:231 contracts/templates/contracts/teams_index.html:888 #: contracts/models.py:231 contracts/templates/contracts/teams_index.html:888
msgid "Motif d'absence" msgid "Motif d'absence"
@ -315,22 +313,16 @@ msgid "Commentaire"
msgstr "Opmerking" msgstr "Opmerking"
#: contracts/models.py:240 #: contracts/models.py:240
#, fuzzy
#| msgid "Enregistrer"
msgid "Enregistré par" msgid "Enregistré par"
msgstr "Opslaan" msgstr "Geregistreerd door"
#: contracts/models.py:242 #: contracts/models.py:242
#, fuzzy
#| msgid "Créé par"
msgid "Créé le" msgid "Créé le"
msgstr "Aangemaakt door" msgstr "Aangemaakt op"
#: contracts/models.py:248 #: contracts/models.py:248
#, fuzzy
#| msgid "Aucun membre"
msgid "Absence d'un membre" msgid "Absence d'un membre"
msgstr "Geen lid" msgstr "Afwezigheid van een lid"
#: contracts/models.py:249 #: contracts/models.py:249
msgid "Absences des membres" msgid "Absences des membres"
@ -547,10 +539,8 @@ msgid "Raison du blocage temporaire du contrat."
msgstr "" msgstr ""
#: contracts/models.py:407 #: contracts/models.py:407
#, fuzzy
#| msgid "Date de commande"
msgid "Date de blocage" msgid "Date de blocage"
msgstr "Besteldatum" msgstr "Blokkeringsdatum"
#: contracts/models.py:408 #: contracts/models.py:408
msgid "Date et heure auxquelles le contrat a été marqué comme bloqué." msgid "Date et heure auxquelles le contrat a été marqué comme bloqué."

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,20 +7,6 @@
<span>{% translate "Matériel / Stock" %}</span> <span>{% translate "Matériel / Stock" %}</span>
</h4> </h4>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
{% if material_stats.has_material %}
<span class="badge bg-light text-dark border py-2 px-2" id="desktop-material-stats-badge" title="{% translate 'Matériel utilisé sur cette intervention' %}">
<i class="bi bi-tools text-primary me-1"></i>
<span id="desktop-material-used-count">{{ material_stats.used }}</span> / <span id="desktop-material-total-count">{{ material_stats.total }}</span> {% translate "utilisé(s)" %}
</span>
<button type="button"
class="btn btn-sm btn-outline-success d-flex align-items-center gap-1"
id="btn-desktop-toggle-all-material"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', null)"
{% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% else %}title="{% translate 'Tout cocher ou tout décocher pour cette intervention' %}"{% endif %}>
<i class="bi bi-check-all fs-6"></i>
<span id="btn-desktop-toggle-all-text">{% if material_stats.all_used %}{% translate "Tout décocher" %}{% else %}{% translate "Tout cocher" %}{% endif %}</span>
</button>
{% endif %}
{% if can_create_preparation_order %} {% if can_create_preparation_order %}
<a href="{% url 'stock:new_preparation_order_from_intervention' intervention.id %}" <a href="{% url 'stock:new_preparation_order_from_intervention' intervention.id %}"
class="btn btn-sm btn-primary d-flex align-items-center gap-1" class="btn btn-sm btn-primary d-flex align-items-center gap-1"

View file

@ -1112,23 +1112,6 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if material_stats.has_material %}
<div class="bg-light px-3 py-2 border-bottom d-flex justify-content-between align-items-center flex-wrap gap-2">
<span class="badge bg-white text-dark border py-1 px-2" id="mobile-material-stats-badge">
<i class="bi bi-tools text-primary me-1"></i>
<span id="mobile-material-used-count">{{ material_stats.used }}</span> / <span id="mobile-material-total-count">{{ material_stats.total }}</span> {% translate "utilisé(s)" %}
</span>
<button type="button"
class="btn btn-sm btn-outline-success d-flex align-items-center gap-1 py-1 px-2"
id="btn-mobile-toggle-all-material"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', null)"
{% if not can_edit_intervention %}disabled{% endif %}
style="font-size:0.75rem;">
<i class="bi bi-check-all fs-6"></i>
<span id="btn-mobile-toggle-all-text">{% if material_stats.all_used %}{% translate "Tout décocher" %}{% else %}{% translate "Tout cocher" %}{% endif %}</span>
</button>
</div>
{% endif %}
<div class="card-body p-2"> <div class="card-body p-2">
{% if preparation_orders %} {% if preparation_orders %}
{% for order in preparation_orders %} {% for order in preparation_orders %}

View file

@ -308,3 +308,38 @@ class MaterialUsedTests(TestCase):
self.assertIn('material_stats', response_mobile.context) self.assertIn('material_stats', response_mobile.context)
self.assertContains(response_mobile, 'id="section-stock"') self.assertContains(response_mobile, 'id="section-stock"')
self.assertContains(response_mobile, 'material-used-checkbox') self.assertContains(response_mobile, 'material-used-checkbox')
def test_material_translations(self):
"""Vérifie la traduction des termes liés au matériel utilisé en FR, NL et EN."""
from django.utils.translation import activate, gettext as _
# Français
activate('fr')
self.assertEqual(_("Matériel / Stock"), "Matériel / Stock")
self.assertEqual(_("Utilisé"), "Utilisé")
self.assertEqual(_("Non utilisé"), "Non utilisé")
self.assertEqual(_("Tout cocher"), "Tout cocher")
self.assertEqual(_("Tout décocher"), "Tout décocher")
self.assertEqual(_("utilisé(s)"), "utilisé(s)")
self.assertEqual(_("Hors catalogue"), "Hors catalogue")
# Néerlandais
activate('nl')
self.assertEqual(_("Matériel / Stock"), "Materiaal / Voorraad")
self.assertEqual(_("Utilisé"), "Gebruikt")
self.assertEqual(_("Non utilisé"), "Niet gebruikt")
self.assertIn(_("Tout cocher"), ["Alles selecteren", "Alles aanvinken"])
self.assertIn(_("Tout décocher"), ["Alles deselecteren", "Alles uitvinken"])
self.assertEqual(_("utilisé(s)"), "gebruikt")
self.assertEqual(_("Hors catalogue"), "Buiten catalogus")
# Anglais
activate('en')
self.assertEqual(_("Matériel / Stock"), "Material / Stock")
self.assertEqual(_("Utilisé"), "Used")
self.assertEqual(_("Non utilisé"), "Not used")
self.assertEqual(_("Tout cocher"), "Check all")
self.assertEqual(_("Tout décocher"), "Uncheck all")
self.assertEqual(_("utilisé(s)"), "used")
self.assertEqual(_("Hors catalogue"), "Out of catalog")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -785,9 +785,7 @@ if (bpLoadBtn) {
bpInfo.style.display = ''; bpInfo.style.display = '';
return; return;
} }
bpInfo.innerHTML = `{% translate "Chargement de" %} <strong>${data.items.length}</strong> {% translate "produit(s) depuis le bon" %} <strong>${escHtml(data.order_number)}</strong>`;
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
bpInfo.style.display = '';
// Pré-remplir l'emplacement de destination si unique // Pré-remplir l'emplacement de destination si unique
if (data.default_location_id) { if (data.default_location_id) {
const globalDestSelect = document.getElementById('global_dest_location_id'); const globalDestSelect = document.getElementById('global_dest_location_id');
@ -796,11 +794,17 @@ if (bpLoadBtn) {
globalDestSelect.dispatchEvent(new Event('change')); globalDestSelect.dispatchEvent(new Event('change'));
} }
} }
// Séparer les articles à réintégrer (non utilisés, qty > 0) et les articles marqués comme utilisés
const itemsToReturn = data.items.filter(item => item.quantity > 0);
const usedItems = data.items.filter(item => data.has_intervention && item.is_used);
// Effacer les lignes existantes // Effacer les lignes existantes
document.getElementById('lines-container').innerHTML = ''; document.getElementById('lines-container').innerHTML = '';
updateNoLinesPlaceholder(); updateNoLinesPlaceholder();
// Ajouter une ligne par produit
data.items.forEach(item => { // Ajouter une ligne par produit à réintégrer
itemsToReturn.forEach(item => {
addLine(); addLine();
const lineEls = document.querySelectorAll('#lines-container .product-line'); const lineEls = document.querySelectorAll('#lines-container .product-line');
const lineEl = lineEls[lineEls.length - 1]; const lineEl = lineEls[lineEls.length - 1];
@ -811,6 +815,59 @@ if (bpLoadBtn) {
if (hiddenInput) hiddenInput.value = item.product_id; if (hiddenInput) hiddenInput.value = item.product_id;
if (qtyInput) qtyInput.value = item.quantity; if (qtyInput) qtyInput.value = item.quantity;
}); });
// Construction du message d'information bpInfo
let infoHtml = '';
if (data.has_intervention && usedItems.length > 0 && itemsToReturn.length > 0) {
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
infoHtml = `<div>{% translate "Préremplissage de" %} <strong>${itemsToReturn.length}</strong> {% translate "produit(s) non utilisé(s) à réintégrer en stock depuis le bon" %} <strong>${escHtml(data.order_number)}</strong> (<strong>${usedItems.length}</strong> {% translate "marqué(s) comme utilisé(s) sur l'intervention" %}).</div>`;
} else if (data.has_intervention && itemsToReturn.length === 0 && usedItems.length > 0) {
bpInfo.className = 'alert alert-warning mt-2 mb-0 py-2 small';
infoHtml = `<div><i class="bi bi-info-circle me-1"></i>{% translate "Tous les articles de ce bon ont été marqués comme utilisés lors de l'intervention" %} (<strong>${escHtml(data.order_number)}</strong>). {% translate "Aucun article à réintégrer automatiquement." %}</div>`;
} else {
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
infoHtml = `<div>{% translate "Chargement de" %} <strong>${itemsToReturn.length}</strong> {% translate "produit(s) depuis le bon" %} <strong>${escHtml(data.order_number)}</strong>.</div>`;
}
if (usedItems.length > 0) {
infoHtml += `<div class="mt-2 pt-2 border-top border-secondary-subtle">
<span class="text-muted d-block mb-1">{% translate "Matériel marqué comme utilisé sur l'intervention (cliquer pour ajouter au retour si surplus) :" %}</span>
<div class="d-flex flex-wrap gap-1">`;
usedItems.forEach(item => {
infoHtml += `<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 btn-add-used-item"
data-id="${item.product_id}"
data-name="${escHtml(item.product_name)}"
data-code="${escHtml(item.product_code || '')}"
data-qty="${item.initial_quantity || 1}">
<i class="bi bi-plus-circle me-1"></i>${escHtml(item.product_name)} (${item.initial_quantity})
</button>`;
});
infoHtml += `</div></div>`;
}
bpInfo.innerHTML = infoHtml;
bpInfo.style.display = '';
// Gestionnaire d'ajout rapide pour le matériel utilisé
bpInfo.querySelectorAll('.btn-add-used-item').forEach(btn => {
btn.addEventListener('click', function() {
const prodId = this.dataset.id;
const prodName = this.dataset.name;
const prodCode = this.dataset.code;
const prodQty = this.dataset.qty || 1;
addLine();
const lineEls = document.querySelectorAll('#lines-container .product-line');
const lineEl = lineEls[lineEls.length - 1];
const searchInput = lineEl.querySelector('.product-search-input');
const hiddenInput = lineEl.querySelector('.product-id-input');
const qtyInput = lineEl.querySelector('input[name="quantity"]');
if (searchInput) searchInput.value = prodName + (prodCode ? ' (' + prodCode + ')' : '');
if (hiddenInput) hiddenInput.value = prodId;
if (qtyInput) qtyInput.value = prodQty;
this.disabled = true;
this.classList.add('opacity-50');
});
});
}) })
.catch(() => { .catch(() => {
bpInfo.textContent = '{% translate "Erreur lors du chargement du bon de préparation." %}'; bpInfo.textContent = '{% translate "Erreur lors du chargement du bon de préparation." %}';

View file

@ -979,9 +979,7 @@ if (bpLoadBtn) {
bpInfo.style.display = ''; bpInfo.style.display = '';
return; return;
} }
bpInfo.innerHTML = `{% translate "Chargement de" %} <strong>${data.items.length}</strong> {% translate "produit(s) depuis le bon" %} <strong>${escHtml(data.order_number)}</strong>`;
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
bpInfo.style.display = '';
// Pré-remplir l'emplacement de destination si unique // Pré-remplir l'emplacement de destination si unique
if (data.default_location_id) { if (data.default_location_id) {
const globalDestSelect = document.getElementById('global_dest_location_id'); const globalDestSelect = document.getElementById('global_dest_location_id');
@ -990,11 +988,17 @@ if (bpLoadBtn) {
globalDestSelect.dispatchEvent(new Event('change')); globalDestSelect.dispatchEvent(new Event('change'));
} }
} }
// Séparer les articles à réintégrer (non utilisés, qty > 0) et les articles marqués comme utilisés
const itemsToReturn = data.items.filter(item => item.quantity > 0);
const usedItems = data.items.filter(item => data.has_intervention && item.is_used);
// Effacer les lignes existantes // Effacer les lignes existantes
document.getElementById('lines-container').innerHTML = ''; document.getElementById('lines-container').innerHTML = '';
updateNoLinesPlaceholder(); updateNoLinesPlaceholder();
// Ajouter une ligne par produit
data.items.forEach(item => { // Ajouter une ligne par produit à réintégrer
itemsToReturn.forEach(item => {
addLine(); addLine();
const lineEls = document.querySelectorAll('#lines-container .product-line'); const lineEls = document.querySelectorAll('#lines-container .product-line');
const lineEl = lineEls[lineEls.length - 1]; const lineEl = lineEls[lineEls.length - 1];
@ -1007,6 +1011,62 @@ if (bpLoadBtn) {
// Charger les emplacements pour ce produit // Charger les emplacements pour ce produit
fetchLocationsForProduct(lineEl, item.product_id); fetchLocationsForProduct(lineEl, item.product_id);
}); });
// Construction du message d'information bpInfo
let infoHtml = '';
if (data.has_intervention && usedItems.length > 0 && itemsToReturn.length > 0) {
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
infoHtml = `<div>{% translate "Préremplissage de" %} <strong>${itemsToReturn.length}</strong> {% translate "produit(s) non utilisé(s) à réintégrer en stock depuis le bon" %} <strong>${escHtml(data.order_number)}</strong> (<strong>${usedItems.length}</strong> {% translate "marqué(s) comme utilisé(s) sur l'intervention" %}).</div>`;
} else if (data.has_intervention && itemsToReturn.length === 0 && usedItems.length > 0) {
bpInfo.className = 'alert alert-warning mt-2 mb-0 py-2 small';
infoHtml = `<div><i class="bi bi-info-circle me-1"></i>{% translate "Tous les articles de ce bon ont été marqués comme utilisés lors de l'intervention" %} (<strong>${escHtml(data.order_number)}</strong>). {% translate "Aucun article à réintégrer automatiquement." %}</div>`;
} else {
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
infoHtml = `<div>{% translate "Chargement de" %} <strong>${itemsToReturn.length}</strong> {% translate "produit(s) depuis le bon" %} <strong>${escHtml(data.order_number)}</strong>.</div>`;
}
if (usedItems.length > 0) {
infoHtml += `<div class="mt-2 pt-2 border-top border-secondary-subtle">
<span class="text-muted d-block mb-1">{% translate "Matériel marqué comme utilisé sur l'intervention (cliquer pour ajouter au retour si surplus) :" %}</span>
<div class="d-flex flex-wrap gap-1">`;
usedItems.forEach(item => {
infoHtml += `<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 btn-add-used-item"
data-id="${item.product_id}"
data-name="${escHtml(item.product_name)}"
data-code="${escHtml(item.product_code || '')}"
data-qty="${item.initial_quantity || 1}">
<i class="bi bi-plus-circle me-1"></i>${escHtml(item.product_name)} (${item.initial_quantity})
</button>`;
});
infoHtml += `</div></div>`;
}
bpInfo.innerHTML = infoHtml;
bpInfo.style.display = '';
// Gestionnaire d'ajout rapide pour le matériel utilisé
bpInfo.querySelectorAll('.btn-add-used-item').forEach(btn => {
btn.addEventListener('click', function() {
const prodId = this.dataset.id;
const prodName = this.dataset.name;
const prodCode = this.dataset.code;
const prodQty = this.dataset.qty || 1;
addLine();
const lineEls = document.querySelectorAll('#lines-container .product-line');
const lineEl = lineEls[lineEls.length - 1];
const searchInput = lineEl.querySelector('.product-search-input');
const hiddenInput = lineEl.querySelector('.product-id-input');
const qtyInput = lineEl.querySelector('input[name="quantity"]');
if (searchInput) searchInput.value = prodName + (prodCode ? ' (' + prodCode + ')' : '');
if (hiddenInput) hiddenInput.value = prodId;
if (qtyInput) qtyInput.value = prodQty;
if (typeof fetchLocationsForProduct === 'function') {
fetchLocationsForProduct(lineEl, prodId);
}
this.disabled = true;
this.classList.add('opacity-50');
});
});
}) })
.catch(() => { .catch(() => {
bpInfo.textContent = '{% translate "Erreur lors du chargement du bon de préparation." %}'; bpInfo.textContent = '{% translate "Erreur lors du chargement du bon de préparation." %}';

View file

@ -1769,6 +1769,98 @@ class PreparationOrderAutocompleteTests(TestCase):
data = response.json() data = response.json()
self.assertEqual(data["default_location_id"], loc.id) self.assertEqual(data["default_location_id"], loc.id)
def test_return_api_with_used_and_unused_items(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem
prod1 = Product.objects.create(name="Câble RJ45", code="CAB-RJ45", sku="SKU-CAB-RJ45", total_stock=20)
prod2 = Product.objects.create(name="Routeur WiFi", code="ROUT-WIFI", sku="SKU-ROUT-WIFI", total_stock=10)
po = PreparationOrder.objects.create(
order_number="BP-RET-USED-01",
intervention=self.intervention,
status="processed",
)
# item1 utilisé lors de l'intervention -> return_qty doit être 0
PreparationOrderItem.objects.create(
preparation_order=po,
product=prod1,
quantity=5,
is_used=True,
)
# item2 NON utilisé lors de l'intervention -> return_qty doit être 2
PreparationOrderItem.objects.create(
preparation_order=po,
product=prod2,
quantity=2,
is_used=False,
)
url = reverse("stock:preparation_order_items_api", args=[po.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["has_intervention"])
self.assertEqual(len(data["items"]), 2)
item1_data = next(it for it in data["items"] if it["product_id"] == prod1.pk)
self.assertEqual(item1_data["quantity"], 0)
self.assertEqual(item1_data["initial_quantity"], 5)
self.assertTrue(item1_data["is_used"])
item2_data = next(it for it in data["items"] if it["product_id"] == prod2.pk)
self.assertEqual(item2_data["quantity"], 2)
self.assertEqual(item2_data["initial_quantity"], 2)
self.assertFalse(item2_data["is_used"])
def test_return_api_with_all_items_used(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem
prod = Product.objects.create(name="Antenne 5G", code="ANT-5G", sku="SKU-ANT-5G", total_stock=5)
po = PreparationOrder.objects.create(
order_number="BP-RET-ALL-USED",
intervention=self.intervention,
status="processed",
)
PreparationOrderItem.objects.create(
preparation_order=po,
product=prod,
quantity=3,
is_used=True,
)
url = reverse("stock:preparation_order_items_api", args=[po.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["has_intervention"])
self.assertEqual(len(data["items"]), 1)
self.assertEqual(data["items"][0]["quantity"], 0)
self.assertTrue(data["items"][0]["is_used"])
def test_return_api_without_intervention(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem
prod = Product.objects.create(name="Outil Simple", code="TOOL-1", sku="SKU-TOOL-1", total_stock=5)
po = PreparationOrder.objects.create(
order_number="BP-RET-NO-INT",
intervention=None,
status="processed",
)
PreparationOrderItem.objects.create(
preparation_order=po,
product=prod,
quantity=4,
is_used=True, # même si is_used est True, sans intervention le bon préremplit la quantité normale
)
url = reverse("stock:preparation_order_items_api", args=[po.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertFalse(data["has_intervention"])
self.assertEqual(len(data["items"]), 1)
self.assertEqual(data["items"][0]["quantity"], 4)
class PreparationOrderDiscussionTests(TestCase): class PreparationOrderDiscussionTests(TestCase):
def setUp(self): def setUp(self):

View file

@ -418,21 +418,46 @@ def preparation_order_items_api(request, pk):
""" """
API endpoint retournant les articles d'un bon de préparation. API endpoint retournant les articles d'un bon de préparation.
Utilisé pour pré-remplir les lignes produit lors d'un retour de stock. Utilisé pour pré-remplir les lignes produit lors d'un retour de stock.
Si le bon est lié à une intervention, les quantités à retourner sont calculées
sur base du matériel utilisé : le matériel marqué comme utilisé (is_used=True)
n'est pas réintégré (quantité 0), tandis que le matériel non utilisé (is_used=False)
est proposé au retour avec sa quantité restante.
""" """
po = get_object_or_404(PreparationOrder, pk=pk) po = get_object_or_404(PreparationOrder, pk=pk)
has_intervention = bool(po.intervention_id)
executed_summary = po.get_executed_output_summary()
has_executed = any(s['out'] > 0 for s in executed_summary.values())
items = [] items = []
for item in po.preparationorderitem_set.select_related('product').all(): for item in po.preparationorderitem_set.select_related('product').all():
p_id = item.product_id
if has_executed:
prod_summary = executed_summary.get(p_id, {'out': 0, 'in': 0, 'net': 0})
base_qty = max(0, prod_summary['net'])
else:
base_qty = item.quantity
is_used = bool(item.is_used)
if has_intervention and is_used:
return_qty = 0
else:
return_qty = base_qty
items.append({ items.append({
'product_id': item.product.pk, 'product_id': item.product.pk,
'product_name': item.product.name, 'product_name': item.product.name,
'product_code': item.product.code or '', 'product_code': item.product.code or '',
'quantity': item.quantity, 'quantity': return_qty,
'initial_quantity': base_qty,
'is_used': is_used,
'unit': item.product.unit or '', 'unit': item.product.unit or '',
}) })
default_loc_id = po.get_default_return_location_id() default_loc_id = po.get_default_return_location_id()
return JsonResponse({ return JsonResponse({
'items': items, 'items': items,
'order_number': po.order_number, 'order_number': po.order_number,
'has_intervention': has_intervention,
'default_location_id': default_loc_id, 'default_location_id': default_loc_id,
}) })