diff --git a/config/settings/base.py b/config/settings/base.py index 697007b..d439037 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,7 +1,7 @@ import datetime from config.settings.environ import * # noqa -APP_NAME = "TPV" +APP_NAME = "Shop" DESCRIPTION = "" VERSION = "0.1.0" @@ -26,6 +26,7 @@ THIRD_PARTY_APPS = [ ] PROJECT_APPS = [ + "shop", "tpv", ] diff --git a/requirements.test.txt b/requirements.test.txt index 97d3ee3..4bd4a20 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,4 +1,5 @@ -r requirements.txt pytest==7.3.0 +pytest-django==4.8.0 coverage==7.2.3 pytest-cov==4.0.0 \ No newline at end of file diff --git a/shop/__init__.py b/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/admin.py b/shop/admin.py new file mode 100644 index 0000000..d4cc21d --- /dev/null +++ b/shop/admin.py @@ -0,0 +1,51 @@ +from django.contrib import admin +from shop.models import Product, ProductPrice, OrderLine, Order, Tax +from unfold.admin import ModelAdmin + + +# Register your models here. +@admin.register(Product) +class ProductAdmin(ModelAdmin): + search_fields = ("name",) + list_display = ( + "id", + "name", + "stock", + "unit", + ) + + +@admin.register(ProductPrice) +class ProductPriceAdmin(ModelAdmin): + search_fields = ("product",) + autocomplete_fields = ("product",) + list_display = ( + "id", + "product", + "price", + "date", + ) + + +@admin.register(Tax) +class TaxAdmin(ModelAdmin): + list_display = ( + "code", + "value", + ) + + +@admin.register(OrderLine) +class OrderLineAdmin(ModelAdmin): + autocomplete_fields = ("product",) + list_display = ( + "id", + "product", + "price", + "quantity", + ) + + +@admin.register(Order) +class OrderAdmin(ModelAdmin): + list_display = ("id",) diff --git a/shop/apps.py b/shop/apps.py new file mode 100644 index 0000000..72c606d --- /dev/null +++ b/shop/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "shop" diff --git a/shop/migrations/0001_initial.py b/shop/migrations/0001_initial.py new file mode 100644 index 0000000..3448c4f --- /dev/null +++ b/shop/migrations/0001_initial.py @@ -0,0 +1,250 @@ +# Generated by Django 5.0.3 on 2024-03-24 12:51 + +import django.db.models.deletion +import django.utils.timezone +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="OrderLine", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "quantity", + models.DecimalField( + decimal_places=4, + default=Decimal("1"), + max_digits=13, + verbose_name="Cantidad", + ), + ), + ( + "price", + models.DecimalField( + decimal_places=4, max_digits=13, verbose_name="Precio" + ), + ), + ( + "base_total", + models.DecimalField( + decimal_places=4, + default=Decimal("0"), + max_digits=13, + verbose_name="Total sin impuestos", + ), + ), + ( + "tax_value", + models.PositiveIntegerField(verbose_name="Valor de impuestos"), + ), + ( + "taxes", + models.DecimalField( + decimal_places=4, max_digits=13, verbose_name="Impuestos" + ), + ), + ( + "total", + models.DecimalField( + decimal_places=4, max_digits=13, verbose_name="Total" + ), + ), + ], + options={ + "verbose_name": "Línea de pedido", + "verbose_name_plural": "Líneas de pedido", + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=96, verbose_name="Nombre")), + ( + "description", + models.CharField( + blank=True, + default="", + max_length=1000, + verbose_name="Descripción", + ), + ), + ( + "stock", + models.DecimalField( + decimal_places=4, + default=Decimal("0"), + max_digits=13, + verbose_name="Stock", + ), + ), + ( + "unit", + models.CharField( + choices=[("UNIT", "Unidad"), ("KG", "kg"), ("L", "L")], + default="UNIT", + max_length=6, + verbose_name="Unidad de medida", + ), + ), + ], + options={ + "verbose_name": "Producto", + "verbose_name_plural": "Productos", + }, + ), + migrations.CreateModel( + name="Tax", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField( + max_length=8, unique=True, verbose_name="Código de impuesto" + ), + ), + ( + "value", + models.PositiveIntegerField( + verbose_name="Valor entero (porcentaje)" + ), + ), + ], + options={ + "verbose_name": "Impuesto", + "verbose_name_plural": "Impuestos", + }, + ), + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_date", + models.DateTimeField( + auto_now_add=True, verbose_name="Fecha de creación" + ), + ), + ( + "last_modification_date", + models.DateTimeField( + auto_now=True, verbose_name="Fecha de última modificación" + ), + ), + ( + "base_total", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=13, + verbose_name="Base imponible", + ), + ), + ( + "total", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=13, + verbose_name="Total", + ), + ), + ( + "lines", + models.ManyToManyField(to="shop.orderline", verbose_name="Líneas"), + ), + ], + options={ + "verbose_name": "Pedido", + "verbose_name_plural": "Pedidos", + }, + ), + migrations.AddField( + model_name="orderline", + name="product", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="shop.product", + verbose_name="Producto", + ), + ), + migrations.CreateModel( + name="ProductPrice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "price", + models.DecimalField( + decimal_places=2, max_digits=11, verbose_name="Precio" + ), + ), + ( + "date", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="Fecha" + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="prices", + to="shop.product", + verbose_name="Producto", + ), + ), + ], + options={ + "verbose_name": "Precio de producto", + "verbose_name_plural": "Precio de producto", + }, + ), + ] diff --git a/shop/migrations/__init__.py b/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/models.py b/shop/models.py new file mode 100644 index 0000000..6f45a96 --- /dev/null +++ b/shop/models.py @@ -0,0 +1,169 @@ +from decimal import Decimal + +from django.db import models +from django.utils import timezone +from django.utils.text import gettext_lazy as _ + + +class Product(models.Model): + class UnitChoices(models.TextChoices): + UNIT = "UNIT", _("Unidad") + WEIGHT_KG = "KG", _("kg") + VOLUME_LITER = "L", _("L") + + name = models.CharField( + max_length=96, + blank=False, + null=False, + verbose_name=_("Nombre"), + ) + description = models.CharField( + max_length=1000, + blank=True, + default="", + verbose_name=_("Descripción"), + ) + stock = models.DecimalField( + max_digits=13, + decimal_places=4, + default=Decimal("0"), + verbose_name=_("Stock"), + ) + unit = models.CharField( + max_length=6, + choices=UnitChoices.choices, + default=UnitChoices.UNIT, + verbose_name=_("Unidad de medida"), + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("Producto") + verbose_name_plural = _("Productos") + + +class ProductPrice(models.Model): + price = models.DecimalField( + max_digits=11, decimal_places=2, verbose_name=_("Precio") + ) + date = models.DateTimeField(default=timezone.now, verbose_name=_("Fecha")) + product = models.ForeignKey( + "shop.Product", + on_delete=models.CASCADE, + verbose_name=_("Producto"), + related_name="prices", + ) + + def __str__(self): + return f"{self.price}" + + class Meta: + verbose_name = _("Precio de producto") + verbose_name_plural = _("Precio de producto") + + +class Tax(models.Model): + code = models.CharField( + max_length=8, blank=False, unique=True, verbose_name=_("Código de impuesto") + ) + value = models.PositiveIntegerField( + null=False, blank=False, verbose_name=_("Valor entero (porcentaje)") + ) + + def __str__(self): + return f"{self.code} - {self.value}%" + + class Meta: + verbose_name = _("Impuesto") + verbose_name_plural = _("Impuestos") + + +class OrderLine(models.Model): + product = models.ForeignKey( + "shop.Product", + on_delete=models.CASCADE, + null=False, + blank=False, + verbose_name=_("Producto"), + ) + quantity = models.DecimalField( + max_digits=13, + decimal_places=4, + default=Decimal("1"), + null=False, + blank=False, + verbose_name=_("Cantidad"), + ) + + # Precio del producto en el momento en el que se crea el pedido + price = models.DecimalField( + max_digits=13, decimal_places=4, verbose_name=_("Precio") + ) + + # base_total = quantity * price + base_total = models.DecimalField( + max_digits=13, + decimal_places=4, + default=Decimal("0"), + null=False, + blank=False, + verbose_name=_("Total sin impuestos"), + ) + + # Valor entero del impuesto en el momento en el que se crea el pedido + tax_value = models.PositiveIntegerField( + null=False, blank=False, verbose_name=_("Valor de impuestos") + ) + + # taxes = price * quantity * (tax_value / 100) + taxes = models.DecimalField( + max_digits=13, decimal_places=4, verbose_name=_("Impuestos") + ) + + # total = base_total + taxes + total = models.DecimalField( + max_digits=13, decimal_places=4, verbose_name=_("Total") + ) + + def __str__(self): + return f"{self.product.name} - {self.quantity} - {self.price}" + + class Meta: + verbose_name = _("Línea de pedido") + verbose_name_plural = _("Líneas de pedido") + + +class Order(models.Model): + lines = models.ManyToManyField("shop.OrderLine", verbose_name=_("Líneas")) + creation_date = models.DateTimeField( + auto_now_add=True, verbose_name=_("Fecha de creación") + ) + last_modification_date = models.DateTimeField( + auto_now=True, verbose_name=_("Fecha de última modificación") + ) + + base_total = models.DecimalField( + default=Decimal("0"), + max_digits=13, + decimal_places=2, + verbose_name=_("Base imponible"), + ) + total = models.DecimalField( + default=Decimal("0"), max_digits=13, decimal_places=2, verbose_name=_("Total") + ) + + def calculate_total_from_lines(self): + self.base_total = self.lines.aggregate(base_total=models.Sum("base_total")).get( + "base_total" + ) + self.total = self.lines.aggregate(base_total=models.Sum("total")).get( + "base_total" + ) + + self.save() + + class Meta: + verbose_name = _("Pedido") + verbose_name_plural = _("Pedidos") diff --git a/shop/tests.py b/shop/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/shop/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shop/tests/__init__.py b/shop/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/tests/test_shop_models.py b/shop/tests/test_shop_models.py new file mode 100644 index 0000000..81255bf --- /dev/null +++ b/shop/tests/test_shop_models.py @@ -0,0 +1,56 @@ +from decimal import Decimal +from rest_framework.test import APITestCase as TestCase +from shop.models import Product, ProductPrice, OrderLine, Tax +from shop.utils import create_order_line_for_product, create_order + + +class ShopModelsTest(TestCase): + def setUp(self) -> None: + self.tax = Tax.objects.create( + code="IVA", + value=21, + ) + self.create_products() + + def create_products(self): + self.potatoes = Product.objects.create( + name="Patatas", + stock=Decimal("100.00"), + unit=Product.UnitChoices.WEIGHT_KG, + ) + self.gasoline = Product.objects.create( + name="Gasolina", + stock=Decimal("800.00"), + unit=Product.UnitChoices.VOLUME_LITER, + ) + self.usb_c = Product.objects.create( + name="Cable USB-C", + stock=Decimal("5.00"), + unit=Product.UnitChoices.UNIT, + ) + + ProductPrice.objects.create(product=self.potatoes, price=Decimal("0.80")) + ProductPrice.objects.create(product=self.gasoline, price=Decimal("1.15")) + ProductPrice.objects.create(product=self.usb_c, price=Decimal("9.95")) + + def test_create_order(self): + l1 = create_order_line_for_product( + self.potatoes, + quantity=Decimal("1.5"), + tax=self.tax, + ) + l2 = create_order_line_for_product( + self.gasoline, + quantity=Decimal("40"), + tax=self.tax, + ) + l3 = create_order_line_for_product( + self.usb_c, + quantity=Decimal("1.00"), + tax=self.tax, + ) + + order = create_order(OrderLine.objects.all()) + + assert order.total == l1.total + l2.total + l3.total + assert order.base_total == l1.base_total + l2.base_total + l3.base_total diff --git a/shop/utils.py b/shop/utils.py new file mode 100644 index 0000000..35f409c --- /dev/null +++ b/shop/utils.py @@ -0,0 +1,29 @@ +from decimal import Decimal +from shop.models import OrderLine, Tax, Product, Order +from django.db.models import QuerySet + + +def create_order_line_for_product(product: Product, quantity: Decimal, tax: Tax): + price = product.prices.last().price + base_total = round(price * quantity, 2) + taxes = round(base_total * (tax.value / Decimal("100")), 2) + + return OrderLine.objects.create( + product=product, + quantity=quantity, + price=price, + base_total=base_total, + tax_value=tax.value, + taxes=taxes, + total=base_total + taxes, + ) + + +def create_order(lines: QuerySet): + order = Order.objects.create() + + for line in lines.all(): + order.lines.add(line) + + order.calculate_total_from_lines() + return order diff --git a/shop/views.py b/shop/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/shop/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.