diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index d42e14feef..15d3bc6018 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -5,7 +5,7 @@
Users side :
------------
- # The version of Django has been upgraded to "4.1".
+ # The version of Django has been upgraded to "4.2".
# A visitor mode has been added; from any list-view, you can visit all the related detail-views the one after the other.
(button "Enter the exploration mode" in list-views).
# The link in the list-views selectors are now opened in other tabs automatically.
@@ -24,8 +24,9 @@
* Activities :
- A color field has been added to Status.
* Billing :
+ - The Product/Service lines can be reordered by drag'n drop.
+ - The mass-import can now import entities with their totals (a Product line is created).
- A color field has been added to Statuses.
- - Billing lines can be reordered if the document have the right permissions.
* Opportunities :
- A color field has been added to SalesPhase.
* Tickets :
diff --git a/creme/activities/forms/mass_import.py b/creme/activities/forms/mass_import.py
index 45f1442e9f..199b342ef3 100644
--- a/creme/activities/forms/mass_import.py
+++ b/creme/activities/forms/mass_import.py
@@ -36,6 +36,7 @@
ImportForm4CremeEntity,
)
from creme.creme_core.models import Relation, RelationType
+from creme.creme_core.utils import as_int
# from creme.creme_core.utils.dates import make_aware_dt
from creme.persons.models import Civility
@@ -56,12 +57,11 @@
MAX_RELATIONSHIPS = 5
-# TODO: in creme_core ?
-def as_int(value, default=0):
- try:
- return int(value)
- except (ValueError, TypeError):
- return default
+# def as_int(value, default=0):
+# try:
+# return int(value)
+# except (ValueError, TypeError):
+# return default
class RelatedExtractor:
diff --git a/creme/billing/forms/mass_import.py b/creme/billing/forms/mass_import.py
index fcb587f871..484e606940 100644
--- a/creme/billing/forms/mass_import.py
+++ b/creme/billing/forms/mass_import.py
@@ -1,6 +1,6 @@
################################################################################
# Creme is a free/open-source Customer Relationship Management software
-# Copyright (C) 2013-2022 Hybird
+# Copyright (C) 2013-2023 Hybird
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -16,23 +16,39 @@
# along with this program. If not, see .
################################################################################
+from __future__ import annotations
+
+from decimal import Decimal
+from functools import partial
+
+from django import forms
from django.conf import settings
-from django.forms.fields import BooleanField
+from django.core.exceptions import ValidationError
+from django.utils.formats import number_format
+from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from creme import persons
from creme.creme_core.forms.mass_import import (
+ BaseExtractorWidget,
EntityExtractorField,
ImportForm4CremeEntity,
)
-from creme.creme_core.utils import update_model_instance
+from creme.creme_core.models import Vat
+from creme.creme_core.utils import as_int, update_model_instance
+from .. import get_product_line_model
from ..utils import copy_or_create_address
Contact = persons.get_contact_model()
Organisation = persons.get_organisation_model()
+MODE_NO_TOTAL = 1
+MODE_COMPUTE_TOTAL_VAT = 2
+MODE_COMPUTE_TOTAL_NO_VAT = 3
+MODE_COMPUTE_VAT = 4
+
def _copy_or_update_address(source, dest, attr_name, addr_name):
change = True
@@ -51,8 +67,301 @@ def _copy_or_update_address(source, dest, attr_name, addr_name):
return change
+class _TotalsExtractor:
+ # TODO: what if ProductLine is not registered?
+ line_model = get_product_line_model()
+
+ def __init__(self, create_vat=False):
+ self.create_vat = create_vat
+ self._vat_validator = Vat._meta.get_field('value').formfield(localize=True).clean
+ self._amount_validator = self.line_model._meta.get_field(
+ 'unit_price'
+ ).formfield(localize=True).clean
+
+ def _extract_total_n_vat(self, line) -> tuple[Decimal, Decimal]:
+ """Return total without VAT and VAT.
+ @raise ValidationError.
+ """
+ raise NotImplementedError
+
+ def _clean_total_without_vat(self, value):
+ try:
+ return self._amount_validator(value)
+ except ValidationError as e:
+ raise ValidationError(
+ gettext('The total without VAT is invalid: {}').format(e.message)
+ )
+
+ def _clean_total_with_vat(self, value):
+ try:
+ return self._amount_validator(value)
+ except ValidationError as e:
+ raise ValidationError(
+ gettext('The total with VAT is invalid: {}').format(e.message)
+ )
+
+ def _clean_vat(self, value):
+ try:
+ cleaned_value = self._vat_validator(value)
+ except ValidationError as e:
+ raise ValidationError(
+ gettext('The VAT value is invalid: {}').format(e.message)
+ )
+
+ return self._get_or_create_vat(cleaned_value)
+
+ def _get_or_create_vat(self, value):
+ try:
+ vat = Vat.objects.get(value=value)
+ except Vat.DoesNotExist:
+ if not self.create_vat:
+ raise ValidationError(
+ gettext(
+ 'The VAT with value «{}» does not exist and cannot be created'
+ ).format(number_format(value)),
+ )
+
+ vat = Vat.objects.create(value=value)
+
+ return vat
+
+ def extract_value(self, line, user):
+ extracted = None
+ error_messages = []
+
+ try:
+ total_no_vat, vat = self._extract_total_n_vat(line)
+ except ValidationError as e:
+ error_messages.append(e.message)
+ else:
+ line_model = self.line_model
+ extracted = line_model(
+ user=user,
+ on_the_fly_item=gettext('N/A (import)'),
+ quantity=Decimal('1'),
+ discount=Decimal('0'),
+ discount_unit=line_model.Discount.PERCENT,
+ unit_price=total_no_vat,
+ vat_value=vat,
+ # unit=...,
+ # comment=...,
+ )
+ # extracted.full_clean() TODO?
+
+ return extracted, error_messages
+
+
+class TotalWithVatExtractor(_TotalsExtractor):
+ def __init__(self, *, total_no_vat_index, vat_index, create_vat=False):
+ super().__init__(create_vat=create_vat)
+ self._total_no_vat_index = total_no_vat_index - 1
+ self._vat_index = vat_index - 1
+
+ def _extract_total_n_vat(self, line):
+ vat = self._clean_vat(line[self._vat_index])
+ total_no_vat = self._clean_total_without_vat(line[self._total_no_vat_index])
+
+ return total_no_vat, vat
+
+
+class TotalWithoutVatExtractor(_TotalsExtractor):
+ def __init__(self, *, total_vat_index, vat_index, create_vat=False):
+ super().__init__(create_vat=create_vat)
+ self._total_vat_index = total_vat_index - 1
+ self._vat_index = vat_index - 1
+
+ def _extract_total_n_vat(self, line):
+ vat = self._clean_vat(line[self._vat_index])
+ total_vat = self._clean_total_with_vat(line[self._total_vat_index])
+
+ return (total_vat / (Decimal(1) + vat.value / Decimal(100))), vat
+
+
+class VatExtractor(_TotalsExtractor):
+ def __init__(self, *, total_no_vat_index, total_vat_index, create_vat=False):
+ super().__init__(create_vat=create_vat)
+ self._total_no_vat_index = total_no_vat_index - 1
+ self._total_vat_index = total_vat_index - 1
+
+ def _extract_total_n_vat(self, line):
+ total_no_vat = self._clean_total_without_vat(line[self._total_no_vat_index])
+ total_vat = self._clean_total_with_vat(line[self._total_vat_index])
+ vat_value = (total_vat / total_no_vat - Decimal(1)) * Decimal(100)
+
+ return total_no_vat, self._get_or_create_vat(vat_value)
+
+
+class TotalsExtractorWidget(BaseExtractorWidget):
+ template_name = 'billing/forms/widgets/totals-extractor.html'
+
+ def get_context(self, name, value, attrs):
+ value = value or {}
+ context = super().get_context(name=name, value=value, attrs=attrs)
+
+ widget_cxt = context['widget']
+ widget_cxt['MODE_NO_TOTAL'] = MODE_NO_TOTAL
+ widget_cxt['MODE_COMPUTE_TOTAL_VAT'] = MODE_COMPUTE_TOTAL_VAT
+ widget_cxt['MODE_COMPUTE_TOTAL_NO_VAT'] = MODE_COMPUTE_TOTAL_NO_VAT
+ widget_cxt['MODE_COMPUTE_VAT'] = MODE_COMPUTE_VAT
+ widget_cxt['mode'] = value.get('mode', MODE_NO_TOTAL)
+
+ id_attr = widget_cxt['attrs']['id']
+
+ def column_select_context(name_fmt, selected_key):
+ return self.column_select.get_context(
+ name=name_fmt.format(name),
+ value=value.get(selected_key),
+ attrs={
+ 'id': name_fmt.format(id_attr),
+ 'class': 'csv_col_select',
+ },
+ )['widget']
+
+ widget_cxt['totalnovat_column_select'] = column_select_context(
+ name_fmt='{}_total_no_vat_colselect',
+ selected_key='total_no_vat_column_index',
+ )
+ widget_cxt['totalvat_column_select'] = column_select_context(
+ name_fmt='{}_total_vat_colselect',
+ selected_key='total_vat_column_index',
+ )
+ widget_cxt['vat_column_select'] = column_select_context(
+ name_fmt='{}_vat_colselect',
+ selected_key='vat_column_index',
+ )
+
+ return context
+
+ def value_from_datadict(self, data, files, name):
+ get = data.get
+
+ return {
+ 'mode': as_int(get(f'{name}_mode'), 1),
+
+ 'total_no_vat_column_index': as_int(get(f'{name}_total_no_vat_colselect')),
+ 'total_vat_column_index': as_int(get(f'{name}_total_vat_colselect')),
+ 'vat_column_index': as_int(get(f'{name}_vat_colselect')),
+ }
+
+
+class TotalsExtractorField(forms.Field):
+ default_error_messages = {
+ 'column_required': _('You have to select a column for «%(field)s».'),
+ }
+ index_verbose_names = {
+ 'total_no_vat_column_index': _('Total without VAT'),
+ 'total_vat_column_index': _('Total with VAT'),
+ 'vat_column_index': _('VAT'),
+ }
+ extractors = {
+ MODE_NO_TOTAL: (None, {}), # TODO: EmptyExtractor??
+ MODE_COMPUTE_TOTAL_VAT: (
+ TotalWithVatExtractor,
+ {
+ 'total_no_vat_index': 'total_no_vat_column_index',
+ 'vat_index': 'vat_column_index',
+ },
+ ),
+ MODE_COMPUTE_TOTAL_NO_VAT: (
+ TotalWithoutVatExtractor,
+ {
+ 'total_vat_index': 'total_vat_column_index',
+ 'vat_index': 'vat_column_index',
+ },
+ ),
+ MODE_COMPUTE_VAT: (
+ VatExtractor,
+ {
+ 'total_vat_index': 'total_vat_column_index',
+ 'total_no_vat_index': 'total_no_vat_column_index',
+ },
+ )
+ }
+
+ def __init__(self, *, choices, **kwargs):
+ super().__init__(widget=TotalsExtractorWidget, **kwargs)
+ self._allowed_indexes = {c[0] for c in choices}
+
+ self.user = None
+ self.widget.choices = choices
+
+ @property
+ def can_create_vat(self):
+ user = self._user
+ return user is not None and user.has_perm_to_admin('creme_core')
+
+ @property
+ def user(self):
+ return self._user
+
+ @user.setter
+ def user(self, user):
+ self._user = user
+ # NB: probably not great to override help_text, but this field should
+ # not be used elsewhere...
+ self.help_text = (
+ ''
+ if self.can_create_vat else
+ _('Beware: you are not allowed to create new VAT values')
+ )
+
+ def _clean_index(self, value, key):
+ try:
+ index = int(value[key])
+ except KeyError as e:
+ raise ValidationError(f'Index "{key}" is required') from e
+ except ValueError as e:
+ raise ValidationError(f'Index "{key}" should be an integer') from e
+
+ if index not in self._allowed_indexes:
+ raise ValidationError('Invalid index')
+
+ if not index:
+ raise ValidationError(
+ self.error_messages['column_required'],
+ code='column_required',
+ params={'field': self.index_verbose_names.get(key, '??')},
+ )
+
+ return index
+
+ def _clean_mode(self, value):
+ try:
+ mode = int(value['mode'])
+ except KeyError as e:
+ if self.required:
+ raise ValidationError('Mode is required') from e
+ mode = MODE_NO_TOTAL
+ except ValueError as e:
+ raise ValidationError('Invalid value for mode') from e
+
+ return mode
+
+ def clean(self, value):
+ mode = self._clean_mode(value)
+
+ try:
+ extractor_cls, args_descriptors = self.extractors[mode]
+ except KeyError:
+ raise ValidationError('Invalid mode')
+
+ if extractor_cls is None:
+ return None
+
+ clean_index = partial(self._clean_index, value)
+
+ return extractor_cls(
+ create_vat=self.can_create_vat,
+ **{
+ arg_name: clean_index(col_name)
+ for arg_name, col_name in args_descriptors.items()
+ },
+ )
+
+
def get_import_form_builder(header_dict, choices):
- class InvoiceMassImportForm(ImportForm4CremeEntity):
+ # class InvoiceMassImportForm(ImportForm4CremeEntity):
+ class BillingMassImportForm(ImportForm4CremeEntity):
source = EntityExtractorField(
models_info=[(Organisation, 'name')],
choices=choices,
@@ -66,15 +375,35 @@ class InvoiceMassImportForm(ImportForm4CremeEntity):
choices=choices, label=pgettext_lazy('billing', 'Target'),
)
- override_billing_addr = BooleanField(
+ override_billing_addr = forms.BooleanField(
label=_('Update the billing address'), required=False,
help_text=_('In update mode, update the billing address from the target.'),
)
- override_shipping_addr = BooleanField(
+ override_shipping_addr = forms.BooleanField(
label=_('Update the shipping address'), required=False,
help_text=_('In update mode, update the shipping address from the target.'),
)
+ totals = TotalsExtractorField(choices=choices, label=_('Totals & VAT'))
+
+ class Meta:
+ exclude = ('discount',) # NB: if uncommented, should be used in totals computing
+
+ blocks = ImportForm4CremeEntity.blocks.new(
+ {
+ 'id': 'organisations_and_addresses',
+ 'label': _('Organisations'),
+ 'fields': [
+ 'source', 'target', 'override_billing_addr', 'override_shipping_addr',
+ ],
+ },
+ {
+ 'id': 'totals',
+ 'label': _('Totals & VAT'),
+ 'fields': ['totals'],
+ },
+ )
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -88,6 +417,16 @@ def __init__(self, *args, **kwargs):
models=model._meta.verbose_name_plural,
)
+ def clean_totals(self):
+ extractor = self.cleaned_data['totals']
+
+ if extractor is not None and self.cleaned_data.get('key_fields'):
+ raise ValidationError(
+ gettext('You cannot compute totals in update mode.')
+ )
+
+ return extractor
+
def _pre_instance_save(self, instance, line):
cdata = self.cleaned_data
append_error = self.append_error
@@ -102,9 +441,20 @@ def _pre_instance_save(self, instance, line):
def _post_instance_creation(self, instance, line, updated):
super()._post_instance_creation(instance, line, updated)
+ cdata = self.cleaned_data
+
+ line_extractor = cdata['totals']
+ if line_extractor is not None:
+ line, errors = line_extractor.extract_value(line, self.user)
+
+ if line:
+ line.related_document = instance
+ line.save()
+
+ for error in errors:
+ self.append_error(error)
if updated:
- cdata = self.cleaned_data
target = instance.target
b_change = s_change = False
@@ -121,4 +471,5 @@ def _post_instance_creation(self, instance, line, updated):
if b_change or s_change:
instance.save()
- return InvoiceMassImportForm
+ # return InvoiceMassImportForm
+ return BillingMassImportForm
diff --git a/creme/billing/locale/fr/LC_MESSAGES/django.po b/creme/billing/locale/fr/LC_MESSAGES/django.po
index 19c6a23028..a124a6cde2 100644
--- a/creme/billing/locale/fr/LC_MESSAGES/django.po
+++ b/creme/billing/locale/fr/LC_MESSAGES/django.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Creme Billing 2.5\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-03-07 14:25+0100\n"
+"POT-Creation-Date: 2023-04-26 15:24+0200\n"
"Last-Translator: Hybird \n"
"Language: fr\n"
"MIME-Version: 1.0\n"
@@ -389,6 +389,37 @@ msgstr ""
"Vous n'avez pas la permission d'à ajouter cet élément au catalogue car il ne "
"s'agit pas d'une ligne à la volée"
+msgid "The total without VAT is invalid: {}"
+msgstr "Le total HT est invalide : {}"
+
+msgid "The total with VAT is invalid: {}"
+msgstr "Le total TTC est invalide : {}"
+
+msgid "The VAT value is invalid: {}"
+msgstr "La valeur de TVA est invalide : {}"
+
+msgid "The VAT with value «{}» does not exist and cannot be created"
+msgstr "La TVA avec la valeur «{}» n'existe pas et ne peut pas être créée"
+
+msgid "N/A (import)"
+msgstr "NC (import)"
+
+#, python-format
+msgid "You have to select a column for «%(field)s»."
+msgstr "Vous devez sélectionner une colonne pour «%(field)s»."
+
+msgid "Total without VAT"
+msgstr "Total HT"
+
+msgid "Total with VAT"
+msgstr "Total TTC"
+
+msgid "VAT"
+msgstr "TVA"
+
+msgid "Beware: you are not allowed to create new VAT values"
+msgstr "Vous n'avez pas la permission de créer de nouvelles valeurs de TVA"
+
msgid "Update the billing address"
msgstr "Mettre à jour l'adresse de facturation"
@@ -403,6 +434,9 @@ msgid "In update mode, update the shipping address from the target."
msgstr ""
"En mode mise-à-jour, mettre à jour l'adresse de livraison depuis la cible."
+msgid "Totals & VAT"
+msgstr "Totaux & TVA"
+
#, python-brace-format
msgid ""
"If you chose an organisation managed by {software} as source organisation, a "
@@ -411,6 +445,9 @@ msgstr ""
"Si vous choisissez une société gérée par {software} en tant qu'émettrice, un "
"nombre sera automatiquement généré pour les «{models}» créé(e)s."
+msgid "You cannot compute totals in update mode."
+msgstr "Vous ne pouvez pas calculer les totaux en mode mise-à-jour."
+
msgid "Billing address"
msgstr "Adresse de facturation"
@@ -472,12 +509,6 @@ msgstr "Devise"
msgid "Comment"
msgstr "Remarques"
-msgid "Total with VAT"
-msgstr "Total avec TVA"
-
-msgid "Total without VAT"
-msgstr "Total HT"
-
msgid "Additional Information"
msgstr "Informations supplémentaires"
@@ -556,9 +587,6 @@ msgstr "Unité"
msgid "Discount Unit"
msgstr "Unité de remise"
-msgid "VAT"
-msgstr "TVA"
-
msgid "Create a line"
msgstr "Créer une ligne"
@@ -1186,6 +1214,39 @@ msgstr "Vous n'avez pas la permission de relier cette fiche"
msgid "You are not allowed to change this entity"
msgstr "Vous n'avez pas la permission de modifier cette fiche"
+msgid "The totals are just set to 0"
+msgstr "Les totaux sont jste mis à 0"
+
+msgid ""
+"Total with VAT is computed from total without VAT & VAT (one product line is "
+"created to get the right totals)"
+msgstr ""
+"Le total TTC est calculé depuis le total HT & la TVA (une line produit est "
+"créée pour obtenir les bons totaux)"
+
+msgid ""
+"Total without VAT is computed from total with VAT & VAT (one product line is "
+"created to get the right totals)"
+msgstr ""
+"Le total HT est calculé depuis le total TTC & la TVA (une line produit est "
+"créée pour obtenir les bons totaux)"
+
+msgid ""
+"VAT is computed from totals (one product line is created to get the right "
+"totals)"
+msgstr ""
+"La TVA est calculée depuis les totaux (une line produit est "
+"créée pour obtenir les bons totaux)"
+
+msgid "Total without VAT: "
+msgstr "Total HT : "
+
+msgid "Total with VAT: "
+msgstr "Total TTC : "
+
+msgid "VAT: "
+msgstr "TVA : "
+
#, python-format
msgid "Errors on the line «%(item)s»:"
msgstr "Erreurs sur la ligne «%(item)s» :"
diff --git a/creme/billing/static/chantilly/billing/css/billing.css b/creme/billing/static/chantilly/billing/css/billing.css
index 76797d858a..cbe7c9f9fb 100644
--- a/creme/billing/static/chantilly/billing/css/billing.css
+++ b/creme/billing/static/chantilly/billing/css/billing.css
@@ -460,4 +460,25 @@ div.billing-exporter-select > div > label {
font-style: italic;
}
+/* Mass-import forms */
+
+.billing-mass_import-totals {
+ display: flex;
+}
+
+.billing-mass_import-totals .billing-mass_import-totals-columns {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ margin-left: 20px;
+ padding-left: 20px;
+
+ border-left: solid 1px #d9d9d9;
+}
+
+.billing-mass_import-totals .billing-mass_import-totals-columns li.is-disabled {
+ color: #c4d1d7;
+}
+
/* Forms - end */
diff --git a/creme/billing/static/icecream/billing/css/billing.css b/creme/billing/static/icecream/billing/css/billing.css
index 3e8171e23f..6e0fb5595c 100644
--- a/creme/billing/static/icecream/billing/css/billing.css
+++ b/creme/billing/static/icecream/billing/css/billing.css
@@ -456,4 +456,25 @@ div.billing-exporter-select > div > label {
font-style: italic;
}
+/* Mass-import forms */
+
+.billing-mass_import-totals {
+ display: flex;
+}
+
+.billing-mass_import-totals .billing-mass_import-totals-columns {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ margin-left: 20px;
+ padding-left: 20px;
+
+ border-left: solid 1px #d9d9d9;
+}
+
+.billing-mass_import-totals .billing-mass_import-totals-columns li.is-disabled {
+ color: #b1b1b1;
+}
+
/* Forms - end */
\ No newline at end of file
diff --git a/creme/billing/templates/billing/forms/widgets/totals-extractor.html b/creme/billing/templates/billing/forms/widgets/totals-extractor.html
new file mode 100644
index 0000000000..ef384bea9b
--- /dev/null
+++ b/creme/billing/templates/billing/forms/widgets/totals-extractor.html
@@ -0,0 +1,79 @@
+{% load i18n %}
+{% with name=widget.name id=widget.attrs.id mode=widget.mode %}
+
+
+
+ -
+ {% with widget=widget.totalnovat_column_select %}
+
+ {% endwith %}
+
+ -
+ {% with widget=widget.totalvat_column_select %}
+
+ {% endwith %}
+
+ -
+ {% with widget=widget.vat_column_select %}
+
+ {% endwith %}
+
+
+
+
+{% endwith %}
\ No newline at end of file
diff --git a/creme/billing/tests/base.py b/creme/billing/tests/base.py
index 95fb3bf09d..e3f20d2004 100644
--- a/creme/billing/tests/base.py
+++ b/creme/billing/tests/base.py
@@ -5,10 +5,11 @@
from django.test.utils import override_settings
from django.urls import reverse
+from django.utils.formats import number_format
from django.utils.translation import gettext as _
from creme import billing
-from creme.creme_core.models import Currency
+from creme.creme_core.models import Currency, Vat
from creme.creme_core.tests.base import CremeTestCase
from creme.creme_core.tests.views import base
from creme.persons import (
@@ -312,7 +313,8 @@ class _BillingTestCase(_BillingTestCaseMixin,
base.MassImportBaseTestCaseMixin,
CremeTestCase):
@override_settings(SOFTWARE_LABEL='My CRM')
- def _aux_test_csv_import(self, model, status_model, update=False, number_help_text=True):
+ def _aux_test_csv_import_no_total(self, model, status_model,
+ update=False, number_help_text=True):
count = model.objects.count()
create_orga = partial(Organisation.objects.create, user=self.user)
create_contact = partial(Contact.objects.create, user=self.user)
@@ -446,6 +448,8 @@ def _aux_test_csv_import(self, model, status_model, update=False, number_help_te
'description_colselect': 0,
'buyers_order_number_colselect': 0, # Invoice only...
+ 'totals_mode': '1', # No totals
+
# 'property_types',
# 'fixed_relations',
# 'dyn_relations',
@@ -512,6 +516,10 @@ def _aux_test_csv_import(self, model, status_model, update=False, number_help_te
self.assertIsNone(billing_doc.payment_terms)
# self.assertIsNone(billing_doc.payment_type) #only in invoice... TODO lambda ??
+ self.assertEqual(Decimal('0.0'), billing_doc.total_vat)
+ self.assertEqual(Decimal('0.0'), billing_doc.total_no_vat)
+ self.assertFalse([*billing_doc.iter_all_lines()])
+
# Billing_doc1
billing_doc1 = billing_docs[0]
imp_source1 = billing_doc1.source
@@ -553,6 +561,121 @@ def _aux_test_csv_import(self, model, status_model, update=False, number_help_te
target4 = self.get_object_or_fail(Contact, last_name=target4_last_name)
self.assertEqual(imp_target4.get_real_entity(), target4)
+ def _aux_test_csv_import_total_no_vat_n_vat(self, model, status_model):
+ count = model.objects.count()
+
+ create_orga = partial(Organisation.objects.create, user=self.user)
+ src = create_orga(name='Nerv')
+ tgt = create_orga(name='Acme')
+
+ vat1 = 15
+ vat_obj1 = Vat.objects.get_or_create(value=vat1)[0]
+ vat2 = '12.5'
+ self.assertFalse(Vat.objects.filter(value=vat2).exists())
+ vat_count = Vat.objects.count()
+
+ total_no_vat1 = 100
+ total_no_vat2 = '200.5'
+
+ lines = [
+ ('Bill #1', src.name, tgt.name, number_format(total_no_vat1), number_format(vat1)),
+ ('Bill #2', src.name, tgt.name, number_format(total_no_vat2), number_format(vat2)),
+ ('Bill #3', src.name, tgt.name, '300', 'nan'),
+ ]
+ doc = self._build_csv_doc(lines)
+ response = self.client.post(
+ self._build_import_url(model),
+ follow=True,
+ data={
+ 'step': 1,
+ 'document': doc.id,
+ # has_header
+
+ 'user': self.user.id,
+ # 'key_fields': ['name'] if update else [],
+
+ 'name_colselect': 1,
+ 'number_colselect': 0,
+
+ 'issuing_date_colselect': 0,
+ 'expiration_date_colselect': 0,
+
+ 'status_colselect': 0,
+ 'status_defval': status_model.objects.all()[0].pk,
+
+ 'discount_colselect': 0,
+ 'discount_defval': '0',
+
+ 'currency_colselect': 0,
+ 'currency_defval': Currency.objects.all()[0].pk,
+
+ 'acceptation_date_colselect': 0,
+
+ 'comment_colselect': 0,
+ 'additional_info_colselect': 0,
+ 'payment_terms_colselect': 0,
+ 'payment_type_colselect': 0,
+
+ 'description_colselect': 0,
+ 'buyers_order_number_colselect': 0, # Invoice only...
+
+ 'source_persons_organisation_colselect': 2,
+ 'target_persons_organisation_colselect': 3,
+ 'target_persons_contact_colselect': 0,
+
+ 'totals_mode': '2', # Compute total with VAT
+ 'totals_total_no_vat_colselect': 4,
+ 'totals_vat_colselect': 5,
+
+ # 'property_types',
+ # 'fixed_relations',
+ # 'dyn_relations',
+ },
+ )
+ self.assertNoFormError(response)
+
+ job = self._execute_job(response)
+ self.assertEqual(count + len(lines), model.objects.count())
+
+ billing_doc1 = self.get_object_or_fail(model, name=lines[0][0])
+ self.assertEqual(Decimal('0.0'), billing_doc1.discount)
+ self.assertEqual(Decimal(total_no_vat1), billing_doc1.total_no_vat)
+ self.assertEqual(Decimal('115.00'), billing_doc1.total_vat)
+
+ line1 = self.get_alone_element(billing_doc1.iter_all_lines())
+ self.assertIsInstance(line1, ProductLine)
+ self.assertEqual(_('N/A (import)'), line1.on_the_fly_item)
+ self.assertFalse(line1.comment)
+ self.assertEqual(1, line1.quantity)
+ self.assertEqual(total_no_vat1, line1.unit_price)
+ self.assertFalse(line1.unit)
+ self.assertEqual(0, line1.discount)
+ self.assertEqual(ProductLine.Discount.PERCENT, line1.discount_unit)
+ self.assertEqual(vat_obj1, line1.vat_value)
+
+ billing_doc2 = self.get_object_or_fail(model, name=lines[1][0])
+ self.assertEqual(Decimal(total_no_vat2), billing_doc2.total_no_vat)
+ self.assertEqual(Decimal('225.56'), billing_doc2.total_vat)
+
+ self.assertEqual(vat_count + 1, Vat.objects.count())
+ line2 = self.get_alone_element(billing_doc2.iter_all_lines())
+ self.assertEqual(Decimal(total_no_vat2), line2.unit_price)
+ self.assertEqual(Decimal(vat2), line2.vat_value.value)
+
+ billing_doc3 = self.get_object_or_fail(model, name=lines[2][0])
+ self.assertEqual(Decimal('0'), billing_doc3.total_no_vat)
+ self.assertEqual(Decimal('0'), billing_doc3.total_vat)
+ self.assertFalse([*billing_doc3.iter_all_lines()])
+
+ results = self._get_job_results(job)
+ self.assertEqual(len(lines), len(results))
+
+ jr_error3 = self.get_alone_element(r for r in results if r.entity_id == billing_doc3.id)
+ self.assertListEqual(
+ [_('The VAT value is invalid: {}').format(_('Enter a number.'))],
+ jr_error3.messages,
+ )
+
def _aux_test_csv_import_update(self, model, status_model,
target_billing_address=True,
override_billing_addr=False,
diff --git a/creme/billing/tests/test_forms.py b/creme/billing/tests/test_forms.py
new file mode 100644
index 0000000000..76aa1eba14
--- /dev/null
+++ b/creme/billing/tests/test_forms.py
@@ -0,0 +1,603 @@
+from decimal import Decimal
+
+from django.core.exceptions import ValidationError
+from django.utils.formats import number_format
+from django.utils.translation import gettext as _
+from parameterized import parameterized
+
+from creme.billing.forms.mass_import import (
+ TotalsExtractorField,
+ TotalWithoutVatExtractor,
+ TotalWithVatExtractor,
+ VatExtractor,
+)
+from creme.billing.tests.base import ProductLine
+from creme.creme_core.models import UserRole, Vat
+from creme.creme_core.tests.base import CremeTestCase
+
+MODE_NO_TOTAL = '1'
+MODE_COMPUTE_TOTAL_VAT = '2'
+MODE_COMPUTE_TOTAL_NO_VAT = '3'
+MODE_COMPUTE_VAT = '4'
+
+
+class TotalsExtractorFieldTestCase(CremeTestCase):
+ choices = [(0, 'No column'), (1, 'Column #1'), (2, 'Column #2')]
+
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cls.user = cls.create_user()
+
+ def test_extractor_total_with_vat(self):
+ extractor = TotalWithVatExtractor(total_no_vat_index=1, vat_index=2)
+ self.assertEqual(0, extractor._total_no_vat_index)
+ self.assertEqual(1, extractor._vat_index)
+
+ vat = Vat.objects.get_or_create(value=Decimal('10.0'))[0]
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), number_format(vat.value)],
+ )
+
+ self.assertEqual([], errors)
+
+ self.assertIsInstance(pline, ProductLine)
+ self.assertIsNone(pline.pk)
+ self.assertEqual(self.user, pline.user)
+ self.assertEqual(_('N/A (import)'), pline.on_the_fly_item)
+ self.assertEqual(Decimal('1'), pline.quantity)
+ self.assertEqual(Decimal('0'), pline.discount)
+ self.assertEqual(ProductLine.Discount.PERCENT, pline.discount_unit)
+ self.assertEqual(Decimal('100.0'), pline.unit_price)
+ self.assertEqual(vat, pline.vat_value)
+
+ def test_extractor_total_with_vat_errors(self):
+ extractor = TotalWithVatExtractor(
+ total_no_vat_index=1, vat_index=2, create_vat=True,
+ )
+
+ # Empty VAT ---
+ with self.assertNoException():
+ pline1, errors1 = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), ''],
+ )
+ self.assertIsNone(pline1)
+ self.assertListEqual(
+ [_('The VAT value is invalid: {}').format(_('This field is required.'))],
+ errors1,
+ )
+
+ # Invalid VAT ---
+ with self.assertNoException():
+ pline2, errors2 = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), 'Nan'],
+ )
+ self.assertIsNone(pline2)
+ self.assertListEqual(
+ [_('The VAT value is invalid: {}').format(_('Enter a number.'))],
+ errors2,
+ )
+
+ # Empty Total ---
+ with self.assertNoException():
+ pline3, errors3 = extractor.extract_value(
+ user=self.user,
+ line=['', number_format('10.0')],
+ )
+ self.assertIsNone(pline3)
+ self.assertListEqual(
+ [_('The total without VAT is invalid: {}').format(_('This field is required.'))],
+ errors3,
+ )
+
+ # Invalid Total ---
+ with self.assertNoException():
+ pline4, errors4 = extractor.extract_value(
+ user=self.user,
+ line=['Nan', number_format('15.0')],
+ )
+ self.assertIsNone(pline4)
+ self.assertListEqual(
+ [_('The total without VAT is invalid: {}').format(_('Enter a number.'))],
+ errors4,
+ )
+
+ def test_extractor_total_without_vat(self):
+ extractor = TotalWithoutVatExtractor(total_vat_index=1, vat_index=2)
+ self.assertEqual(0, extractor._total_vat_index)
+ self.assertEqual(1, extractor._vat_index)
+
+ vat = Vat.objects.get_or_create(value=Decimal('10.5'))[0]
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('221.0'), number_format(vat.value)],
+ )
+
+ self.assertEqual([], errors)
+
+ self.assertIsInstance(pline, ProductLine)
+ self.assertIsNone(pline.pk)
+ self.assertEqual(self.user, pline.user)
+ self.assertEqual(_('N/A (import)'), pline.on_the_fly_item)
+ self.assertEqual(Decimal('1'), pline.quantity)
+ self.assertEqual(Decimal('0'), pline.discount)
+ self.assertEqual(ProductLine.Discount.PERCENT, pline.discount_unit)
+ self.assertEqual(Decimal('200.0'), pline.unit_price)
+ self.assertEqual(vat, pline.vat_value)
+
+ def test_extractor_total_without_vat_errors(self):
+ extractor = TotalWithoutVatExtractor(
+ total_vat_index=1, vat_index=2, create_vat=True,
+ )
+
+ # Empty VAT ---
+ with self.assertNoException():
+ pline1, errors1 = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), ''],
+ )
+ self.assertIsNone(pline1)
+ self.assertListEqual(
+ [_('The VAT value is invalid: {}').format(_('This field is required.'))],
+ errors1,
+ )
+
+ # Invalid VAT ---
+ with self.assertNoException():
+ pline2, errors2 = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), 'Nan'],
+ )
+ self.assertIsNone(pline2)
+ self.assertListEqual(
+ [_('The VAT value is invalid: {}').format(_('Enter a number.'))],
+ errors2,
+ )
+
+ # Empty Total ---
+ with self.assertNoException():
+ pline3, errors3 = extractor.extract_value(
+ user=self.user,
+ line=['', number_format('10.0')],
+ )
+ self.assertIsNone(pline3)
+ self.assertListEqual(
+ [_('The total with VAT is invalid: {}').format(_('This field is required.'))],
+ errors3,
+ )
+
+ # Invalid Total ---
+ with self.assertNoException():
+ pline4, errors4 = extractor.extract_value(
+ user=self.user,
+ line=['Nan', number_format('15.0')],
+ )
+ self.assertIsNone(pline4)
+ self.assertListEqual(
+ [_('The total with VAT is invalid: {}').format(_('Enter a number.'))],
+ errors4,
+ )
+
+ def test_extractor_vat(self):
+ extractor = VatExtractor(total_no_vat_index=1, total_vat_index=2)
+ self.assertEqual(0, extractor._total_no_vat_index)
+ self.assertEqual(1, extractor._total_vat_index)
+
+ vat = Vat.objects.get_or_create(value=Decimal('10'))[0]
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('300.0'), number_format('330.0')],
+ )
+
+ self.assertEqual([], errors)
+
+ self.assertIsInstance(pline, ProductLine)
+ self.assertIsNone(pline.pk)
+ self.assertEqual(self.user, pline.user)
+ self.assertEqual(_('N/A (import)'), pline.on_the_fly_item)
+ self.assertEqual(Decimal('1'), pline.quantity)
+ self.assertEqual(Decimal('0'), pline.discount)
+ self.assertEqual(ProductLine.Discount.PERCENT, pline.discount_unit)
+ self.assertEqual(Decimal('300.0'), pline.unit_price)
+ self.assertEqual(vat, pline.vat_value)
+
+ def test_extractor_vat_errors(self):
+ extractor = VatExtractor(total_no_vat_index=1, total_vat_index=2)
+
+ # Invalid Total without VAT ---
+ with self.assertNoException():
+ pline1, errors1 = extractor.extract_value(
+ user=self.user,
+ line=['Nan', number_format('15.0')],
+ )
+ self.assertIsNone(pline1)
+ self.assertListEqual(
+ [_('The total without VAT is invalid: {}').format(_('Enter a number.'))],
+ errors1,
+ )
+
+ # Invalid Total with VAT ---
+ with self.assertNoException():
+ pline2, errors2 = extractor.extract_value(
+ user=self.user,
+ line=[number_format('15.0'), 'Nan'],
+ )
+ self.assertIsNone(pline2)
+ self.assertListEqual(
+ [_('The total with VAT is invalid: {}').format(_('Enter a number.'))],
+ errors2,
+ )
+
+ def test_extractor_extractor_total_with_vat__vat_creation01(self):
+ extractor = TotalWithVatExtractor(
+ total_no_vat_index=1, vat_index=2,
+ create_vat=True,
+ )
+ self.assertTrue(extractor.create_vat)
+
+ vat = '10.0'
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), number_format(vat)],
+ )
+
+ self.assertEqual([], errors)
+ self.assertEqual(Decimal('100.0'), pline.unit_price)
+
+ vat_obj = self.get_object_or_fail(Vat, value=Decimal(vat))
+ self.assertEqual(vat_obj, pline.vat_value)
+
+ def test_extractor_extractor_total_with_vat__vat_creation02(self):
+ "Not allowed."
+ extractor = TotalWithVatExtractor(total_no_vat_index=1, vat_index=2)
+ self.assertFalse(extractor.create_vat)
+
+ vat = '10.0'
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), number_format(vat)],
+ )
+
+ self.assertIsNone(pline)
+ self.assertEqual(
+ [
+ _('The VAT with value «{}» does not exist and cannot be created').format(
+ number_format(vat),
+ ),
+ ],
+ errors,
+ )
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ def test_extractor_extractor_total_without_vat__vat_creation01(self):
+ extractor = TotalWithoutVatExtractor(
+ total_vat_index=1, vat_index=2,
+ create_vat=True,
+ )
+ self.assertTrue(extractor.create_vat)
+
+ vat = '10.0'
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('110.0'), number_format(vat)],
+ )
+
+ self.assertEqual([], errors)
+ self.assertEqual(Decimal('100.0'), pline.unit_price)
+
+ vat_obj = self.get_object_or_fail(Vat, value=Decimal(vat))
+ self.assertEqual(vat_obj, pline.vat_value)
+
+ def test_extractor_extractor_total_without_vat__vat_creation02(self):
+ "Not allowed."
+ extractor = TotalWithoutVatExtractor(total_vat_index=1, vat_index=2)
+ self.assertFalse(extractor.create_vat)
+
+ vat = '10.0'
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('110.0'), number_format(vat)],
+ )
+
+ self.assertIsNone(pline)
+ self.assertEqual(
+ [
+ _('The VAT with value «{}» does not exist and cannot be created').format(
+ number_format(vat),
+ ),
+ ],
+ errors,
+ )
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ def test_extractor_extractor_vat__vat_creation01(self):
+ extractor = VatExtractor(
+ total_no_vat_index=1, total_vat_index=2,
+ create_vat=True,
+ )
+ self.assertTrue(extractor.create_vat)
+
+ vat = '10.0'
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), number_format('110.0')],
+ )
+
+ self.assertEqual([], errors)
+ self.assertEqual(Decimal('100.0'), pline.unit_price)
+
+ vat_obj = self.get_object_or_fail(Vat, value=Decimal(vat))
+ self.assertEqual(vat_obj, pline.vat_value)
+
+ def test_extractor_extractor_vat__vat_creation02(self):
+ "Not allowed."
+ extractor = VatExtractor(total_no_vat_index=1, total_vat_index=2)
+ self.assertFalse(extractor.create_vat)
+
+ vat = '10.0'
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ with self.assertNoException():
+ pline, errors = extractor.extract_value(
+ user=self.user,
+ line=[number_format('100.0'), number_format('110.00')],
+ )
+
+ self.assertIsNone(pline)
+ self.assertEqual(
+ [
+ _('The VAT with value «{}» does not exist and cannot be created').format(
+ number_format(vat),
+ ),
+ ],
+ errors,
+ )
+ self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists())
+
+ def test_field_no_total(self):
+ field = TotalsExtractorField(choices=self.choices)
+ self.assertIsNone(field.clean({'mode': MODE_NO_TOTAL}))
+
+ def test_field_total_no_vat_n_vat(self):
+ field1 = TotalsExtractorField(choices=self.choices)
+
+ with self.assertNoException():
+ extractor1 = field1.clean({
+ 'mode': MODE_COMPUTE_TOTAL_VAT,
+ 'total_no_vat_column_index': 1,
+ 'vat_column_index': 2,
+ })
+
+ self.assertIsInstance(extractor1, TotalWithVatExtractor)
+ self.assertEqual(0, extractor1._total_no_vat_index)
+ self.assertEqual(1, extractor1._vat_index)
+
+ # Other indexes ---
+ field2 = TotalsExtractorField(choices=self.choices)
+ extractor2 = field2.clean({
+ 'mode': MODE_COMPUTE_TOTAL_VAT,
+ 'total_no_vat_column_index': 2,
+ 'vat_column_index': 1,
+ })
+ self.assertEqual(1, extractor2._total_no_vat_index)
+ self.assertEqual(0, extractor2._vat_index)
+
+ def test_field_total_vat_n_vat(self):
+ field1 = TotalsExtractorField(choices=self.choices)
+
+ with self.assertNoException():
+ extractor1 = field1.clean({
+ 'mode': MODE_COMPUTE_TOTAL_NO_VAT,
+ 'total_vat_column_index': 1,
+ 'vat_column_index': 2,
+ })
+
+ self.assertIsInstance(extractor1, TotalWithoutVatExtractor)
+ self.assertEqual(0, extractor1._total_vat_index)
+ self.assertEqual(1, extractor1._vat_index)
+
+ # Other indexes ---
+ field2 = TotalsExtractorField(choices=self.choices)
+ extractor2 = field2.clean({
+ 'mode': MODE_COMPUTE_TOTAL_NO_VAT,
+ 'total_vat_column_index': 2,
+ 'vat_column_index': 1,
+ })
+ self.assertEqual(1, extractor2._total_vat_index)
+ self.assertEqual(0, extractor2._vat_index)
+
+ def test_field_totals(self):
+ field1 = TotalsExtractorField(choices=self.choices)
+
+ with self.assertNoException():
+ extractor1 = field1.clean({
+ 'mode': MODE_COMPUTE_VAT,
+ 'total_vat_column_index': 1,
+ 'total_no_vat_column_index': 2,
+ })
+
+ self.assertIsInstance(extractor1, VatExtractor)
+ self.assertEqual(0, extractor1._total_vat_index)
+ self.assertEqual(1, extractor1._total_no_vat_index)
+
+ # Other indexes ---
+ field2 = TotalsExtractorField(choices=self.choices)
+ extractor2 = field2.clean({
+ 'mode': MODE_COMPUTE_VAT,
+ 'total_vat_column_index': 2,
+ 'total_no_vat_column_index': 1,
+ })
+ self.assertEqual(1, extractor2._total_vat_index)
+ self.assertEqual(0, extractor2._total_no_vat_index)
+
+ def test_field_invalid_mode(self):
+ field = TotalsExtractorField(choices=self.choices)
+
+ with self.assertRaises(ValidationError):
+ field.clean({
+ 'mode': '6',
+ 'total_vat_column_index': 1,
+ 'total_no_vat_column_index': 2,
+ })
+
+ with self.assertRaises(ValidationError):
+ field.clean({
+ 'mode': 'nan',
+ 'total_vat_column_index': 1,
+ 'total_no_vat_column_index': 2,
+ })
+
+ with self.assertRaises(ValidationError):
+ field.clean({
+ # 'mode': '2',
+ 'total_vat_column_index': 1,
+ 'total_no_vat_column_index': 2,
+ })
+
+ def test_field_invalid_index(self):
+ field = TotalsExtractorField(choices=self.choices)
+
+ # ---
+ with self.assertRaises(ValidationError) as cm1:
+ field.clean({
+ 'mode': MODE_COMPUTE_VAT,
+ 'total_vat_column_index': 'nan',
+ 'total_no_vat_column_index': 2,
+ })
+ self.assertEqual(
+ 'Index "total_vat_column_index" should be an integer',
+ cm1.exception.message,
+ )
+
+ # ---
+ with self.assertRaises(ValidationError) as cm2:
+ field.clean({
+ 'mode': MODE_COMPUTE_VAT,
+ # 'total_vat_column_index': 1,
+ 'total_no_vat_column_index': 2,
+ })
+ self.assertEqual(
+ 'Index "total_vat_column_index" is required',
+ cm2.exception.message,
+ )
+
+ # ---
+ with self.assertRaises(ValidationError) as cm3:
+ field.clean({
+ 'mode': MODE_COMPUTE_VAT,
+ 'total_vat_column_index': 12,
+ 'total_no_vat_column_index': 2,
+ })
+ self.assertEqual('Invalid index', cm3.exception.message)
+
+ def test_field_required_choices(self):
+ clean = TotalsExtractorField(choices=self.choices).clean
+ msg_fmt = _('You have to select a column for «%(field)s».')
+
+ with self.assertRaises(ValidationError) as cm1:
+ clean({
+ 'mode': MODE_COMPUTE_TOTAL_VAT,
+ 'total_no_vat_column_index': '0',
+ 'vat_column_index': 2,
+ })
+ self.assertListEqual(
+ [msg_fmt % {'field': _('Total without VAT')}],
+ [*cm1.exception],
+ )
+
+ # ---
+ with self.assertRaises(ValidationError) as cm2:
+ clean({
+ 'mode': MODE_COMPUTE_TOTAL_VAT,
+ 'total_no_vat_column_index': 1,
+ 'vat_column_index': '0',
+ })
+ self.assertListEqual(
+ [msg_fmt % {'field': _('VAT')}],
+ [*cm2.exception],
+ )
+
+ # ---
+ with self.assertRaises(ValidationError) as cm2:
+ clean({
+ 'mode': MODE_COMPUTE_TOTAL_NO_VAT,
+ 'total_vat_column_index': '0',
+ 'vat_column_index': 2,
+ })
+ self.assertListEqual(
+ [msg_fmt % {'field': _('Total with VAT')}],
+ [*cm2.exception],
+ )
+
+ def test_field_empty_not_required(self):
+ field = TotalsExtractorField(choices=self.choices, required=False)
+
+ with self.assertNoException():
+ extractor = field.clean({})
+
+ self.assertIsNone(extractor)
+
+ @parameterized.expand([
+ (MODE_COMPUTE_TOTAL_VAT, 'total_no_vat_column_index', 'vat_column_index'),
+ (MODE_COMPUTE_TOTAL_NO_VAT, 'total_vat_column_index', 'vat_column_index'),
+ (MODE_COMPUTE_VAT, 'total_no_vat_column_index', 'total_vat_column_index'),
+ ])
+ def test_field_vat_creation(self, mode, index1, index2):
+ field = TotalsExtractorField(choices=self.choices)
+ self.assertIs(field.can_create_vat, False)
+ beware = _('Beware: you are not allowed to create new VAT values')
+ self.assertEqual(beware, str(field.help_text))
+
+ field.user = self.user
+ self.assertIs(field.can_create_vat, True)
+ self.assertEqual('', field.help_text)
+
+ data = {
+ 'mode': mode,
+ index1: 1,
+ index2: 2,
+ }
+
+ with self.assertNoException():
+ extractor1 = field.clean(data)
+
+ self.assertTrue(extractor1.create_vat)
+
+ # ---
+ role = UserRole(name='Basic')
+ role.allowed_apps = ['creme_core']
+ role.admin_4_apps = ['persons'] # creme_core
+ role.save()
+
+ field.user = self.build_user(index=1, role=role)
+ self.assertIs(field.can_create_vat, False)
+ self.assertEqual(beware, str(field.help_text))
+
+ with self.assertNoException():
+ extractor2 = field.clean(data)
+
+ self.assertFalse(extractor2.create_vat)
diff --git a/creme/billing/tests/test_invoice.py b/creme/billing/tests/test_invoice.py
index 33f9e5721f..d225eeda8a 100644
--- a/creme/billing/tests/test_invoice.py
+++ b/creme/billing/tests/test_invoice.py
@@ -1355,9 +1355,13 @@ def test_delete_additional_info(self):
self.assertIsNone(invoice.additional_info)
@skipIfCustomAddress
- def test_mass_import(self):
+ def test_mass_import_no_total(self):
self.login()
- self._aux_test_csv_import(Invoice, InvoiceStatus, number_help_text=False)
+ self._aux_test_csv_import_no_total(Invoice, InvoiceStatus, number_help_text=False)
+
+ def test_mass_import_total_no_vat_n_vat(self):
+ self.login()
+ self._aux_test_csv_import_total_no_vat_n_vat(Invoice, InvoiceStatus)
@skipIfCustomAddress
def test_mass_import_update01(self):
@@ -1387,12 +1391,70 @@ def test_mass_import_update03(self):
)
@skipIfCustomAddress
- def test_mass_import_update04(self):
+ def test_mass_import_update_total01(self):
self.login()
- self._aux_test_csv_import(
+ self._aux_test_csv_import_no_total(
Invoice, InvoiceStatus, update=True, number_help_text=False,
)
+ def test_mass_import_update_total02(self):
+ user = self.login()
+ doc = self._build_csv_doc([('Bill #1', 'Nerv', 'Acme', '300', '15')])
+ response = self.assertPOST200(
+ self._build_import_url(Invoice),
+ follow=True,
+ data={
+ 'step': 1,
+ 'document': doc.id,
+ # has_header
+
+ 'user': user.id,
+ 'key_fields': ['name'],
+
+ 'name_colselect': 1,
+ 'number_colselect': 0,
+
+ 'issuing_date_colselect': 0,
+ 'expiration_date_colselect': 0,
+
+ 'status_colselect': 0,
+ 'status_defval': InvoiceStatus.objects.all()[0].pk,
+
+ 'discount_colselect': 0,
+ 'discount_defval': '0',
+
+ 'currency_colselect': 0,
+ 'currency_defval': Currency.objects.all()[0].pk,
+
+ 'acceptation_date_colselect': 0,
+
+ 'comment_colselect': 0,
+ 'additional_info_colselect': 0,
+ 'payment_terms_colselect': 0,
+ 'payment_type_colselect': 0,
+
+ 'description_colselect': 0,
+ 'buyers_order_number_colselect': 0, # Invoice only...
+
+ 'source_persons_organisation_colselect': 2,
+ 'target_persons_organisation_colselect': 3,
+ 'target_persons_contact_colselect': 0,
+
+ 'totals_mode': '2', # Compute total with VAT
+ 'totals_total_no_vat_colselect': 4,
+ 'totals_vat_colselect': 5,
+
+ # 'property_types',
+ # 'fixed_relations',
+ # 'dyn_relations',
+ },
+ )
+ self.assertFormError(
+ response.context['form'],
+ field='totals',
+ errors=_('You cannot compute totals in update mode.'),
+ )
+
def test_brick01(self):
user = self.login()
source, target = self.create_orgas(user=user)
diff --git a/creme/billing/tests/test_quote.py b/creme/billing/tests/test_quote.py
index 4923e3313c..a37d8d8183 100644
--- a/creme/billing/tests/test_quote.py
+++ b/creme/billing/tests/test_quote.py
@@ -577,11 +577,11 @@ def test_delete_status(self):
)
@skipIfCustomAddress
- def test_mass_import01(self):
+ def test_mass_import_no_total01(self):
self.login()
- self._aux_test_csv_import(Quote, QuoteStatus)
+ self._aux_test_csv_import_no_total(Quote, QuoteStatus)
- def test_mass_import02(self):
+ def test_mass_import_no_total02(self):
"Source is managed."
user = self.login()
@@ -678,6 +678,10 @@ def test_mass_import02(self):
self.assertNotEqual(number1, number2)
+ def test_mass_import_total_no_vat_n_vat(self):
+ self.login()
+ self._aux_test_csv_import_total_no_vat_n_vat(Quote, QuoteStatus)
+
@skipIfCustomAddress
@skipIfCustomServiceLine
def test_clone01(self):
diff --git a/creme/billing/tests/test_sales_order.py b/creme/billing/tests/test_sales_order.py
index d33e432e9a..ae637133a7 100644
--- a/creme/billing/tests/test_sales_order.py
+++ b/creme/billing/tests/test_sales_order.py
@@ -341,7 +341,7 @@ def test_delete_status(self):
@skipIfCustomAddress
def test_mass_import(self):
self.login()
- self._aux_test_csv_import(SalesOrder, SalesOrderStatus)
+ self._aux_test_csv_import_no_total(SalesOrder, SalesOrderStatus)
@skipIfCustomAddress
def test_mass_import_update(self):
diff --git a/creme/creme_core/tests/utils/test_main.py b/creme/creme_core/tests/utils/test_main.py
index e34e4e051c..9196c7379a 100644
--- a/creme/creme_core/tests/utils/test_main.py
+++ b/creme/creme_core/tests/utils/test_main.py
@@ -21,6 +21,7 @@
from creme.creme_core.models import FakeOrganisation, SetCredentials
# from creme.creme_core.utils import find_first, split_filter
from creme.creme_core.utils import (
+ as_int,
create_if_needed,
ellipsis,
ellipsis_multi,
@@ -208,6 +209,15 @@ def test_date_2_dict(self):
d = {'year': 2012, 'month': 6, 'day': 6}
self.assertEqual(d, date_2_dict(date(**d)))
+ def test_as_int(self):
+ self.assertEqual(1, as_int('1'))
+ self.assertEqual(42, as_int('42'))
+ self.assertEqual(-12, as_int('-12'))
+
+ self.assertEqual(0, as_int('foo'))
+ self.assertEqual(0, as_int([]))
+ self.assertEqual(-1, as_int('foo', default=-1))
+
def test_int_2_roman(self):
self.assertListEqual(
[
diff --git a/creme/creme_core/utils/__init__.py b/creme/creme_core/utils/__init__.py
index 3b0f59fcc9..c6e3f69864 100644
--- a/creme/creme_core/utils/__init__.py
+++ b/creme/creme_core/utils/__init__.py
@@ -231,6 +231,13 @@ def bool_as_html(b: bool) -> str:
return f'{label}'
+def as_int(value, default=0):
+ try:
+ return int(value)
+ except (ValueError, TypeError):
+ return default
+
+
_I2R_NUMERAL_MAP = [
(1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'),
(90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'),