8
0
Fork 0
mirror of https://gitlab2.federez.net/re2o/re2o synced 2024-11-23 11:53:12 +00:00

Merge branch 'feature_improve_invoice' into 'dev'

Feature improve invoice

See merge request federez/re2o!382
This commit is contained in:
chirac 2019-01-01 18:45:52 +01:00
commit 125c4244bc
15 changed files with 734 additions and 118 deletions

View file

@ -30,7 +30,7 @@ from django.contrib import admin
from reversion.admin import VersionAdmin from reversion.admin import VersionAdmin
from .models import Facture, Article, Banque, Paiement, Cotisation, Vente from .models import Facture, Article, Banque, Paiement, Cotisation, Vente
from .models import CustomInvoice from .models import CustomInvoice, CostEstimate
class FactureAdmin(VersionAdmin): class FactureAdmin(VersionAdmin):
@ -38,6 +38,11 @@ class FactureAdmin(VersionAdmin):
pass pass
class CostEstimateAdmin(VersionAdmin):
"""Admin class for cost estimates."""
pass
class CustomInvoiceAdmin(VersionAdmin): class CustomInvoiceAdmin(VersionAdmin):
"""Admin class for custom invoices.""" """Admin class for custom invoices."""
pass pass
@ -76,3 +81,4 @@ admin.site.register(Paiement, PaiementAdmin)
admin.site.register(Vente, VenteAdmin) admin.site.register(Vente, VenteAdmin)
admin.site.register(Cotisation, CotisationAdmin) admin.site.register(Cotisation, CotisationAdmin)
admin.site.register(CustomInvoice, CustomInvoiceAdmin) admin.site.register(CustomInvoice, CustomInvoiceAdmin)
admin.site.register(CostEstimate, CostEstimateAdmin)

View file

@ -46,7 +46,10 @@ from django.shortcuts import get_object_or_404
from re2o.field_permissions import FieldPermissionFormMixin from re2o.field_permissions import FieldPermissionFormMixin
from re2o.mixins import FormRevMixin from re2o.mixins import FormRevMixin
from .models import Article, Paiement, Facture, Banque, CustomInvoice from .models import (
Article, Paiement, Facture, Banque,
CustomInvoice, Vente, CostEstimate
)
from .payment_methods import balance from .payment_methods import balance
@ -104,7 +107,44 @@ class SelectArticleForm(FormRevMixin, Form):
user = kwargs.pop('user') user = kwargs.pop('user')
target_user = kwargs.pop('target_user', None) target_user = kwargs.pop('target_user', None)
super(SelectArticleForm, self).__init__(*args, **kwargs) super(SelectArticleForm, self).__init__(*args, **kwargs)
self.fields['article'].queryset = Article.find_allowed_articles(user, target_user) self.fields['article'].queryset = Article.find_allowed_articles(
user, target_user)
class DiscountForm(Form):
"""
Form used in oder to create a discount on an invoice.
"""
is_relative = forms.BooleanField(
label=_("Discount is on percentage"),
required=False,
)
discount = forms.DecimalField(
label=_("Discount"),
max_value=100,
min_value=0,
max_digits=5,
decimal_places=2,
required=False,
)
def apply_to_invoice(self, invoice):
invoice_price = invoice.prix_total()
discount = self.cleaned_data['discount']
is_relative = self.cleaned_data['is_relative']
if is_relative:
amount = discount/100 * invoice_price
else:
amount = discount
if amount:
name = _("{}% discount") if is_relative else _("{}€ discount")
name = name.format(discount)
Vente.objects.create(
facture=invoice,
name=name,
prix=-amount,
number=1
)
class CustomInvoiceForm(FormRevMixin, ModelForm): class CustomInvoiceForm(FormRevMixin, ModelForm):
@ -116,6 +156,15 @@ class CustomInvoiceForm(FormRevMixin, ModelForm):
fields = '__all__' fields = '__all__'
class CostEstimateForm(FormRevMixin, ModelForm):
"""
Form used to create a cost estimate.
"""
class Meta:
model = CostEstimate
exclude = ['paid', 'final_invoice']
class ArticleForm(FormRevMixin, ModelForm): class ArticleForm(FormRevMixin, ModelForm):
""" """
Form used to create an article. Form used to create an article.
@ -248,7 +297,8 @@ class RechargeForm(FormRevMixin, Form):
super(RechargeForm, self).__init__(*args, **kwargs) super(RechargeForm, self).__init__(*args, **kwargs)
self.fields['payment'].empty_label = \ self.fields['payment'].empty_label = \
_("Select a payment method") _("Select a payment method")
self.fields['payment'].queryset = Paiement.find_allowed_payments(user_source).exclude(is_balance=True) self.fields['payment'].queryset = Paiement.find_allowed_payments(
user_source).exclude(is_balance=True)
def clean(self): def clean(self):
""" """
@ -266,4 +316,3 @@ class RechargeForm(FormRevMixin, Form):
} }
) )
return self.cleaned_data return self.cleaned_data

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-12-29 14:22
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0035_notepayment'),
]
operations = [
migrations.AddField(
model_name='custominvoice',
name='remark',
field=models.TextField(blank=True, null=True, verbose_name='Remark'),
),
]

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-12-29 21:03
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0036_custominvoice_remark'),
]
operations = [
migrations.CreateModel(
name='CostEstimate',
fields=[
('custominvoice_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='cotisations.CustomInvoice')),
('validity', models.DurationField(verbose_name='Period of validity')),
('final_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='origin_cost_estimate', to='cotisations.CustomInvoice')),
],
options={
'permissions': (('view_costestimate', 'Can view a cost estimate object'),),
},
bases=('cotisations.custominvoice',),
),
]

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-12-31 22:57
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0037_costestimate'),
]
operations = [
migrations.AlterField(
model_name='costestimate',
name='final_invoice',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_cost_estimate', to='cotisations.CustomInvoice'),
),
migrations.AlterField(
model_name='costestimate',
name='validity',
field=models.DurationField(help_text='DD HH:MM:SS', verbose_name='Period of validity'),
),
migrations.AlterField(
model_name='custominvoice',
name='paid',
field=models.BooleanField(default=False, verbose_name='Paid'),
),
]

View file

@ -284,8 +284,65 @@ class CustomInvoice(BaseInvoice):
verbose_name=_("Address") verbose_name=_("Address")
) )
paid = models.BooleanField( paid = models.BooleanField(
verbose_name=_("Paid") verbose_name=_("Paid"),
default=False
) )
remark = models.TextField(
verbose_name=_("Remark"),
blank=True,
null=True
)
class CostEstimate(CustomInvoice):
class Meta:
permissions = (
('view_costestimate', _("Can view a cost estimate object")),
)
validity = models.DurationField(
verbose_name=_("Period of validity"),
help_text="DD HH:MM:SS"
)
final_invoice = models.ForeignKey(
CustomInvoice,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="origin_cost_estimate",
primary_key=False
)
def create_invoice(self):
"""Create a CustomInvoice from the CostEstimate."""
if self.final_invoice is not None:
return self.final_invoice
invoice = CustomInvoice()
invoice.recipient = self.recipient
invoice.payment = self.payment
invoice.address = self.address
invoice.paid = False
invoice.remark = self.remark
invoice.date = timezone.now()
invoice.save()
self.final_invoice = invoice
self.save()
for sale in self.vente_set.all():
Vente.objects.create(
facture=invoice,
name=sale.name,
prix=sale.prix,
number=sale.number,
)
return invoice
def can_delete(self, user_request, *args, **kwargs):
if not user_request.has_perm('cotisations.delete_costestimate'):
return False, _("You don't have the right "
"to delete a cost estimate.")
if self.final_invoice is not None:
return False, _("The cost estimate has an "
"invoice and cannot be deleted.")
return True, None
# TODO : change Vente to Purchase # TODO : change Vente to Purchase

View file

@ -0,0 +1,101 @@
{% comment %}
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
se veut agnostique au réseau considéré, de manière à être installable en
quelques clics.
Copyright © 2018 Hugo Levy-Falk
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
{% endcomment %}
{% load i18n %}
{% load acl %}
{% load logs_extra %}
{% load design %}
<div class="table-responsive">
{% if cost_estimate_list.paginator %}
{% include 'pagination.html' with list=cost_estimate_list%}
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th>
{% trans "Recipient" as tr_recip %}
{% include 'buttons/sort.html' with prefix='invoice' col='user' text=tr_user %}
</th>
<th>{% trans "Designation" %}</th>
<th>{% trans "Total price" %}</th>
<th>
{% trans "Payment method" as tr_payment_method %}
{% include 'buttons/sort.html' with prefix='invoice' col='payement' text=tr_payment_method %}
</th>
<th>
{% trans "Date" as tr_date %}
{% include 'buttons/sort.html' with prefix='invoice' col='date' text=tr_date %}
</th>
<th>
{% trans "Validity" as tr_validity %}
{% include 'buttons/sort.html' with prefix='invoice' col='validity' text=tr_validity %}
</th>
<th>
{% trans "Cost estimate ID" as tr_estimate_id %}
{% include 'buttons/sort.html' with prefix='invoice' col='id' text=tr_estimate_id %}
</th>
<th>
{% trans "Invoice created" as tr_invoice_created%}
{% include 'buttons/sort.html' with prefix='invoice' col='paid' text=tr_invoice_created %}
</th>
<th></th>
<th></th>
</tr>
</thead>
{% for estimate in cost_estimate_list %}
<tr>
<td>{{ estimate.recipient }}</td>
<td>{{ estimate.name }}</td>
<td>{{ estimate.prix_total }}</td>
<td>{{ estimate.payment }}</td>
<td>{{ estimate.date }}</td>
<td>{{ estimate.validity }}</td>
<td>{{ estimate.id }}</td>
<td>
{% if estimate.final_invoice %}
<a href="{% url 'cotisations:edit-custom-invoice' estimate.final_invoice.pk %}"><i style="color: #1ECA18;" class="fa fa-check"></i></a>
{% else %}
<i style="color: #D10115;" class="fa fa-times"></i>'
{% endif %}
</td>
<td>
{% can_edit estimate %}
{% include 'buttons/edit.html' with href='cotisations:edit-cost-estimate' id=estimate.id %}
{% acl_end %}
{% history_button estimate %}
{% include 'buttons/suppr.html' with href='cotisations:del-cost-estimate' id=estimate.id %}
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:cost-estimate-to-invoice' estimate.id %}">
<i class="fa fa-file"></i>
</a>
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:cost-estimate-pdf' estimate.id %}">
<i class="fa fa-file-pdf-o"></i> {% trans "PDF" %}
</a>
</td>
</tr>
{% endfor %}
</table>
{% if custom_invoice_list.paginator %}
{% include 'pagination.html' with list=custom_invoice_list %}
{% endif %}
</div>

View file

@ -35,7 +35,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<form class="form" method="post"> <form class="form" method="post">
{% csrf_token %} {% csrf_token %}
{% if title %}
<h3>{{title}}</h3>
{% else %}
<h3>{% trans "Edit the invoice" %}</h3> <h3>{% trans "Edit the invoice" %}</h3>
{% endif %}
{% massive_bootstrap_form factureform 'user' %} {% massive_bootstrap_form factureform 'user' %}
{{ venteform.management_form }} {{ venteform.management_form }}
<h3>{% trans "Articles" %}</h3> <h3>{% trans "Articles" %}</h3>

View file

@ -44,6 +44,8 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% blocktrans %}Current balance: {{ balance }} €{% endblocktrans %} {% blocktrans %}Current balance: {{ balance }} €{% endblocktrans %}
</p> </p>
{% endif %} {% endif %}
{% bootstrap_form_errors factureform %}
{% bootstrap_form_errors discount_form %}
<form class="form" method="post"> <form class="form" method="post">
{% csrf_token %} {% csrf_token %}
@ -68,8 +70,12 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% endfor %} {% endfor %}
</div> </div>
<input class="btn btn-primary btn-block" role="button" value="{% trans "Add an extra article"%}" id="add_one"> <input class="btn btn-primary btn-block" role="button" value="{% trans "Add an extra article"%}" id="add_one">
<h3>{% trans "Discount" %}</h3>
{% if discount_form %}
{% bootstrap_form discount_form %}
{% endif %}
<p> <p>
{% blocktrans %}Total price: <span id="total_price">0,00</span> €{% endblocktrans %} {% blocktrans %}Total price: <span id="total_price">0,00</span> €{% endblocktrans %}
</p> </p>
{% endif %} {% endif %}
{% bootstrap_button action_name button_type='submit' icon='ok' button_class='btn-success' %} {% bootstrap_button action_name button_type='submit' icon='ok' button_class='btn-success' %}
@ -78,105 +84,117 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% if articlesformset or payment_method%} {% if articlesformset or payment_method%}
<script type="text/javascript"> <script type="text/javascript">
{% if articlesformset %} {% if articlesformset %}
var prices = {}; var prices = {};
{% for article in articlelist %} {% for article in articlelist %}
prices[{{ article.id|escapejs }}] = {{ article.prix }}; prices[{{ article.id|escapejs }}] = {{ article.prix }};
{% endfor %} {% endfor %}
var template = `Article : &nbsp; var template = `Article : &nbsp;
{% bootstrap_form articlesformset.empty_form label_class='sr-only' %} {% bootstrap_form articlesformset.empty_form label_class='sr-only' %}
&nbsp; &nbsp;
<button class="btn btn-danger btn-sm" <button class="btn btn-danger btn-sm"
id="id_form-__prefix__-article-remove" type="button"> id="id_form-__prefix__-article-remove" type="button">
<span class="fa fa-times"></span> <span class="fa fa-times"></span>
</button>` </button>`
function add_article(){ function add_article(){
// Index start at 0 => new_index = number of items // Index start at 0 => new_index = number of items
var new_index = var new_index =
document.getElementsByClassName('product_to_sell').length; document.getElementsByClassName('product_to_sell').length;
document.getElementById('id_form-TOTAL_FORMS').value ++; document.getElementById('id_form-TOTAL_FORMS').value ++;
var new_article = document.createElement('div'); var new_article = document.createElement('div');
new_article.className = 'product_to_sell form-inline'; new_article.className = 'product_to_sell form-inline';
new_article.innerHTML = template.replace(/__prefix__/g, new_index); new_article.innerHTML = template.replace(/__prefix__/g, new_index);
document.getElementById('form_set').appendChild(new_article); document.getElementById('form_set').appendChild(new_article);
add_listenner_for_id(new_index); add_listenner_for_id(new_index);
} }
function update_price(){ function update_price(){
var price = 0; var price = 0;
var product_count = var product_count =
document.getElementsByClassName('product_to_sell').length; document.getElementsByClassName('product_to_sell').length;
var article, article_price, quantity; var article, article_price, quantity;
for (i = 0; i < product_count; ++i){ for (i = 0; i < product_count; ++i){
article = document.getElementById( article = document.getElementById(
'id_form-' + i.toString() + '-article').value; 'id_form-' + i.toString() + '-article').value;
if (article == '') { if (article == '') {
continue; continue;
} }
article_price = prices[article]; article_price = prices[article];
quantity = document.getElementById( quantity = document.getElementById(
'id_form-' + i.toString() + '-quantity').value; 'id_form-' + i.toString() + '-quantity').value;
price += article_price * quantity; price += article_price * quantity;
}
document.getElementById('total_price').innerHTML =
price.toFixed(2).toString().replace('.', ',');
} }
{% if discount_form %}
function add_listenner_for_id(i){ var relative_discount = document.getElementById('id_is_relative').checked;
document.getElementById('id_form-' + i.toString() + '-article') var discount = document.getElementById('id_discount').value;
.addEventListener("change", update_price, true); if(relative_discount) {
document.getElementById('id_form-' + i.toString() + '-article') discount = discount/100 * price;
.addEventListener("onkeypress", update_price, true);
document.getElementById('id_form-' + i.toString() + '-quantity')
.addEventListener("change", update_price, true);
document.getElementById('id_form-' + i.toString() + '-article-remove')
.addEventListener("click", function(event) {
var article = event.target.parentNode;
article.parentNode.removeChild(article);
document.getElementById('id_form-TOTAL_FORMS').value --;
update_price();
})
} }
price -= discount;
{% endif %}
document.getElementById('total_price').innerHTML =
price.toFixed(2).toString().replace('.', ',');
}
// Add events manager when DOM is fully loaded function add_listenner_for_id(i){
document.addEventListener("DOMContentLoaded", function() { document.getElementById('id_form-' + i.toString() + '-article')
document.getElementById("add_one") .addEventListener("change", update_price, true);
.addEventListener("click", add_article, true); document.getElementById('id_form-' + i.toString() + '-article')
var product_count = .addEventListener("onkeypress", update_price, true);
document.getElementsByClassName('product_to_sell').length; document.getElementById('id_form-' + i.toString() + '-quantity')
for (i = 0; i < product_count; ++i){ .addEventListener("change", update_price, true);
add_listenner_for_id(i); document.getElementById('id_form-' + i.toString() + '-article-remove')
} .addEventListener("click", function(event) {
update_price(); var article = event.target.parentNode;
}); article.parentNode.removeChild(article);
document.getElementById('id_form-TOTAL_FORMS').value --;
update_price();
})
}
// Add events manager when DOM is fully loaded
document.addEventListener("DOMContentLoaded", function() {
document.getElementById("add_one")
.addEventListener("click", add_article, true);
var product_count =
document.getElementsByClassName('product_to_sell').length;
for (i = 0; i < product_count; ++i){
add_listenner_for_id(i);
}
document.getElementById('id_discount')
.addEventListener('change', update_price, true);
document.getElementById('id_is_relative')
.addEventListener('click', update_price, true);
update_price();
});
{% endif %} {% endif %}
{% if payment_method.templates %} {% if payment_method.templates %}
var TEMPLATES = [ var TEMPLATES = [
"", "",
{% for t in payment_method.templates %} {% for t in payment_method.templates %}
{% if t %} {% if t %}
`{% bootstrap_form t %}`, `{% bootstrap_form t %}`,
{% else %} {% else %}
"", "",
{% endif %} {% endif %}
{% endfor %} {% endfor %}
]; ];
function update_payment_method_form(){ function update_payment_method_form(){
var method = document.getElementById('paymentMethodSelect').value; var method = document.getElementById('paymentMethodSelect').value;
if(method==""){ if(method==""){
method=0; method=0;
}
else{
method = Number(method);
method += 1;
}
console.log(method);
var html = TEMPLATES[method];
document.getElementById('paymentMethod').innerHTML = html;
} }
document.getElementById("paymentMethodSelect").addEventListener("change", update_payment_method_form); else{
method = Number(method);
method += 1;
}
console.log(method);
var html = TEMPLATES[method];
document.getElementById('paymentMethod').innerHTML = html;
}
document.getElementById("paymentMethodSelect").addEventListener("change", update_payment_method_form);
{% endif %} {% endif %}
</script> </script>
{% endif %} {% endif %}

View file

@ -43,7 +43,7 @@
\begin{document} \begin{document}
%---------------------------------------------------------------------------------------- %----------------------------------------------------------------------------------------
% HEADING SECTION % HEADING SECTION
%---------------------------------------------------------------------------------------- %----------------------------------------------------------------------------------------
@ -70,13 +70,17 @@
{\bf Siret :} {{siret|safe}} {\bf Siret :} {{siret|safe}}
\vspace{2cm} \vspace{2cm}
\begin{tabular*}{\textwidth}{@{\extracolsep{\fill}} l r} \begin{tabular*}{\textwidth}{@{\extracolsep{\fill}} l r}
{\bf Pour :} {{recipient_name|safe}} & {\bf Date :} {{DATE}} \\ {\bf Pour :} {{recipient_name|safe}} & {\bf Date :} {{DATE}} \\
{\bf Adresse :} {% if address is None %}Aucune adresse renseignée{% else %}{{address}}{% endif %} & \\ {\bf Adresse :} {% if address is None %}Aucune adresse renseignée{% else %}{{address}}{% endif %} & \\
{% if fid is not None %} {% if fid is not None %}
{% if is_estimate %}
{\bf Devis n\textsuperscript{o} :} {{ fid }} & \\
{% else %}
{\bf Facture n\textsuperscript{o} :} {{ fid }} & \\ {\bf Facture n\textsuperscript{o} :} {{ fid }} & \\
{% endif %} {% endif %}
{% endif %}
\end{tabular*} \end{tabular*}
\\ \\
@ -84,39 +88,57 @@
%---------------------------------------------------------------------------------------- %----------------------------------------------------------------------------------------
% TABLE OF EXPENSES % TABLE OF EXPENSES
%---------------------------------------------------------------------------------------- %----------------------------------------------------------------------------------------
\begin{tabularx}{\textwidth}{|X|r|r|r|} \begin{tabularx}{\textwidth}{|X|r|r|r|}
\hline \hline
\textbf{Désignation} & \textbf{Prix Unit.} \euro & \textbf{Quantité} & \textbf{Prix total} \euro\\ \textbf{Désignation} & \textbf{Prix Unit.} \euro & \textbf{Quantité} & \textbf{Prix total} \euro\\
\doublehline \doublehline
{% for a in article %} {% for a in article %}
{{a.name}} & {{a.price}} \euro & {{a.quantity}} & {{a.total_price}} \euro\\ {{a.name}} & {{a.price}} \euro & {{a.quantity}} & {{a.total_price}} \euro\\
\hline \hline
{% endfor %} {% endfor %}
\end{tabularx} \end{tabularx}
\vspace{1cm} \vspace{1cm}
\hfill \hfill
\begin{tabular}{|l|r|} \begin{tabular}{|l|r|}
\hline \hline
\textbf{Total} & {{total|floatformat:2}} \euro \\ \textbf{Total} & {{total|floatformat:2}} \euro \\
{% if not is_estimate %}
\textbf{Votre règlement} & {% if paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro \\ \textbf{Votre règlement} & {% if paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro \\
\doublehline \doublehline
\textbf{À PAYER} & {% if not paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro\\ \textbf{À PAYER} & {% if not paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro\\
{% endif %}
\hline \hline
\end{tabular} \end{tabular}
\vspace{1cm}
\begin{tabularx}{\textwidth}{r X}
\hline
\textbf{Moyen de paiement} & {{payment_method|default:"Non spécifié"}} \\
\hline
{% if remark %}
\textbf{Remarque} & {{remark|safe}} \\
\hline
{% endif %}
{% if end_validity %}
\textbf{Validité} & Jusqu'au {{end_validity}} \\
\hline
{% endif %}
\end{tabularx}
\vfill \vfill
%---------------------------------------------------------------------------------------- %----------------------------------------------------------------------------------------
% FOOTNOTE % FOOTNOTE
%---------------------------------------------------------------------------------------- %----------------------------------------------------------------------------------------
\hrule \hrule
\smallskip \smallskip
\footnotesize{TVA non applicable, art. 293 B du CGI} \footnotesize{TVA non applicable, art. 293 B du CGI}

View file

@ -0,0 +1,36 @@
{% extends "cotisations/sidebar.html" %}
{% comment %}
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
se veut agnostique au réseau considéré, de manière à être installable en
quelques clics.
Copyright © 2017 Gabriel Détraz
Copyright © 2017 Goulven Kermarec
Copyright © 2017 Augustin Lemesle
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
{% endcomment %}
{% load acl %}
{% load i18n %}
{% block title %}{% trans "Cost estimates" %}{% endblock %}
{% block content %}
<h2>{% trans "Cost estimates list" %}</h2>
{% can_create CostEstimate %}
{% include "buttons/add.html" with href='cotisations:new-cost-estimate'%}
{% acl_end %}
{% include 'cotisations/aff_cost_estimate.html' %}
{% endblock %}

View file

@ -45,6 +45,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<i class="fa fa-list-ul"></i> {% trans "Custom invoices" %} <i class="fa fa-list-ul"></i> {% trans "Custom invoices" %}
</a> </a>
{% acl_end %} {% acl_end %}
{% can_view_all CostEstimate %}
<a class="list-group-item list-group-item-info" href="{% url "cotisations:index-cost-estimate" %}">
<i class="fa fa-list-ul"></i> {% trans "Cost estimate" %}
</a>
{% acl_end %}
{% can_view_all Article %} {% can_view_all Article %}
<a class="list-group-item list-group-item-info" href="{% url "cotisations:index-article" %}"> <a class="list-group-item list-group-item-info" href="{% url "cotisations:index-article" %}">
<i class="fa fa-list-ul"></i> {% trans "Available articles" %} <i class="fa fa-list-ul"></i> {% trans "Available articles" %}

View file

@ -36,6 +36,7 @@ from django.template import Context
from django.http import HttpResponse from django.http import HttpResponse
from django.conf import settings from django.conf import settings
from django.utils.text import slugify from django.utils.text import slugify
import logging
TEMP_PREFIX = getattr(settings, 'TEX_TEMP_PREFIX', 'render_tex-') TEMP_PREFIX = getattr(settings, 'TEX_TEMP_PREFIX', 'render_tex-')
@ -48,8 +49,9 @@ def render_invoice(_request, ctx={}):
Render an invoice using some available information such as the current Render an invoice using some available information such as the current
date, the user, the articles, the prices, ... date, the user, the articles, the prices, ...
""" """
is_estimate = ctx.get('is_estimate', False)
filename = '_'.join([ filename = '_'.join([
'invoice', 'cost_estimate' if is_estimate else 'invoice',
slugify(ctx.get('asso_name', "")), slugify(ctx.get('asso_name', "")),
slugify(ctx.get('recipient_name', "")), slugify(ctx.get('recipient_name', "")),
str(ctx.get('DATE', datetime.now()).year), str(ctx.get('DATE', datetime.now()).year),
@ -93,6 +95,20 @@ def create_pdf(template, ctx={}):
return pdf return pdf
def escape_chars(string):
"""Escape the '%' and the '' signs to avoid messing with LaTeX"""
if not isinstance(string, str):
return string
mapping = (
('', r'\euro'),
('%', r'\%'),
)
r = str(string)
for k, v in mapping:
r = r.replace(k, v)
return r
def render_tex(_request, template, ctx={}): def render_tex(_request, template, ctx={}):
"""Creates a PDF from a LaTex templates using pdflatex. """Creates a PDF from a LaTex templates using pdflatex.

View file

@ -51,11 +51,41 @@ urlpatterns = [
views.facture_pdf, views.facture_pdf,
name='facture-pdf' name='facture-pdf'
), ),
url(
r'^new_cost_estimate/$',
views.new_cost_estimate,
name='new-cost-estimate'
),
url(
r'^index_cost_estimate/$',
views.index_cost_estimate,
name='index-cost-estimate'
),
url(
r'^cost_estimate_pdf/(?P<costestimateid>[0-9]+)$',
views.cost_estimate_pdf,
name='cost-estimate-pdf',
),
url( url(
r'^index_custom_invoice/$', r'^index_custom_invoice/$',
views.index_custom_invoice, views.index_custom_invoice,
name='index-custom-invoice' name='index-custom-invoice'
), ),
url(
r'^edit_cost_estimate/(?P<costestimateid>[0-9]+)$',
views.edit_cost_estimate,
name='edit-cost-estimate'
),
url(
r'^cost_estimate_to_invoice/(?P<costestimateid>[0-9]+)$',
views.cost_estimate_to_invoice,
name='cost-estimate-to-invoice'
),
url(
r'^del_cost_estimate/(?P<costestimateid>[0-9]+)$',
views.del_cost_estimate,
name='del-cost-estimate'
),
url( url(
r'^new_custom_invoice/$', r'^new_custom_invoice/$',
views.new_custom_invoice, views.new_custom_invoice,

View file

@ -68,7 +68,8 @@ from .models import (
Paiement, Paiement,
Banque, Banque,
CustomInvoice, CustomInvoice,
BaseInvoice BaseInvoice,
CostEstimate
) )
from .forms import ( from .forms import (
FactureForm, FactureForm,
@ -80,9 +81,11 @@ from .forms import (
DelBanqueForm, DelBanqueForm,
SelectArticleForm, SelectArticleForm,
RechargeForm, RechargeForm,
CustomInvoiceForm CustomInvoiceForm,
DiscountForm,
CostEstimateForm,
) )
from .tex import render_invoice from .tex import render_invoice, escape_chars
from .payment_methods.forms import payment_method_factory from .payment_methods.forms import payment_method_factory
from .utils import find_payment_method from .utils import find_payment_method
@ -178,7 +181,58 @@ def new_facture(request, user, userid):
) )
# TODO : change facture to invoice @login_required
@can_create(CostEstimate)
def new_cost_estimate(request):
"""
View used to generate a custom invoice. It's mainly used to
get invoices that are not taken into account, for the administrative
point of view.
"""
# The template needs the list of articles (for the JS part)
articles = Article.objects.filter(
Q(type_user='All') | Q(type_user=request.user.class_name)
)
# Building the invocie form and the article formset
cost_estimate_form = CostEstimateForm(request.POST or None)
articles_formset = formset_factory(SelectArticleForm)(
request.POST or None,
form_kwargs={'user': request.user}
)
discount_form = DiscountForm(request.POST or None)
if cost_estimate_form.is_valid() and articles_formset.is_valid() and discount_form.is_valid():
cost_estimate_instance = cost_estimate_form.save()
for art_item in articles_formset:
if art_item.cleaned_data:
article = art_item.cleaned_data['article']
quantity = art_item.cleaned_data['quantity']
Vente.objects.create(
facture=cost_estimate_instance,
name=article.name,
prix=article.prix,
type_cotisation=article.type_cotisation,
duration=article.duration,
number=quantity
)
discount_form.apply_to_invoice(cost_estimate_instance)
messages.success(
request,
_("The cost estimate was created.")
)
return redirect(reverse('cotisations:index-cost-estimate'))
return form({
'factureform': cost_estimate_form,
'action_name': _("Confirm"),
'articlesformset': articles_formset,
'articlelist': articles,
'discount_form': discount_form,
'title': _("Cost estimate"),
}, 'cotisations/facture.html', request)
@login_required @login_required
@can_create(CustomInvoice) @can_create(CustomInvoice)
def new_custom_invoice(request): def new_custom_invoice(request):
@ -198,8 +252,9 @@ def new_custom_invoice(request):
request.POST or None, request.POST or None,
form_kwargs={'user': request.user} form_kwargs={'user': request.user}
) )
discount_form = DiscountForm(request.POST or None)
if invoice_form.is_valid() and articles_formset.is_valid(): if invoice_form.is_valid() and articles_formset.is_valid() and discount_form.is_valid():
new_invoice_instance = invoice_form.save() new_invoice_instance = invoice_form.save()
for art_item in articles_formset: for art_item in articles_formset:
if art_item.cleaned_data: if art_item.cleaned_data:
@ -213,6 +268,7 @@ def new_custom_invoice(request):
duration=article.duration, duration=article.duration,
number=quantity number=quantity
) )
discount_form.apply_to_invoice(new_invoice_instance)
messages.success( messages.success(
request, request,
_("The custom invoice was created.") _("The custom invoice was created.")
@ -223,7 +279,8 @@ def new_custom_invoice(request):
'factureform': invoice_form, 'factureform': invoice_form,
'action_name': _("Confirm"), 'action_name': _("Confirm"),
'articlesformset': articles_formset, 'articlesformset': articles_formset,
'articlelist': articles 'articlelist': articles,
'discount_form': discount_form
}, 'cotisations/facture.html', request) }, 'cotisations/facture.html', request)
@ -266,7 +323,8 @@ def facture_pdf(request, facture, **_kwargs):
'siret': AssoOption.get_cached_value('siret'), 'siret': AssoOption.get_cached_value('siret'),
'email': AssoOption.get_cached_value('contact'), 'email': AssoOption.get_cached_value('contact'),
'phone': AssoOption.get_cached_value('telephone'), 'phone': AssoOption.get_cached_value('telephone'),
'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH) 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH),
'payment_method': facture.paiement.moyen,
}) })
@ -331,6 +389,55 @@ def del_facture(request, facture, **_kwargs):
}, 'cotisations/delete.html', request) }, 'cotisations/delete.html', request)
@login_required
@can_edit(CostEstimate)
def edit_cost_estimate(request, invoice, **kwargs):
# Building the invocie form and the article formset
invoice_form = CostEstimateForm(
request.POST or None,
instance=invoice
)
purchases_objects = Vente.objects.filter(facture=invoice)
purchase_form_set = modelformset_factory(
Vente,
fields=('name', 'number'),
extra=0,
max_num=len(purchases_objects)
)
purchase_form = purchase_form_set(
request.POST or None,
queryset=purchases_objects
)
if invoice_form.is_valid() and purchase_form.is_valid():
if invoice_form.changed_data:
invoice_form.save()
purchase_form.save()
messages.success(
request,
_("The cost estimate was edited.")
)
return redirect(reverse('cotisations:index-cost-estimate'))
return form({
'factureform': invoice_form,
'venteform': purchase_form,
'title': "Edit the cost estimate"
}, 'cotisations/edit_facture.html', request)
@login_required
@can_edit(CostEstimate)
@can_create(CustomInvoice)
def cost_estimate_to_invoice(request, cost_estimate, **_kwargs):
"""Create a custom invoice from a cos estimate"""
cost_estimate.create_invoice()
messages.success(
request,
_("An invoice was successfully created from your cost estimate.")
)
return redirect(reverse('cotisations:index-custom-invoice'))
@login_required @login_required
@can_edit(CustomInvoice) @can_edit(CustomInvoice)
def edit_custom_invoice(request, invoice, **kwargs): def edit_custom_invoice(request, invoice, **kwargs):
@ -367,10 +474,10 @@ def edit_custom_invoice(request, invoice, **kwargs):
@login_required @login_required
@can_view(CustomInvoice) @can_view(CostEstimate)
def custom_invoice_pdf(request, invoice, **_kwargs): def cost_estimate_pdf(request, invoice, **_kwargs):
""" """
View used to generate a PDF file from an existing invoice in database View used to generate a PDF file from an existing cost estimate in database
Creates a line for each Purchase (thus article sold) and generate the Creates a line for each Purchase (thus article sold) and generate the
invoice with the total price, the payment method, the address and the invoice with the total price, the payment method, the address and the
legal information for the user. legal information for the user.
@ -382,7 +489,7 @@ def custom_invoice_pdf(request, invoice, **_kwargs):
purchases_info = [] purchases_info = []
for purchase in purchases_objects: for purchase in purchases_objects:
purchases_info.append({ purchases_info.append({
'name': purchase.name, 'name': escape_chars(purchase.name),
'price': purchase.prix, 'price': purchase.prix,
'quantity': purchase.number, 'quantity': purchase.number,
'total_price': purchase.prix_total 'total_price': purchase.prix_total
@ -401,11 +508,74 @@ def custom_invoice_pdf(request, invoice, **_kwargs):
'siret': AssoOption.get_cached_value('siret'), 'siret': AssoOption.get_cached_value('siret'),
'email': AssoOption.get_cached_value('contact'), 'email': AssoOption.get_cached_value('contact'),
'phone': AssoOption.get_cached_value('telephone'), 'phone': AssoOption.get_cached_value('telephone'),
'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH) 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH),
'payment_method': invoice.payment,
'remark': invoice.remark,
'end_validity': invoice.date + invoice.validity,
'is_estimate': True,
})
@login_required
@can_delete(CostEstimate)
def del_cost_estimate(request, estimate, **_kwargs):
"""
View used to delete an existing invocie.
"""
if request.method == "POST":
estimate.delete()
messages.success(
request,
_("The cost estimate was deleted.")
)
return redirect(reverse('cotisations:index-cost-estimate'))
return form({
'objet': estimate,
'objet_name': _("Cost Estimate")
}, 'cotisations/delete.html', request)
@login_required
@can_view(CustomInvoice)
def custom_invoice_pdf(request, invoice, **_kwargs):
"""
View used to generate a PDF file from an existing invoice in database
Creates a line for each Purchase (thus article sold) and generate the
invoice with the total price, the payment method, the address and the
legal information for the user.
"""
# TODO : change vente to purchase
purchases_objects = Vente.objects.all().filter(facture=invoice)
# Get the article list and build an list out of it
# contiaining (article_name, article_price, quantity, total_price)
purchases_info = []
for purchase in purchases_objects:
purchases_info.append({
'name': escape_chars(purchase.name),
'price': purchase.prix,
'quantity': purchase.number,
'total_price': purchase.prix_total
})
return render_invoice(request, {
'paid': invoice.paid,
'fid': invoice.id,
'DATE': invoice.date,
'recipient_name': invoice.recipient,
'address': invoice.address,
'article': purchases_info,
'total': invoice.prix_total(),
'asso_name': AssoOption.get_cached_value('name'),
'line1': AssoOption.get_cached_value('adresse1'),
'line2': AssoOption.get_cached_value('adresse2'),
'siret': AssoOption.get_cached_value('siret'),
'email': AssoOption.get_cached_value('contact'),
'phone': AssoOption.get_cached_value('telephone'),
'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH),
'payment_method': invoice.payment,
'remark': invoice.remark,
}) })
# TODO : change facture to invoice
@login_required @login_required
@can_delete(CustomInvoice) @can_delete(CustomInvoice)
def del_custom_invoice(request, invoice, **_kwargs): def del_custom_invoice(request, invoice, **_kwargs):
@ -756,12 +926,35 @@ def index_banque(request):
}) })
@login_required
@can_view_all(CustomInvoice)
def index_cost_estimate(request):
"""View used to display every custom invoice."""
pagination_number = GeneralOption.get_cached_value('pagination_number')
cost_estimate_list = CostEstimate.objects.prefetch_related('vente_set')
cost_estimate_list = SortTable.sort(
cost_estimate_list,
request.GET.get('col'),
request.GET.get('order'),
SortTable.COTISATIONS_CUSTOM
)
cost_estimate_list = re2o_paginator(
request,
cost_estimate_list,
pagination_number,
)
return render(request, 'cotisations/index_cost_estimate.html', {
'cost_estimate_list': cost_estimate_list
})
@login_required @login_required
@can_view_all(CustomInvoice) @can_view_all(CustomInvoice)
def index_custom_invoice(request): def index_custom_invoice(request):
"""View used to display every custom invoice.""" """View used to display every custom invoice."""
pagination_number = GeneralOption.get_cached_value('pagination_number') pagination_number = GeneralOption.get_cached_value('pagination_number')
custom_invoice_list = CustomInvoice.objects.prefetch_related('vente_set') cost_estimate_ids = [i for i, in CostEstimate.objects.values_list('id')]
custom_invoice_list = CustomInvoice.objects.prefetch_related('vente_set').exclude(id__in=cost_estimate_ids)
custom_invoice_list = SortTable.sort( custom_invoice_list = SortTable.sort(
custom_invoice_list, custom_invoice_list,
request.GET.get('col'), request.GET.get('col'),