This commit is contained in:
Felix Baumann
2026-06-28 10:20:36 +00:00
parent 64b32d0b44
commit 148efd919d
86 changed files with 2475 additions and 760 deletions
-1
View File
@@ -5,7 +5,6 @@ from core.forms.base import BaseForm
class InventoryItemForm(BaseForm):
class Meta:
model = InventoryItem
fields = [
+6 -5
View File
@@ -1,5 +1,6 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
@@ -44,8 +45,8 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Preis')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Anzahl')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Preis')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
@@ -60,7 +61,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
('quantity', models.PositiveIntegerField(default=0, verbose_name='Anzahl')),
('quantity', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Anzahl')),
('article_number', models.CharField(blank=True, max_length=255, verbose_name='Artikelnummer')),
('created_at', models.DateTimeField(auto_now_add=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorycategory', verbose_name='Kategorie')),
@@ -77,7 +78,7 @@ class Migration(migrations.Migration):
name='ProjectInventoryItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Anzahl')),
('status', models.CharField(choices=[('open', 'Offen'), ('packed', 'Eingepackt'), ('on_site', 'Vor Ort')], default='open', max_length=20, verbose_name='Status')),
('created_at', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.inventoryitem', verbose_name='Artikel')),
+1 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.db import migrations, models
+5 -4
View File
@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from projects.models import Project
from finance.models import Category, Supplier
from django.db.models import Sum
from django.core.validators import MinValueValidator
class InventoryCategory(models.Model):
name = models.CharField(max_length=255, unique=True, verbose_name="Name")
@@ -31,7 +32,7 @@ class InventoryLocation(models.Model):
class InventoryItem(models.Model):
name = models.CharField(max_length=255, verbose_name="Name")
description = models.TextField(blank=True, verbose_name="Beschreibung")
quantity = models.PositiveIntegerField(default=0, verbose_name="Anzahl")
quantity = models.PositiveIntegerField(default=0, verbose_name="Anzahl", validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
location = models.ForeignKey(InventoryLocation, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Standort")
article_number = models.CharField(max_length=255, blank=True, verbose_name="Artikelnummer")
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Verantwortlicher")
@@ -55,7 +56,7 @@ class ProjectInventoryItem(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="inventory_items", verbose_name="Projekt")
item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, verbose_name="Artikel")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl", validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN, verbose_name="Status")
created_at = models.DateTimeField(auto_now_add=True)
@@ -72,9 +73,9 @@ class ShoppingItem(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="shopping_items", verbose_name="Projekt")
name = models.CharField(max_length=255, verbose_name="Name")
description = models.TextField(blank=True, verbose_name="Beschreibung")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl", validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Preis")
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Preis",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
created_at = models.DateTimeField(auto_now_add=True)
+8 -1
View File
@@ -32,7 +32,6 @@ class InventoryGlobalTable(tables.Table):
model = InventoryItem
template_name = "django_tables2/table_toggle.html"
fields = (
"expand",
"name",
"category",
"available",
@@ -85,6 +84,11 @@ class InventoryAddTable(tables.Table):
}
class ShoppingItemTable(tables.Table):
selection = tables.TemplateColumn(
template_name="inventory/tables/shopping/selection.html",
orderable=False,
verbose_name="",
)
name = tables.TemplateColumn(
template_name="inventory/tables/shopping/name.html",
@@ -125,6 +129,7 @@ class ShoppingItemTable(tables.Table):
class Meta:
model = ShoppingItem
fields = (
"selection",
"name",
"category",
"quantity",
@@ -137,6 +142,8 @@ class ShoppingItemTable(tables.Table):
"class": "w-full text-sm"
}
class ProjectInventoryItemTable(tables.Table):
name = tables.Column(
@@ -18,7 +18,7 @@ Globale Inventarverwaltung
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Inventarartikel hinzufügen</p>
</div>
@@ -34,7 +34,7 @@ Globale Inventarverwaltung
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Inventarliste</h2>
<h2 class="text-lg font-bold">Inventarliste</h2>
<p class="text-slate-400 text-sm mt-1">Gelagerte und verfügbare Artikel</p>
</div>
+9 -5
View File
@@ -18,7 +18,7 @@ Vorhandenen & benötigtes Material
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Artikel zur Packliste oder Einkaufsliste hinzufügen</p>
</div>
@@ -34,7 +34,7 @@ Vorhandenen & benötigtes Material
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Packliste</h2>
<h2 class="text-lg font-bold">Packliste</h2>
<p class="text-slate-400 text-sm mt-1">Vorhandene und benötigte Artikel</p>
</div>
@@ -53,7 +53,7 @@ Vorhandenen & benötigtes Material
<div class="flex flex-wrap justify-between items-center mb-6">
<div>
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Einkaufsliste
</h2>
@@ -64,9 +64,13 @@ Vorhandenen & benötigtes Material
</div>
<div class="overflow-x-auto rounded-xl">
<form id="bulk-shopping-form" method="post" action="{% url 'inventory:shopping_to_expense' project.id %}" class="rounded-xl">
{% csrf_token %}
<button type="submit" class="btn btn-action mb-2">
Zu Ausgabe konvertieren
</button>
</form>
{% render_table shopping_table %}
</div>
</div>
@@ -10,20 +10,20 @@
name="item_id"
value="{{ record.id }}"
form="save-form-{{ record.id }}">
<div class="flex gap-2">
<button
type="submit"
form="save-form-{{ record.id }}"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
form="save-form-{{ record.id }}"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
form="save-form-{{ record.id }}"
formaction="{% url 'inventory:delete' project.id record.id %}"
class="btn btn-delete">
Löschen
</button>
<button
type="submit"
form="save-form-{{ record.id }}"
formaction="{% url 'inventory:delete' project.id record.id %}"
class="btn btn-delete">
Löschen
</button>
</div>
</form>
@@ -3,8 +3,12 @@
{{ record.available_quantity }}
</span>
{% elif record.available_quantity == 0 %}
<span class="text-orange-600 font-semibold">
<span class="text-yellow-600 font-semibold">
0
</span>
{% elif record.available_quantity < 0 %}
<span class="text-orange-600 font-semibold">
{{ record.available_quantity }}
</span>
{% endif %}
/ {{ record.quantity }}
@@ -1,7 +1,7 @@
<form
id="shopping-save-form-{{ record.id }}"
method="post"
action="{% url 'inventory:shopping_edit' table.project.id record.id %}">
action="{% url 'inventory:shopping_edit' shopping_table.project.id record.id %}">
{% csrf_token %}
@@ -22,7 +22,7 @@
<button
type="submit"
form="shopping-save-form-{{ record.id }}"
formaction="{% url 'inventory:shopping_delete' table.project.id record.id %}"
formaction="{% url 'inventory:shopping_delete' shopping_table.project.id record.id %}"
class="btn btn-delete">
Löschen
</button>
@@ -5,7 +5,7 @@
<option value="">---</option>
{% for c in table.categories %}
{% for c in shopping_table.categories %}
<option value="{{ c.id }}"
{% if record.category_id == c.id %}selected{% endif %}>
{{ c.name }}
@@ -0,0 +1,5 @@
<input
type="checkbox"
name="selection"
value="{{ record.id }}"
form="bulk-shopping-form">
@@ -5,7 +5,7 @@
<option value="">---</option>
{% for s in table.suppliers %}
{% for s in shopping_table.suppliers %}
<option value="{{ s.id }}"
{% if record.supplier_id == s.id %}selected{% endif %}>
{{ s.name }}
+2
View File
@@ -13,4 +13,6 @@ urlpatterns = [
path("<int:project_id>/inventory/delete/<int:item_id>/", views.inventory_delete, name="delete"),
path("<int:project_id>/inventory/shopping-delete/<int:item_id>/", views.shopping_delete, name="shopping_delete"),
path("<int:project_id>/inventory/shopping-to-expense/", views.shopping_to_expense, name="shopping_to_expense"),
]
+93
View File
@@ -9,6 +9,9 @@ from django.db.models import Sum, Value, F
from django_tables2 import RequestConfig
from .tables import ProjectInventoryItemTable, ShoppingItemTable, InventoryAddTable, InventoryGlobalTable
from django.db.models.functions import Coalesce
import json
from collections import Counter
from django.utils import timezone
@login_required
@permission_required("projects_inventory_inventory_view")
@@ -428,4 +431,94 @@ def inventory_global_edit(request, item_id):
"locations": InventoryLocation.objects.all(),
"users": User.objects.order_by("username"),
}
)
@login_required
@permission_required("projects_inventory_shopping_to_expense")
def shopping_to_expense(request, project_id):
ids = request.POST.getlist("selection")
items = ShoppingItem.objects.filter(pk__in=ids)
data = []
total_amount = 0
categories = []
suppliers = []
for item in items:
total_amount += (item.quantity * item.price)
if item.category:
categories.append(item.category.id)
if item.supplier:
suppliers.append(item.supplier.id)
data.append({
"name": item.name,
"quantity": item.quantity,
"price": str(item.price),
"supplier": str(item.supplier) if item.supplier else None,
"category": str(item.category) if item.category else None,
"description": item.description,
})
most_category = (
Counter(categories).most_common(1)[0][0]
if categories else None
)
most_supplier = (
Counter(suppliers).most_common(1)[0][0]
if suppliers else None
)
request.session["expense_initial"] = {
"name": "Einkauf vom " + timezone.now().strftime("%d.%m.%Y"),
"amount": str(total_amount),
"date": timezone.now().strftime("%Y-%m-%dT%H:%M"),
"category": most_category,
"supplier": most_supplier,
"note": json.dumps(
data,
ensure_ascii=False,
indent=2
)
}
request.session["shopping_item_ids"] = list(
items.values_list("id", flat=True)
)
return redirect(
"finance:add_expense",
project_id=project_id
)