diff --git a/inventory/models.py b/inventory/models.py index 4445d14f..4655193f 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -6,7 +6,9 @@ from django.contrib.auth.models import Permission from inventory.validators import SaudiPhoneNumberValidator from decimal import Decimal from django.urls import reverse -from django.utils.text import slugify + +# from django.utils.text import slugify +from slugify import slugify from django.utils import timezone from django.core.validators import MinValueValidator,MaxValueValidator import hashlib @@ -59,10 +61,11 @@ from encrypted_model_fields.fields import ( # from simple_history.models import HistoricalRecords from plans.models import Invoice +from django_extensions.db.fields import RandomCharField + logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) - class Base(models.Model): id = models.UUIDField( unique=True, @@ -644,7 +647,7 @@ class Car(Base): null=True, blank=True, ) - vin = models.CharField(max_length=17, unique=True, verbose_name=_("VIN")) + vin = models.CharField(max_length=17, verbose_name=_("VIN")) dealer = models.ForeignKey( "Dealer", models.DO_NOTHING, related_name="cars", verbose_name=_("Dealer") ) @@ -771,6 +774,11 @@ class Car(Base): condition=Q(status=CarStatusChoices.AVAILABLE), ), ] + constraints = [ + models.UniqueConstraint( + fields=["dealer", "vin"], name="unique_vin_per_dealer" + ) + ] def __str__(self): make = self.id_car_make.name if self.id_car_make else "Unknown Make" @@ -1519,25 +1527,11 @@ class Staff(models.Model): active = models.BooleanField(default=True, verbose_name=_("Active")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField( - max_length=255, unique=True, editable=False, null=True, blank=True - ) - - def save(self, *args, **kwargs): - if not self.slug: - base_slug = slugify(f"{self.first_name}-{self.last_name}") - self.slug = base_slug - counter = 1 - - while ( - self.__class__.objects.filter(slug=self.slug) - .exclude(pk=self.pk) - .exists() - ): - self.slug = f"{base_slug}-{counter}" - counter += 1 - super().save(*args, **kwargs) - + # slug = models.SlugField( + # max_length=255, unique=True, editable=False, null=True, blank=True,allow_unicode=True + # ) + slug = RandomCharField(length=8, unique=True) + objects = StaffUserManager() @property @@ -1760,24 +1754,7 @@ class Customer(models.Model): ) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField( - max_length=255, unique=True, editable=False, null=True, blank=True - ) - - def save(self, *args, **kwargs): - if not self.slug: - base_slug = slugify(f"{self.last_name} {self.first_name}") - self.slug = base_slug - counter = 1 - - while ( - self.__class__.objects.filter(slug=self.slug) - .exclude(pk=self.pk) - .exists() - ): - self.slug = f"{base_slug}-{counter}" - counter += 1 - super().save(*args, **kwargs) + slug = RandomCharField(length=8, unique=True) class Meta: constraints = [ @@ -1932,24 +1909,7 @@ class Organization(models.Model, LocalizedNameMixin): active = models.BooleanField(default=True, verbose_name=_("Active")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField( - max_length=255, unique=True, editable=False, null=True, blank=True - ) - - def save(self, *args, **kwargs): - if not self.slug: - base_slug = slugify(f"{self.name}") - self.slug = base_slug - counter = 1 - - while ( - self.__class__.objects.filter(slug=self.slug) - .exclude(pk=self.pk) - .exists() - ): - self.slug = f"{base_slug}-{counter}" - counter += 1 - super().save(*args, **kwargs) + slug = RandomCharField(length=8, unique=True) class Meta: verbose_name = _("Organization") @@ -2151,7 +2111,7 @@ class Lead(models.Model): auto_now_add=True, verbose_name=_("Created"), db_index=True ) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField(unique=True, blank=True, null=True) + slug = RandomCharField(length=8, unique=True) class Meta: verbose_name = _("Lead") @@ -2272,21 +2232,6 @@ class Lead(models.Model): def get_absolute_url(self): return reverse("lead_detail", args=[self.dealer.slug, self.slug]) - def save(self, *args, **kwargs): - if not self.slug: - base_slug = slugify(f"{self.last_name} {self.first_name}") - self.slug = base_slug - counter = 1 - - while ( - self.__class__.objects.filter(slug=self.slug) - .exclude(pk=self.pk) - .exists() - ): - self.slug = f"{base_slug}-{counter}" - counter += 1 - super().save(*args, **kwargs) - class Schedule(models.Model): PURPOSE_CHOICES = [ @@ -2485,13 +2430,8 @@ class Opportunity(models.Model): null=True, blank=True, ) - slug = models.SlugField( - null=True, - blank=True, - unique=True, - verbose_name=_("Slug"), - help_text=_("Unique slug for the opportunity."), - ) + slug = RandomCharField(length=8, unique=True) + loss_reason = models.CharField(max_length=255, blank=True, null=True) def get_notes(self): @@ -2534,29 +2474,6 @@ class Opportunity(models.Model): objects = objects.union(lead_objects).order_by("-created") return objects - def save(self, *args, **kwargs): - opportinity_for = "" - if self.lead.lead_type == "customer": - self.customer = self.lead.customer - opportinity_for = self.customer.first_name + " " + self.customer.last_name - elif self.lead.lead_type == "organization": - self.organization = self.lead.organization - opportinity_for = self.organization.name - - if not self.slug: - base_slug = slugify(f"opportinity {opportinity_for}") - self.slug = base_slug - counter = 1 - - while ( - self.__class__.objects.filter(slug=self.slug) - .exclude(pk=self.pk) - .exists() - ): - self.slug = f"{base_slug}-{counter}" - counter += 1 - super().save(*args, **kwargs) - class Meta: verbose_name = _("Opportunity") verbose_name_plural = _("Opportunities") @@ -2809,30 +2726,13 @@ class Vendor(models.Model, LocalizedNameMixin): ) active = models.BooleanField(default=True, verbose_name=_("Active")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) - slug = models.SlugField( - max_length=255, unique=True, verbose_name=_("Slug"), null=True, blank=True - ) + slug = RandomCharField(length=8, unique=True) def get_absolute_url(self): return reverse( "vendor_detail", kwargs={"dealer_slug": self.dealer.slug, "slug": self.slug} ) - def save(self, *args, **kwargs): - if not self.slug: - base_slug = slugify(self.name) - self.slug = base_slug - counter = 1 - - while ( - self.__class__.objects.filter(slug=self.slug) - .exclude(pk=self.pk) - .exists() - ): - self.slug = f"{base_slug}-{counter}" - counter += 1 - super().save(*args, **kwargs) - class Meta: verbose_name = _("Vendor") verbose_name_plural = _("Vendors") diff --git a/inventory/urls.py b/inventory/urls.py index d3b39132..4073cdae 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -938,7 +938,12 @@ urlpatterns = [ views.ItemServiceUpdateView.as_view(), name="item_service_update", ), - + + path( + "/items/services//detail/", + views.ItemServiceDetailView.as_view(), + name="item_service_detail", + ), # Expanese path( "/items/expeneses/", @@ -955,6 +960,11 @@ urlpatterns = [ views.ItemExpenseUpdateView.as_view(), name="item_expense_update", ), + path( + "/items/expeneses//detail/", + views.ItemExpenseDetailView.as_view(), + name="item_expense_detail", + ), # Bills path( "/items/bills/", diff --git a/inventory/utils.py b/inventory/utils.py index d4294400..aabfb15f 100644 --- a/inventory/utils.py +++ b/inventory/utils.py @@ -25,7 +25,9 @@ from django_ledger.models import ( InvoiceModel, BillModel, VendorModel, - AccountModel + AccountModel, + EntityModel, + ChartOfAccountModel ) from django.core.files.base import ContentFile from django_ledger.models.items import ItemModel @@ -1594,10 +1596,13 @@ def _post_sale_and_cogs(invoice, dealer): 1) Cash / A-R / VAT / Revenue journal 2) COGS / Inventory journal """ - entity = invoice.ledger.entity + entity:EntityModel = invoice.ledger.entity # calc = CarFinanceCalculator(invoice) data = get_finance_data(invoice, dealer) + car = data.get("car") + + coa:ChartOfAccountModel = entity.get_default_coa() # cash_acc = ( # entity.get_default_coa_accounts() # .filter(role_default=True, role=roles.ASSET_CA_CASH) @@ -1611,6 +1616,25 @@ def _post_sale_and_cogs(invoice, dealer): add_rev = dealer.settings.invoice_additional_services_account + if not add_rev: + try: + add_rev = entity.get_default_coa_accounts().filter(name="After-Sales Services", active=True).first() + if not add_rev: + add_rev = coa.create_account( + code="4020", + name="After-Sales Services", + role=roles.INCOME_OPERATIONAL, + balance_type=roles.CREDIT, + active=True, + ) + add_rev.role_default = False + add_rev.save(update_fields=['role_default']) + dealer.settings.invoice_additional_services_account = add_rev + dealer.settings.save() + except Exception as e: + logger.error(f"error find or create additional services account {e}") + if car.get_additional_services_amount > 0 and not add_rev: + raise Exception("additional services exist but not account found,please create account for the additional services and set as default in the settings") cogs_acc = dealer.settings.invoice_cost_of_good_sold_account or entity.get_default_coa_accounts().filter(role_default=True, role=roles.COGS).first() inv_acc = dealer.settings.invoice_inventory_account or entity.get_default_coa_accounts().filter(role_default=True, role=roles.ASSET_CA_INVENTORY).first() @@ -1672,7 +1696,7 @@ def _post_sale_and_cogs(invoice, dealer): if car.get_additional_services_amount > 0: # Cr Sales – Additional Services if not add_rev: - logger.warning(f"Additional Services account not set for dealer {dealer}. Skipping additional services revenue entry.") + logger.warning(f"Additional Services account not set for dealer {dealer}. Skipping additional services revenue entry.") else: TransactionModel.objects.create( journal_entry=je_sale, diff --git a/inventory/views.py b/inventory/views.py index 49d54ba6..94055d2c 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -775,8 +775,7 @@ def aging_inventory_list_view(request, dealer_slug): dealer=dealer, receiving_date__date__lt=today_local - timedelta(days=aging_threshold_days) ).exclude(status='sold') - total_aging_inventory_value=aging_cars_queryset.aggregate(total=Sum('cost_price'))['total'] - + # Apply filters to the queryset if they exist. Chaining is fine here. if selected_make: aging_cars_queryset = aging_cars_queryset.filter(id_car_make__name=selected_make) @@ -789,6 +788,9 @@ def aging_inventory_list_view(request, dealer_slug): if selected_stock_type: aging_cars_queryset = aging_cars_queryset.filter(stock_type=selected_stock_type) + total_aging_inventory_value=aging_cars_queryset.aggregate(total=Sum('cost_price'))['total'] + count_of_aging_cars = aging_cars_queryset.count() + # Get distinct values for filter dropdowns based on the initial, unfiltered aging cars queryset. # This ensures all possible filter options are always available. @@ -826,7 +828,9 @@ def aging_inventory_list_view(request, dealer_slug): 'all_series': all_series, 'all_stock_types': all_stock_types, 'all_years': all_years, - 'total_aging_inventory_value':total_aging_inventory_value + 'total_aging_inventory_value':total_aging_inventory_value, + 'page_obj':page_obj, + 'count_of_aging_cars':count_of_aging_cars } @@ -7748,7 +7752,7 @@ class ItemServiceListView(LoginRequiredMixin, PermissionRequiredMixin, ListView) model = models.AdditionalServices template_name = "items/service/service_list.html" context_object_name = "services" - paginate_by = 30 + paginate_by = 20 permission_required = ["inventory.view_additionalservices"] def get_queryset(self): @@ -7762,7 +7766,17 @@ class ItemServiceListView(LoginRequiredMixin, PermissionRequiredMixin, ListView) ) return qs +class ItemServiceDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + model = models.AdditionalServices + template_name = "items/service/service_detail.html" + context_object_name = "service" + permission_required = ["inventory.view_additionalservices"] + def get_context_data(self, **kwargs): + context=super().get_context_data(**kwargs) + sold_cars=models.Car.objects.filter(status='sold',) + context['total_services_price']=self.object.price*self.object.additionals.filter(status='sold').count() + return context class ItemExpenseCreateView(LoginRequiredMixin, PermissionRequiredMixin,SuccessMessageMixin, CreateView): @@ -7884,7 +7898,7 @@ class ItemExpenseListView(LoginRequiredMixin, PermissionRequiredMixin, ListView) model = ItemModel template_name = "items/expenses/expenses_list.html" context_object_name = "expenses" - paginate_by = 4 + paginate_by =20 permission_required = ["django_ledger.view_itemmodel"] def get_queryset(self): @@ -7896,6 +7910,32 @@ class ItemExpenseListView(LoginRequiredMixin, PermissionRequiredMixin, ListView) return qs +class ItemExpenseDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + queryset=ItemModel.objects.filter(item_role='expense') + template_name = "items/expenses/expense_detail.html" + context_object_name = "expense" + permission_required = ["django_ledger.view_itemmodel"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Get the related bills queryset + bills_list = self.object.billmodel_set.all().order_by('-created') + + # Paginate the bills + paginator = Paginator(bills_list, 10) # Show 10 bills per page + page_number = self.request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Add the paginated bills to the context + context['page_obj'] = page_obj + context["entity"] = get_user_type(self.request).entity + return context + + + + + + class BillListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): """ Provides a view for listing bills. @@ -7918,6 +7958,7 @@ class BillListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): model = BillModel template_name = "ledger/bills/bill_list.html" context_object_name = "bills" + paginate_by=20 permission_required = ["django_ledger.view_billmodel"] def get_queryset(self): @@ -9834,13 +9875,14 @@ def ledger_unpost_all_journals(request, dealer_slug, entity_slug, pk): def pricing_page(request, dealer_slug): dealer=get_object_or_404(models.Dealer, slug=dealer_slug) vat = models.VatRate.objects.filter(dealer=dealer).first() - if not hasattr(dealer.user,'userplan') or dealer.is_plan_expired: + now = datetime.now().date() + timedelta(days=15) + if not hasattr(dealer.user,'userplan') or dealer.is_plan_expired or dealer.user.userplan.expire <= now: plan_list = PlanPricing.objects.annotate( price_with_tax=Round(F('price') * vat.rate + F('price'), 2) ).all() form = forms.PaymentPlanForm() - return render(request, "pricing_page.html", {"plan_list": plan_list, "form": form}) + return render(request, "pricing_page.html", {"plan_list": plan_list, "form": form}) else: messages.info(request,_("You already have an plan!!")) return redirect('home',dealer_slug=dealer_slug) diff --git a/templates/crm/opportunities/opportunity_detail.html b/templates/crm/opportunities/opportunity_detail.html index 3a53ee6f..5198269b 100644 --- a/templates/crm/opportunities/opportunity_detail.html +++ b/templates/crm/opportunities/opportunity_detail.html @@ -72,6 +72,7 @@ {% endif %}
{% if opportunity.car.marked_price %} + {% trans "Marked Price: " %}
{{ opportunity.car.marked_price }}
@@ -370,7 +371,7 @@ {% if request.user.email == opportunity.staff.email %}
{% trans "You" %}
{% else %} -
{{ opportunity.staff.get_local_name }}
+
{{ opportunity.staff.fullname }}
{% endif %} diff --git a/templates/crm/opportunities/opportunity_list.html b/templates/crm/opportunities/opportunity_list.html index 97533751..7579dce6 100644 --- a/templates/crm/opportunities/opportunity_list.html +++ b/templates/crm/opportunities/opportunity_list.html @@ -29,17 +29,22 @@
-
- + id="filter-container" + hx-get="{% url 'opportunity_list' request.dealer.slug %}" + hx-target="#opportunities-grid" + hx-select="#opportunities-grid" + hx-swap="outerHTML"> -
- -
+ hx-include="#search-form input, select[name='stage']">
{% if page_obj.paginator.num_pages > 1 %} -
-
{% include 'partials/pagination.html' %}
+
+
+ {% include 'partials/pagination.html' %} +
{% endif %} {% else %} @@ -125,17 +128,19 @@ document.addEventListener("DOMContentLoaded", function() { const searchInput = document.getElementById("search-input"); const clearButton = document.getElementById("clear-search"); - const searchForm = document.getElementById("search-form"); - + if (clearButton) { clearButton.addEventListener("click", function() { + // Clear the input field searchInput.value = ""; - // This clears the search and triggers the htmx search - // by submitting the form with an empty query. - searchForm.submit(); + + // Trigger HTMX search with a 'search' event + // This uses the hx-trigger="search" on the form + // and prevents a full page reload. + searchInput.dispatchEvent(new Event('search', { bubbles: true })); }); } }); {% endblock %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/crm/opportunities/partials/opportunity_grid.html b/templates/crm/opportunities/partials/opportunity_grid.html index ebe06229..448fcfa2 100644 --- a/templates/crm/opportunities/partials/opportunity_grid.html +++ b/templates/crm/opportunities/partials/opportunity_grid.html @@ -67,7 +67,7 @@ {% else %} {{ opportunity.staff.fullname }}

- {% endif %} + {% endif %}
diff --git a/templates/customers/view_customer.html b/templates/customers/view_customer.html index 53da72fe..f369aa62 100644 --- a/templates/customers/view_customer.html +++ b/templates/customers/view_customer.html @@ -11,7 +11,7 @@

- {% trans 'Customer details' %} + {% trans 'Customer details' %}

diff --git a/templates/dashboards/aging_inventory_list.html b/templates/dashboards/aging_inventory_list.html index 54e9f5c5..7d34146b 100644 --- a/templates/dashboards/aging_inventory_list.html +++ b/templates/dashboards/aging_inventory_list.html @@ -80,7 +80,7 @@ {% if is_paginated %}
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }} - {% trans "Total Aging Cars:" %} {{ page_obj.paginator.count }} + {% trans "Total Aging Cars:" %} {{ count_of_aging_cars }}
{% endif %} {% if cars %} @@ -118,12 +118,11 @@
{% trans "Excellent! There are no cars in the aging inventory at the moment." %}
{% endif %} -
-
+ {% if is_paginated %} {% include 'partials/pagination.html' %} {% endif %} -
-
+ +
{% endblock content %} diff --git a/templates/items/expenses/expense_detail.html b/templates/items/expenses/expense_detail.html new file mode 100644 index 00000000..d13b58ac --- /dev/null +++ b/templates/items/expenses/expense_detail.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} +{% load tenhal_tag %} + +{% block title %}{% trans "Expense Detail" %}{% endblock %} + +{% block content %} + +
+
+

{{ expense.name|title }}

+

{% trans "Comprehensive details for your expense." %}

+
+
+ +
+
+
+
+
+ +

{% trans "Expense Information" %} Back To List

+ +
+
+
SKU
+

{{ expense.sku|default:"N/A" }}

+
+
+
UPC
+

{{ expense.upc|default:"N/A" }}

+
+
+
{% trans "Default Amount" %}
+

{{ expense.default_amount }}

+
+
+
{% trans "Expense Account" %}
+

{{ expense.expense_account }}

+
+
+ +
+ +

{% trans "Associated Bills" %}

+ + + {% if page_obj %} +
+
+ + + + + + + + + + + {% for bill in page_obj%} + + + + + + + + + {% endfor %} + +
{% trans "Bill Number" %}{% trans "Status" %}{% trans "Vendor" %}{% trans "Terms" %}{% trans "Created On" %}
+ + {{ bill.bill_number }} + + + {{ bill.get_bill_status_display }} + {{ bill.vendor }}{{ bill.get_terms_display }}{{ bill.created|date:"M j, Y" }}
+ +
+ {% if page_obj.paginator.num_pages > 1 %} +
+
{% include 'partials/pagination.html' %}
+
+ {% endif %} + {% else %} +
+ {% trans "No bills are associated with this expense yet." %} +
+ {% endif %} +
+
+ + + +{% endblock %} diff --git a/templates/items/expenses/expenses_list.html b/templates/items/expenses/expenses_list.html index f4e60d44..627fdc2f 100644 --- a/templates/items/expenses/expenses_list.html +++ b/templates/items/expenses/expenses_list.html @@ -51,9 +51,12 @@ href="{% url 'item_expense_update' request.dealer.slug expense.pk %}"> {% trans "Update" %} - + {% trans "Create Expense Bill" %} + + {% trans "Expense Detail" %} + {% endif %} diff --git a/templates/items/service/service_detail.html b/templates/items/service/service_detail.html new file mode 100644 index 00000000..18f71962 --- /dev/null +++ b/templates/items/service/service_detail.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} +{% load tenhal_tag %} + +{% block title %}{{ service.name|title }} - {% trans "My Company" %}{% endblock %} + +{% block content %} + +
+
+

{{ service.name|title }}

+

{{ service.description|default:"No description provided." }}

+
+
+ +
+
+ +
+
+ +

{% trans "Service Details" %}

+ + + + +
+ +
+
{% trans 'Service Name' %}
+

{{ service.name|capfirst }}

+
+ +
+
{% trans 'Price' %}
+

{{ service.price }}

+
+ +
+
{% trans 'Unit of Measure' %}
+

{{ service.get_uom_display }}

+
+ +
+
{% trans 'Tax Status' %}
+

{% if service.taxable %}{% trans 'Taxable' %}{% else %}{% trans 'Non Taxable' %}{% endif %}

+
+ +
+ +
+
+ +
+

{% trans "Total Revenue from this service" %}

+

{{ total_services_price }}

+
+ +
+
+ +{% endblock %} diff --git a/templates/items/service/service_list.html b/templates/items/service/service_list.html index 00ce8e3a..fcf5543f 100644 --- a/templates/items/service/service_list.html +++ b/templates/items/service/service_list.html @@ -52,9 +52,9 @@ href="{% url 'item_service_update' request.dealer.slug service.pk %}"> {% trans "Update" %} - {% comment %} - {% trans "Delete" %} - {% endcomment %} + + {% trans "service detail" %} + {% endif %} diff --git a/templates/ledger/reports/car_sale_report.html b/templates/ledger/reports/car_sale_report.html index e17979b0..88db5f35 100644 --- a/templates/ledger/reports/car_sale_report.html +++ b/templates/ledger/reports/car_sale_report.html @@ -57,7 +57,7 @@
-
65000.00 +