feat(c4): complete stock workflows and daily alerts
This commit is contained in:
parent
f0901f5061
commit
136b0e6c32
4 changed files with 33 additions and 12 deletions
|
|
@ -12,8 +12,9 @@ from ..core.models import (
|
||||||
)
|
)
|
||||||
from ..core.services.c4_service import (
|
from ..core.services.c4_service import (
|
||||||
C4DomainError, assign_equipment_to_contract, calculate_contract_usage,
|
C4DomainError, assign_equipment_to_contract, calculate_contract_usage,
|
||||||
create_consumable_order, create_contract, evaluate_quota_alert, project_contract_usage,
|
cancel_consumable_order, close_photocopier_contract, create_consumable_order, create_contract,
|
||||||
estimate_consumable_lifetime, receive_consumable_order, record_consumable_issue,
|
evaluate_quota_alert, estimate_consumable_lifetime, estimate_consumable_stock_coverage,
|
||||||
|
project_contract_usage, receive_consumable_order, record_consumable_issue,
|
||||||
)
|
)
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
|
|
||||||
|
|
@ -99,11 +100,12 @@ def contract_equipment(contract_id):
|
||||||
@permission_required("contract.manage")
|
@permission_required("contract.manage")
|
||||||
def close_contract(contract_id):
|
def close_contract(contract_id):
|
||||||
contract = PhotocopierContract.query.get_or_404(contract_id)
|
contract = PhotocopierContract.query.get_or_404(contract_id)
|
||||||
contract.status = "CLOSED"
|
try:
|
||||||
contract.closed_at = _parse_date(request.form.get("closed_at")) or date.today()
|
close_photocopier_contract(contract=contract, closed_at=_parse_date(request.form.get("closed_at")))
|
||||||
contract.end_date = min(contract.end_date, contract.closed_at)
|
flash("Contrat clos ; son historique est conservé.", "success")
|
||||||
db.session.commit()
|
except C4DomainError as exc:
|
||||||
flash("Contrat clos ; son historique est conservé.", "success")
|
db.session.rollback()
|
||||||
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("c4.contract_detail", contract_id=contract_id))
|
return redirect(url_for("c4.contract_detail", contract_id=contract_id))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,8 +124,12 @@ def consumable_detail(consumable_id):
|
||||||
consumable = Consumable.query.get_or_404(consumable_id)
|
consumable = Consumable.query.get_or_404(consumable_id)
|
||||||
usages = consumable.usages.order_by(ConsumableUsage.used_at.desc()).limit(100).all()
|
usages = consumable.usages.order_by(ConsumableUsage.used_at.desc()).limit(100).all()
|
||||||
orders = ConsumableOrder.query.filter_by(consumable_id=consumable.id).order_by(ConsumableOrder.id.desc()).all()
|
orders = ConsumableOrder.query.filter_by(consumable_id=consumable.id).order_by(ConsumableOrder.id.desc()).all()
|
||||||
compatible = EquipmentConsumable.query.filter_by(consumable_id=consumable.id).all()
|
compatible = (EquipmentConsumable.query.filter_by(consumable_id=consumable.id)
|
||||||
return render_template("c4/consumable_detail.html", consumable=consumable, usages=usages, orders=orders, compatible=compatible)
|
.options(joinedload(EquipmentConsumable.equipment)).all())
|
||||||
|
coverage = estimate_consumable_stock_coverage(consumable=consumable)
|
||||||
|
quantity_on_order = sum(order.remaining_quantity for order in orders if order.status in {"ORDERED", "PARTIALLY_RECEIVED"})
|
||||||
|
return render_template("c4/consumable_detail.html", consumable=consumable, usages=usages, orders=orders,
|
||||||
|
compatible=compatible, coverage=coverage, quantity_on_order=quantity_on_order)
|
||||||
|
|
||||||
|
|
||||||
@c4_bp.post("/consumables/<int:consumable_id>/orders")
|
@c4_bp.post("/consumables/<int:consumable_id>/orders")
|
||||||
|
|
@ -154,6 +160,20 @@ def receive_order(order_id):
|
||||||
return redirect(url_for("c4.consumable_detail", consumable_id=order.consumable_id))
|
return redirect(url_for("c4.consumable_detail", consumable_id=order.consumable_id))
|
||||||
|
|
||||||
|
|
||||||
|
@c4_bp.post("/orders/<int:order_id>/cancel")
|
||||||
|
@login_required
|
||||||
|
@permission_required("stock.manage")
|
||||||
|
def cancel_order(order_id):
|
||||||
|
order = ConsumableOrder.query.get_or_404(order_id)
|
||||||
|
try:
|
||||||
|
cancel_consumable_order(order=order, user_id=current_user.id, reason=request.form.get("reason"))
|
||||||
|
flash("Commande annulée ; le stock déjà reçu est conservé.", "success")
|
||||||
|
except (ValueError, C4DomainError) as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(str(exc), "danger")
|
||||||
|
return redirect(url_for("c4.consumable_detail", consumable_id=order.consumable_id))
|
||||||
|
|
||||||
|
|
||||||
@c4_bp.post("/consumables/<int:consumable_id>/issue")
|
@c4_bp.post("/consumables/<int:consumable_id>/issue")
|
||||||
@login_required
|
@login_required
|
||||||
@permission_required("stock.issue")
|
@permission_required("stock.issue")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ consumable.name }} — GMAO{% endblock %}
|
{% block title %}{{ consumable.name }} — GMAO{% endblock %}
|
||||||
{% block content %}<div class="container-fluid"><h1 class="h4">{{ consumable.name }}</h1><p>Référence : {{ consumable.reference or '—' }} · Type : {{ consumable.consumable_type }} · Stock : {{ consumable.quantity }} · À commander : {{ consumable.quantity_to_order }}</p><h2 class="h5">Compatible avec {{ compatible|length }} équipement(s)</h2><ul>{% for link in compatible %}<li>{{ link.equipment.name }}</li>{% else %}<li>Aucune machine.</li>{% endfor %}</ul><h2 class="h5">Commandes</h2><div class="table-responsive"><table class="table table-sm"><tr><th>Quantité</th><th>Reçue</th><th>État</th><th>Action</th></tr>{% for order in orders %}<tr><td>{{ order.quantity_ordered }}</td><td>{{ order.quantity_received }}</td><td>{{ order.status }}</td><td>{% if order.remaining_quantity %}<form method="post" action="{{ url_for('c4.receive_order', order_id=order.id) }}" class="d-flex gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control form-control-sm" type="number" name="quantity" min="1" max="{{ order.remaining_quantity }}" value="{{ order.remaining_quantity }}"><button class="btn btn-sm btn-success">Réceptionner</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4">Aucune commande.</td></tr>{% endfor %}</table></div><form method="post" action="{{ url_for('c4.order_consumable', consumable_id=consumable.id) }}" class="row g-2 mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-auto"><label class="form-label">Nouvelle commande</label><input class="form-control" type="number" name="quantity" min="1" value="{{ consumable.quantity_to_order or 1 }}"></div><div class="col-auto align-self-end"><button class="btn btn-primary">Commander</button></div></form><h2 class="h5 mt-4">Historique des sorties</h2><ul>{% for usage in usages %}<li>{{ usage.used_at }} — {{ usage.quantity_used }} — {{ usage.equipment.name if usage.equipment else '—' }}</li>{% else %}<li>Aucune sortie.</li>{% endfor %}</ul></div>{% endblock %}
|
{% block content %}<div class="container-fluid"><h1 class="h4">{{ consumable.name }}</h1><p>Référence : {{ consumable.reference or '—' }} · Type : {{ consumable.consumable_type }} · Stock : {{ consumable.quantity }} · Minimum : {{ consumable.min_quantity }} · Cible : {{ consumable.target_quantity or '—' }} · À commander : {{ consumable.quantity_to_order }}</p><p>Commandé / à recevoir : <strong>{{ quantity_on_order }}</strong></p><p>Couverture estimée : {% if coverage.coverage_days is not none %}~{{ '%.0f'|format(coverage.coverage_days) }} jours{% else %}indisponible{% endif %} — {{ coverage.quality }} ({{ coverage.machines_with_history }}/{{ coverage.machines_total }} machines avec historique)</p><h2 class="h5">Compatible avec {{ compatible|length }} équipement(s)</h2><ul>{% for link in compatible %}<li>{{ link.equipment.name }}</li>{% else %}<li>Aucune machine.</li>{% endfor %}</ul><h2 class="h5">Commandes</h2><div class="table-responsive"><table class="table table-sm"><tr><th>Quantité</th><th>Reçue</th><th>À recevoir</th><th>État</th><th>Action</th></tr>{% for order in orders %}<tr><td>{{ order.quantity_ordered }}</td><td>{{ order.quantity_received }}</td><td>{{ order.remaining_quantity }}</td><td>{{ order.status }}</td><td>{% if order.remaining_quantity and order.status in ('ORDERED','PARTIALLY_RECEIVED') %}<form method="post" action="{{ url_for('c4.receive_order', order_id=order.id) }}" class="d-flex gap-2 mb-1"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control form-control-sm" type="number" name="quantity" min="1" max="{{ order.remaining_quantity }}" value="{{ order.remaining_quantity }}"><button class="btn btn-sm btn-success">Réceptionner</button></form><form method="post" action="{{ url_for('c4.cancel_order', order_id=order.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="reason" value="Annulation demandée"><button class="btn btn-sm btn-outline-danger">Annuler</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="5">Aucune commande.</td></tr>{% endfor %}</table></div><form method="post" action="{{ url_for('c4.order_consumable', consumable_id=consumable.id) }}" class="row g-2 mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-auto"><label class="form-label">Nouvelle commande</label><input class="form-control" type="number" name="quantity" min="1" value="{{ consumable.quantity_to_order or 1 }}"></div><div class="col-auto align-self-end"><button class="btn btn-primary">Commander</button></div></form><h2 class="h5 mt-4">Historique des sorties</h2><ul>{% for usage in usages %}<li>{{ usage.used_at }} — {{ usage.quantity_used }} — {{ usage.equipment.name if usage.equipment else '—' }}</li>{% else %}<li>Aucune sortie.</li>{% endfor %}</ul></div>{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,8 @@ def my_day():
|
||||||
}
|
}
|
||||||
execution_states = {}
|
execution_states = {}
|
||||||
from ..core.services.meter_analytics import visible_open_meter_alerts
|
from ..core.services.meter_analytics import visible_open_meter_alerts
|
||||||
informational_alerts = visible_open_meter_alerts(current_user)
|
from ..core.services.c4_service import visible_open_c4_alerts
|
||||||
|
informational_alerts = visible_open_meter_alerts(current_user) + visible_open_c4_alerts(current_user)
|
||||||
for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all():
|
for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all():
|
||||||
execution_states[task.id] = {
|
execution_states[task.id] = {
|
||||||
'status': task.status,
|
'status': task.status,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Ma journée{% endblock %}
|
{% block title %}Ma journée{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if informational_alerts %}<div class="alert alert-warning" role="status"><strong>Informations à examiner</strong><ul class="mb-0">{% for alert in informational_alerts %}<li>{{ alert.meter.name }} — {{ 'Critique' if alert.level == 'CRITICAL' else 'Avertissement' }}{% if alert.intervention_id %} — Intervention créée : #{{ alert.intervention_id }}{% endif %}</li>{% endfor %}</ul><small>Ces alertes n'occupent aucun créneau ; une intervention liée constitue le travail planifiable.</small></div>{% endif %}
|
{% if informational_alerts %}<div class="alert alert-warning" role="status"><strong>Informations à examiner</strong><ul class="mb-0">{% for alert in informational_alerts %}<li>{% if alert.meter %}{{ alert.meter.name }}{% else %}{{ alert.title }}{% endif %} — {{ alert.message }}{% if alert.intervention_id %} — Intervention créée : #{{ alert.intervention_id }}{% endif %}</li>{% endfor %}</ul><small>Ces alertes n'occupent aucun créneau ; une intervention liée constitue le travail planifiable.</small></div>{% endif %}
|
||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue