Ajoute la création rapide des lots par catégorie

This commit is contained in:
root 2026-08-14 23:37:17 +00:00
parent 0b3fff43e5
commit 25934d018d
6 changed files with 194 additions and 9 deletions

View file

@ -154,11 +154,18 @@ def create():
if request.method == 'POST': if request.method == 'POST':
parent_id = request.form.get('parent_select') or request.form.get('parent_id') or None parent_id = request.form.get('parent_select') or request.form.get('parent_id') or None
category_id = request.form.get('category_id', type=int)
lot_id = request.form.get('lot_id', type=int)
if lot_id:
lot = db.session.get(Lot, lot_id)
if not category_id or not lot or lot.category_id != category_id:
flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger')
return redirect(url_for('equipments.create', advanced=1)), 400
equipment = Equipment( equipment = Equipment(
name=request.form.get('name'), name=request.form.get('name'),
category_id=request.form.get('category_id') or None, category_id=category_id,
room_id=request.form.get('room_id') or None, room_id=request.form.get('room_id') or None,
lot_id=request.form.get('lot_id') or None, lot_id=lot_id,
code=_empty_to_none(request.form.get('code', '').strip()), code=_empty_to_none(request.form.get('code', '').strip()),
description=_empty_to_none(request.form.get('description', '').strip()), description=_empty_to_none(request.form.get('description', '').strip()),
status=request.form.get('status', 'en_service'), status=request.form.get('status', 'en_service'),
@ -225,11 +232,18 @@ def edit(id):
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
if request.method == 'POST': if request.method == 'POST':
category_id = request.form.get('category_id', type=int)
lot_id = request.form.get('lot_id', type=int)
if lot_id:
lot = db.session.get(Lot, lot_id)
if not category_id or not lot or lot.category_id != category_id:
flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger')
return redirect(url_for('equipments.edit', id=id)), 400
equipment.name = request.form.get('name') equipment.name = request.form.get('name')
equipment.code = _empty_to_none(request.form.get('code')) equipment.code = _empty_to_none(request.form.get('code'))
equipment.description = _empty_to_none(request.form.get('description')) equipment.description = _empty_to_none(request.form.get('description'))
equipment.category_id = _empty_to_none(request.form.get('category_id')) equipment.category_id = category_id
equipment.lot_id = _empty_to_none(request.form.get('lot_id')) equipment.lot_id = lot_id
equipment.room_id = _empty_to_none(request.form.get('room_id')) equipment.room_id = _empty_to_none(request.form.get('room_id'))
equipment.status = request.form.get('status', 'en_service') equipment.status = request.form.get('status', 'en_service')
equipment.quantity = int(request.form.get('quantity', 1)) or 1 equipment.quantity = int(request.form.get('quantity', 1)) or 1

View file

@ -90,11 +90,12 @@
<select class="form-select" id="lot_id" name="lot_id"> <select class="form-select" id="lot_id" name="lot_id">
<option value="">-- Sans lot --</option> <option value="">-- Sans lot --</option>
{% for lot in lots %} {% for lot in lots %}
<option value="{{ lot.id }}" {{ 'selected' if equipment.lot_id == lot.id else '' }}> <option value="{{ lot.id }}" data-category-id="{{ lot.category_id or '' }}" {{ 'selected' if equipment.lot_id == lot.id else '' }}>
{{ lot.name }} {{ lot.name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" id="quick-add-lot"><i class="bi bi-plus-lg"></i> Créer un lot dans cette catégorie</button>
</div> </div>
</div> </div>
@ -641,6 +642,50 @@
</div> </div>
<script> <script>
const equipmentCategory = document.getElementById('category_id');
const equipmentLot = document.getElementById('lot_id');
const quickAddEquipmentLot = document.getElementById('quick-add-lot');
function filterEquipmentLots() {
const categoryId = equipmentCategory.value;
let selectedIsValid = !equipmentLot.value;
[...equipmentLot.options].forEach((option, index) => {
if (!index) return;
const visible = !!categoryId && option.dataset.categoryId === categoryId;
option.hidden = !visible;
option.disabled = !visible;
if (visible && option.selected) selectedIsValid = true;
});
if (!selectedIsValid) equipmentLot.value = '';
quickAddEquipmentLot.disabled = !categoryId;
}
equipmentCategory.addEventListener('change', filterEquipmentLots);
quickAddEquipmentLot.addEventListener('click', async () => {
const categoryName = equipmentCategory.selectedOptions[0]?.textContent.trim();
const name = window.prompt(`Nom du nouveau lot pour « ${categoryName} » :`);
if (!name || !name.trim()) return;
const response = await fetch('{{ url_for("wizard.quick_create_lot") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
body: JSON.stringify({name: name.trim(), category_id: equipmentCategory.value})
});
const data = await response.json();
if (!response.ok && !data.lot) {
window.alert(data.error || 'Création du lot impossible.');
return;
}
const created = data.lot;
let option = [...equipmentLot.options].find(item => item.value === String(created.id));
if (!option) {
option = new Option(created.name, created.id);
option.dataset.categoryId = String(created.category_id);
equipmentLot.add(option);
}
filterEquipmentLots();
equipmentLot.value = String(created.id);
if (!response.ok) window.alert(data.error);
});
filterEquipmentLots();
// Gestion des formulaires AJAX // Gestion des formulaires AJAX
document.getElementById('meterForm').addEventListener('submit', function(e) { document.getElementById('meterForm').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();

View file

@ -116,11 +116,12 @@
<select class="form-select" id="lot_id" name="lot_id"> <select class="form-select" id="lot_id" name="lot_id">
<option value="">-- Aucun lot --</option> <option value="">-- Aucun lot --</option>
{% for lot in lots %} {% for lot in lots %}
<option value="{{ lot.id }}" {{ 'selected' if equipment and equipment.lot_id == lot.id else '' }}> <option value="{{ lot.id }}" data-category-id="{{ lot.category_id or '' }}" {{ 'selected' if equipment and equipment.lot_id == lot.id else '' }}>
{{ lot.name }} {{ lot.name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" id="quick-add-lot" disabled><i class="bi bi-plus-lg"></i> Créer un lot dans cette catégorie</button>
<small class="text-muted">Associe les tâches préventives du lot à l'équipement</small> <small class="text-muted">Associe les tâches préventives du lot à l'équipement</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@ -256,6 +257,51 @@ document.addEventListener('DOMContentLoaded', function() {
const codeField = document.getElementById('code_field'); const codeField = document.getElementById('code_field');
const roomRequired = document.getElementById('room_required'); const roomRequired = document.getElementById('room_required');
const roomHint = document.getElementById('room_hint'); const roomHint = document.getElementById('room_hint');
const categorySelect = document.getElementById('category_id');
const lotSelect = document.getElementById('lot_id');
const quickAddLot = document.getElementById('quick-add-lot');
function filterLots() {
const categoryId = categorySelect.value;
let selectedIsValid = !lotSelect.value;
[...lotSelect.options].forEach((option, index) => {
if (!index) return;
const visible = !!categoryId && option.dataset.categoryId === categoryId;
option.hidden = !visible;
option.disabled = !visible;
if (visible && option.selected) selectedIsValid = true;
});
if (!selectedIsValid) lotSelect.value = '';
quickAddLot.disabled = !categoryId;
}
categorySelect.addEventListener('change', filterLots);
quickAddLot.addEventListener('click', async () => {
const categoryName = categorySelect.selectedOptions[0]?.textContent.trim();
const name = window.prompt(`Nom du nouveau lot pour « ${categoryName} » :`);
if (!name || !name.trim()) return;
const response = await fetch('{{ url_for("wizard.quick_create_lot") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
body: JSON.stringify({name: name.trim(), category_id: categorySelect.value})
});
const data = await response.json();
if (!response.ok && !data.lot) {
window.alert(data.error || 'Création du lot impossible.');
return;
}
const created = data.lot;
let option = [...lotSelect.options].find(item => item.value === String(created.id));
if (!option) {
option = new Option(created.name, created.id);
option.dataset.categoryId = String(created.category_id);
lotSelect.add(option);
}
filterLots();
lotSelect.value = String(created.id);
if (!response.ok) window.alert(data.error);
});
filterLots();
// Vérifier si on a un parent_id dans l'URL (via template) // Vérifier si on a un parent_id dans l'URL (via template)
const preselectedParentId = "{{ preselected_parent_id or '' }}"; const preselectedParentId = "{{ preselected_parent_id or '' }}";
@ -276,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (this.selectedOptions[0]) { if (this.selectedOptions[0]) {
const categoryId = this.selectedOptions[0].dataset.category; const categoryId = this.selectedOptions[0].dataset.category;
document.getElementById('category_id').value = categoryId; document.getElementById('category_id').value = categoryId;
filterLots();
} }
}); });
} }

View file

@ -11,6 +11,38 @@ from app_new.core.models.college import Room, Building, Zone, RoomType
wizard_bp = Blueprint('wizard', __name__, url_prefix='/wizard', template_folder='templates') wizard_bp = Blueprint('wizard', __name__, url_prefix='/wizard', template_folder='templates')
@wizard_bp.route('/api/lots', methods=['POST'])
@login_required
def quick_create_lot():
"""Crée un lot depuis un formulaire d'équipement et le lie à sa catégorie."""
data = request.get_json(silent=True) or {}
name = (data.get('name') or '').strip()
category_id = data.get('category_id')
try:
category_id = int(category_id)
except (TypeError, ValueError):
category_id = 0
category = db.session.get(EquipmentCategory, category_id)
if not name or len(name) > 500 or not category:
return jsonify({'error': 'Sélectionnez une catégorie et saisissez le nom du lot.'}), 400
existing = Lot.query.filter(
Lot.category_id == category.id,
db.func.lower(Lot.name) == name.lower(),
).first()
if existing:
return jsonify({
'error': 'Ce lot existe déjà dans cette catégorie.',
'lot': {'id': existing.id, 'name': existing.name, 'category_id': category.id},
}), 409
lot = Lot(name=name, category_id=category.id, is_present=True)
db.session.add(lot)
db.session.commit()
return jsonify({
'success': True,
'lot': {'id': lot.id, 'name': lot.name, 'category_id': category.id},
}), 201
@wizard_bp.route('/equipments') @wizard_bp.route('/equipments')
@login_required @login_required
def equipment_wizard(): def equipment_wizard():
@ -66,7 +98,7 @@ def bulk_create_equipments():
mobility = raw.get('mobility', 'non_precise') mobility = raw.get('mobility', 'non_precise')
if not name or not category or not lot or quantity < 1 or quantity > 1000: if not name or not category or not lot or quantity < 1 or quantity > 1000:
raise ValueError('Une ligne contient des données invalides.') raise ValueError('Une ligne contient des données invalides.')
if lot.category_id and lot.category_id != category.id: if lot.category_id != category.id:
raise ValueError(f'Le lot « {lot.name} » ne correspond pas à la catégorie « {category.name} ».') raise ValueError(f'Le lot « {lot.name} » ne correspond pas à la catégorie « {category.name} ».')
root = Equipment( root = Equipment(

View file

@ -20,7 +20,7 @@
<div class="card mb-3"><div class="card-header fw-semibold">3. Vérification</div><div class="card-body" id="summary">Sélectionnez une pièce et ajoutez un équipement.</div></div> <div class="card mb-3"><div class="card-header fw-semibold">3. Vérification</div><div class="card-body" id="summary">Sélectionnez une pièce et ajoutez un équipement.</div></div>
<div class="d-flex justify-content-between"><a href="{{ url_for('equipments.index') }}" class="btn btn-outline-secondary">Annuler</a><button id="submit" class="btn btn-success btn-lg" disabled><i class="bi bi-check-lg"></i> Créer linventaire</button></div> <div class="d-flex justify-content-between"><a href="{{ url_for('equipments.index') }}" class="btn btn-outline-secondary">Annuler</a><button id="submit" class="btn btn-success btn-lg" disabled><i class="bi bi-check-lg"></i> Créer linventaire</button></div>
<template id="row-template"><tr class="item"><td><input class="form-control name" placeholder="Ex. Luminaire" required></td><td><select class="form-select category"><option value="">Catégorie…</option>{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select><select class="form-select lot mt-1" disabled><option value="">Lot…</option></select></td><td><input type="number" min="1" max="1000" value="1" class="form-control quantity" style="min-width:6rem"></td><td><select class="form-select mode"><option value="quantitatif">Quantitatif</option><option value="individuel">Individualisé</option></select></td><td><select class="form-select mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></td><td><button type="button" class="btn btn-outline-danger remove" title="Supprimer"><i class="bi bi-trash"></i></button></td></tr></template> <template id="row-template"><tr class="item"><td><input class="form-control name" placeholder="Ex. Luminaire" required></td><td><select class="form-select category"><option value="">Catégorie…</option>{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select><select class="form-select lot mt-1" disabled><option value="">Lot…</option></select><button type="button" class="btn btn-link btn-sm p-0 mt-1 quick-lot" disabled><i class="bi bi-plus-lg"></i> Nouveau lot dans cette catégorie</button></td><td><input type="number" min="1" max="1000" value="1" class="form-control quantity" style="min-width:6rem"></td><td><select class="form-select mode"><option value="quantitatif">Quantitatif</option><option value="individuel">Individualisé</option></select></td><td><select class="form-select mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></td><td><button type="button" class="btn btn-outline-danger remove" title="Supprimer"><i class="bi bi-trash"></i></button></td></tr></template>
{% endblock %} {% endblock %}
{% block extra_scripts %}<script> {% block extra_scripts %}<script>
const lots={{ lots_by_category|tojson }}, selected=new Set({{ selected_room_ids|tojson }}), items=document.getElementById('items'); const lots={{ lots_by_category|tojson }}, selected=new Set({{ selected_room_ids|tojson }}), items=document.getElementById('items');
@ -30,7 +30,7 @@ async function zones(){const s=document.getElementById('zone'),b=document.getEle
document.getElementById('rooms').onclick=e=>{const x=e.target.closest('.room');if(!x)return;const id=+x.dataset.id;selected.has(id)?selected.delete(id):selected.add(id);refreshRooms()}; document.getElementById('rooms').onclick=e=>{const x=e.target.closest('.room');if(!x)return;const id=+x.dataset.id;selected.has(id)?selected.delete(id):selected.add(id);refreshRooms()};
document.getElementById('building').onchange=zones;document.getElementById('zone').onchange=refreshRooms;document.getElementById('room-search').oninput=refreshRooms; document.getElementById('building').onchange=zones;document.getElementById('zone').onchange=refreshRooms;document.getElementById('room-search').oninput=refreshRooms;
document.getElementById('select-visible').onclick=()=>{document.querySelectorAll('.room:not([hidden])').forEach(x=>selected.add(+x.dataset.id));refreshRooms()};document.getElementById('clear-rooms').onclick=()=>{selected.clear();refreshRooms()}; document.getElementById('select-visible').onclick=()=>{document.querySelectorAll('.room:not([hidden])').forEach(x=>selected.add(+x.dataset.id));refreshRooms()};document.getElementById('clear-rooms').onclick=()=>{selected.clear();refreshRooms()};
function addRow(data={}){const row=document.getElementById('row-template').content.firstElementChild.cloneNode(true);items.append(row);row.querySelector('.name').value=data.name||'';const cat=row.querySelector('.category'),lot=row.querySelector('.lot');if(data.category){const option=[...cat.options].find(o=>norm(o.text).includes(norm(data.category)));if(option)cat.value=option.value}function fill(){lot.innerHTML='<option value="">Lot…</option>';for(const x of (lots[cat.value]||[]))lot.add(new Option(x.name,x.id));lot.disabled=!cat.value;if(data.lot&&lot.options.length>1)lot.selectedIndex=1;refreshSummary()}cat.onchange=fill;fill();row.querySelector('.quantity').value=data.quantity||1;row.querySelector('.mode').value=data.mode||'quantitatif';row.querySelector('.mobility').value=data.mobility||'non_precise';row.querySelectorAll('input,select').forEach(x=>x.addEventListener('input',refreshSummary));row.querySelector('.remove').onclick=()=>{row.remove();refreshSummary()};refreshSummary()} function addRow(data={}){const row=document.getElementById('row-template').content.firstElementChild.cloneNode(true);items.append(row);row.querySelector('.name').value=data.name||'';const cat=row.querySelector('.category'),lot=row.querySelector('.lot'),quick=row.querySelector('.quick-lot');if(data.category){const option=[...cat.options].find(o=>norm(o.text).includes(norm(data.category)));if(option)cat.value=option.value}function fill(selected=''){lot.innerHTML='<option value="">Lot…</option>';for(const x of (lots[cat.value]||[]))lot.add(new Option(x.name,x.id));lot.disabled=!cat.value;quick.disabled=!cat.value;if(selected)lot.value=String(selected);else if(data.lot&&lot.options.length>1)lot.selectedIndex=1;refreshSummary()}cat.onchange=()=>fill();quick.onclick=async()=>{const categoryName=cat.selectedOptions[0]?.textContent.trim(),name=window.prompt(`Nom du nouveau lot pour « ${categoryName} » :`);if(!name||!name.trim())return;quick.disabled=true;const response=await fetch('{{ url_for("wizard.quick_create_lot") }}',{method:'POST',headers:{'Content-Type':'application/json','X-CSRFToken':'{{ csrf_token() }}'},body:JSON.stringify({name:name.trim(),category_id:cat.value})}),result=await response.json();quick.disabled=false;if(!response.ok&&!result.lot){alert(result.error||'Création du lot impossible');return}const created=result.lot;lots[cat.value]=lots[cat.value]||[];if(!lots[cat.value].some(x=>String(x.id)===String(created.id)))lots[cat.value].push(created);fill(created.id);if(!response.ok)alert(result.error)};fill();row.querySelector('.quantity').value=data.quantity||1;row.querySelector('.mode').value=data.mode||'quantitatif';row.querySelector('.mobility').value=data.mobility||'non_precise';row.querySelectorAll('input,select').forEach(x=>x.addEventListener('input',refreshSummary));row.querySelector('.remove').onclick=()=>{row.remove();refreshSummary()};refreshSummary()}
document.getElementById('add-row').onclick=()=>addRow(); document.getElementById('add-row').onclick=()=>addRow();
const presets={classe:[['Chaises','chaise',30,'quantitatif','mobile'],['Prises','prise',10,'individuel','fixe'],['Luminaires','luminaire',10,'individuel','fixe'],['Portes','porte',2,'individuel','fixe'],['Murs','mur',4,'quantitatif','fixe']],logement:[['Prises','prise',8,'individuel','fixe'],['Luminaires','luminaire',2,'individuel','fixe'],['Portes','porte',1,'individuel','fixe'],['Fenêtres','fenetre',1,'individuel','fixe']]}; const presets={classe:[['Chaises','chaise',30,'quantitatif','mobile'],['Prises','prise',10,'individuel','fixe'],['Luminaires','luminaire',10,'individuel','fixe'],['Portes','porte',2,'individuel','fixe'],['Murs','mur',4,'quantitatif','fixe']],logement:[['Prises','prise',8,'individuel','fixe'],['Luminaires','luminaire',2,'individuel','fixe'],['Portes','porte',1,'individuel','fixe'],['Fenêtres','fenetre',1,'individuel','fixe']]};
document.querySelectorAll('.preset').forEach(b=>b.onclick=()=>presets[b.dataset.preset].forEach(x=>addRow({name:x[0],category:x[1],quantity:x[2],mode:x[3],mobility:x[4],lot:true}))); document.querySelectorAll('.preset').forEach(b=>b.onclick=()=>presets[b.dataset.preset].forEach(x=>addRow({name:x[0],category:x[1],quantity:x[2],mode:x[3],mobility:x[4],lot:true})));

View file

@ -0,0 +1,47 @@
from uuid import uuid4
from app_new.extensions import db
from app_new.core.models.equipment import EquipmentCategory
from app_new.core.models.maintenance import Lot
def test_quick_lot_is_created_in_selected_category(authenticated_client, app):
with app.app_context():
category = EquipmentCategory(name=f'Catégorie rapide {uuid4().hex[:6]}')
db.session.add(category)
db.session.commit()
category_id = category.id
response = authenticated_client.post('/wizard/api/lots', json={
'name': 'Lot créé pendant linventaire',
'category_id': category_id,
})
assert response.status_code == 201
payload = response.get_json()['lot']
assert payload['category_id'] == category_id
with app.app_context():
lot = db.session.get(Lot, payload['id'])
assert lot.category_id == category_id
assert lot.is_present is True
def test_quick_lot_duplicate_returns_existing_lot(authenticated_client, app):
with app.app_context():
category = EquipmentCategory(name=f'Catégorie doublon {uuid4().hex[:6]}')
lot = Lot(name='Lot identique', category=category)
db.session.add_all([category, lot])
db.session.commit()
response = authenticated_client.post('/wizard/api/lots', json={
'name': 'lot IDENTIQUE', 'category_id': category.id,
})
lot_id = lot.id
assert response.status_code == 409
assert response.get_json()['lot']['id'] == lot_id
def test_equipment_forms_offer_category_bound_lot_creation(authenticated_client):
wizard = authenticated_client.get('/wizard/equipments')
advanced = authenticated_client.get('/equipments/create?advanced=1')
assert wizard.status_code == 200
assert advanced.status_code == 200
assert 'Nouveau lot dans cette catégorie'.encode() in wizard.data
assert 'Créer un lot dans cette catégorie'.encode() in advanced.data