invoicing: group invoice lines by user, activity, event, status (#80188)
gitea/lingo/pipeline/head This commit looks good Details

This commit is contained in:
Lauréline Guérin 2023-08-10 16:23:33 +02:00
parent 6428b3812a
commit 1ee8aa5e95
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
12 changed files with 196 additions and 51 deletions

View File

@ -597,12 +597,65 @@ class AbstractInvoice(models.Model):
payer_name = '%s %s' % (self.payer_first_name, self.payer_last_name)
return payer_name.strip()
def get_grouped_and_ordered_lines(self):
lines = []
agendas_by_slug = {a.slug: a for a in Agenda.objects.all()}
possible_status = ['presence', 'absence', 'cancelled', '']
for line in self.lines.all():
# build event slug
slug = (
'%s@%s' % (line.details['agenda'], line.details['primary_event'])
if line.details.get('agenda')
else line.slug
)
# search for agenda
line.agenda = None
if '@' in slug:
agenda_slug = slug.split('@')[0]
line.agenda = agendas_by_slug.get(agenda_slug)
# build event date/datetime, and check status
event_date = line.event_date.strftime('Y-m-d')
event_status = 'z' * 5000
if line.details:
event_date = '%s:%s' % (line.event_date, line.details.get('event_time') or '')
status = line.details.get('status') or ''
event_status = '%s:%s' % (possible_status.index(status), status)
if status in ['presence', 'absence'] and line.details.get('check_type_label'):
# note: presence without reason will be sorted first
event_status += ':%s' % line.details['check_type_label']
if status == 'absence' and not line.details.get('check_type_label'):
# so absence without reason will be sorted last
event_status += ':%s' % 'z' * 5000
lines.append(
(
line,
# sort by user
line.user_external_id,
# by agenda (activity)
line.agenda.label if line.agenda else 'z' * 5000,
# by date/datetime
event_date,
# bu slug
slug,
# by check status
event_status,
# and pk
line.pk,
)
)
lines = sorted(
lines,
key=lambda li: li[1:],
)
lines = [li[0] for li in lines]
return lines
def html(self):
template = get_template('lingo/invoicing/invoice.html')
context = {
'regie': self.regie,
'invoice': self,
'lines': self.lines.all().order_by('user_external_id', 'event_date', 'pk'),
'lines': self.get_grouped_and_ordered_lines(),
}
return template.render(context)

View File

@ -16,7 +16,7 @@
}
h1 {
color: #1ee494;
color: #df5a13;
font-family: Pacifico;
margin: 0.4em 0;
font-size: 12pt;
@ -67,21 +67,24 @@
border-collapse: collapse;
width: 100%;
}
tr.new-event {
border-top: .2mm solid;
}
th {
border-bottom: .2mm solid #a9a;
border-bottom: .2mm solid #14213d;
color: #a9a;
font-size: 9pt;
font-weight: 400;
padding-bottom: .25cm;
text-transform: uppercase;
}
td {
padding-top: 7mm;
}
td.total-amount {
color: #1ee494;
color: #df5a13;
font-weight: bold;
}
td.details {
vertical-align: top;
}
th.amount, td.amount, th.quantity, td.quantity {
text-align: right;
}
@ -143,14 +146,27 @@
</thead>
<tbody>
{% endifchanged %}
<tr>
<tr {% if not line.details %}class="new-event"{% else %}{% ifchanged line.label %}class="new-event"{% endifchanged %}{% endif %}>
<td class="description">
{% if not line.details %}{{ line.event_date|date:"d/m/Y" }} - {% endif %}{{ line.label }}
<br />
{% if line.details %}
{% ifchanged line.label %}
{{ line.label }}
<br />
{% if line.agenda %}
<i>{{ line.agenda.label }}</i>
<br />
{% endif %}
{% endifchanged %}
{% else %}
{{ line.event_date|date:"d/m/Y" }} - {{ line.label }}
<br />
{% if line.agenda %}
<i>{{ line.agenda.label }}</i>
<br />
{% endif %}
{% endif %}
{% if line.details.check_type_label %}
{{ line.details.check_type_label }}
{% elif line.details.status == 'presence' %}
{% trans "Presence" %}
{% elif line.details.status == 'absence' %}
{% trans "Absence" %}
{% endif %}
@ -160,9 +176,11 @@
{% for d in line.details.dates %}{{ d|parse_date|date:"Dd" }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% endif %}
</td>
<td class="amount unit-amount">{% blocktrans with amount=line.unit_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
<td class="quantity">{{ line.quantity }}</td>
<td class="amount total-amount">{% blocktrans with amount=line.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
{% if line.details.check_type_label or line.details.status != 'absence' %}
<td class="amount unit-amount">{% blocktrans with amount=line.unit_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
<td class="quantity">{{ line.quantity }}</td>
<td class="amount total-amount">{% blocktrans with amount=line.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>

View File

@ -60,19 +60,28 @@
<td class="amount" colspan="2">{% trans "Subtotal" %}</td>
</tr>
{% endifchanged %}
{% ifchanged line.agenda %}
{% if line.agenda or not forloop.first %}
<tr class="line" data-related-invoicing-element-id="{{ invoice.pk }}">
<td colspan="6" class="activity">{{ line.agenda.label|default:"&nbsp;" }}</td>
</tr>
{% endif %}
{% endifchanged %}
<tr class="line {% if pool.draft and forloop.last %}last-line{% endif %}" data-related-invoicing-element-id="{{ invoice.pk }}">
<td class="details" colspan="2">
<td class="event">
{% if line.pool %}<a href="{{ journal_url }}?invoice_line={{ line.pk }}">{% endif %}{% if not line.details %}{{ line.event_date|date:"d/m/Y" }} - {% endif %}{{ line.label }}{% if line.pool %}</a>{% endif %}
</td>
<td class="details">
<small>
{% if line.details.check_type_label %}
- {{ line.details.check_type_label }}
{{ line.details.check_type_label }}
{% elif line.details.status == 'presence' %}
- {% trans "Presence" %}
{% trans "Presence" %}
{% elif line.details.status == 'absence' %}
- {% trans "Absence" %}
{% trans "Absence" %}
{% endif %}
{% if line.details.dates %}
- {% for d in line.details.dates %}{{ d|parse_date|date:"Dd" }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% for d in line.details.dates %}{{ d|parse_date|date:"Dd" }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% endif %}
</small>
</td>

View File

@ -16,7 +16,7 @@
}
h1 {
color: #1ee494;
color: #df5a13;
font-family: Pacifico;
margin: 0.4em 0;
font-size: 12pt;

View File

@ -16,7 +16,7 @@
}
h1 {
color: #1ee494;
color: #df5a13;
font-family: Pacifico;
margin: 0.4em 0;
font-size: 12pt;

View File

@ -19,6 +19,7 @@ import datetime
from django.db import transaction
from django.test.client import RequestFactory
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _
from lingo.agendas.chrono import get_check_status, get_subscriptions
@ -353,6 +354,10 @@ def generate_invoices_from_lines(all_lines, pool):
check_type = CheckType.objects.get(slug=key[4], group__slug=key[5], kind=key[3])
except CheckType.DoesNotExist:
check_type = None
event_datetime = localtime(
datetime.datetime.fromisoformat(journal_lines[0].event['start_datetime'])
)
event_time = event_datetime.time().isoformat()
invoice_line = DraftInvoiceLine.objects.create(
invoice=invoice,
event_date=pool.campaign.date_start,
@ -368,6 +373,7 @@ def generate_invoices_from_lines(all_lines, pool):
'check_type_group': key[5],
'check_type_label': check_type.label if check_type else key[4],
'dates': sorted(li.event_date for li in journal_lines),
'event_time': event_time,
},
user_external_id=first_line.user_external_id,
user_first_name=first_line.user_first_name,

View File

@ -282,7 +282,7 @@ class InvoiceLineListView(ListView):
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return self.invoice.lines.all().order_by('user_external_id', 'event_date', 'pk')
return self.invoice.get_grouped_and_ordered_lines()
def get_context_data(self, **kwargs):
kwargs['regie'] = self.pool.campaign.regie

View File

@ -388,7 +388,7 @@ class RegieInvoiceLineListView(ListView):
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return self.invoice.lines.all().order_by('user_external_id', 'event_date', 'pk')
return self.invoice.get_grouped_and_ordered_lines()
def get_context_data(self, **kwargs):
kwargs['regie'] = self.regie

View File

@ -143,6 +143,9 @@ table.invoicing-element-list {
&.quantity, &.amount, &.with-togglable {
text-align: right;
}
&.activity {
font-style: italic;
}
}
}

View File

@ -1532,18 +1532,20 @@ def test_detail_pool_invoices(app, admin_user, draft):
)
lines_resp = app.get(lines_url)
if draft:
assert len(lines_resp.pyquery('tr')) == 9
assert len(lines_resp.pyquery('tr[data-related-invoicing-element-id="%s"]' % invoice1.pk)) == 9
assert len(lines_resp.pyquery('tr')) == 11
assert len(lines_resp.pyquery('tr[data-related-invoicing-element-id="%s"]' % invoice1.pk)) == 11
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: yes',
'Direct debit: no',
'User1 Name1',
'Description\nAmount\nQuantity\nSubtotal',
'Event A - Foo! - Thu01, Fri02, Sat03\n1.00€\n1\n1.00€',
'Event A - Presence - Sun04, Mon05\n3.00€\n1\n3.00€',
'Agenda A',
'Event A\nFoo! Thu01, Fri02, Sat03\n1.00€\n1\n1.00€',
'Event A\nPresence Sun04, Mon05\n3.00€\n1\n3.00€',
'User2 Name2',
'Description\nAmount\nQuantity\nSubtotal',
'Event B - Foo! - Thu01, Fri02, Sat03\n2.00€\n1\n2.00€',
'Agenda B',
'Event B\nFoo! Thu01, Fri02, Sat03\n2.00€\n1\n2.00€',
]
assert [PyQuery(a).attr('href') for a in lines_resp.pyquery('tr a')] == [
'/manage/invoicing/regie/%s/campaign/%s/pool/%s/journal/?user_external_id=user:1'
@ -1558,8 +1560,8 @@ def test_detail_pool_invoices(app, admin_user, draft):
% (regie.pk, campaign.pk, pool.pk, invoice_line12.pk),
]
else:
assert len(lines_resp.pyquery('tr')) == 17
assert len(lines_resp.pyquery('tr[data-related-invoicing-element-id="%s"]' % invoice1.pk)) == 17
assert len(lines_resp.pyquery('tr')) == 19
assert len(lines_resp.pyquery('tr[data-related-invoicing-element-id="%s"]' % invoice1.pk)) == 19
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: yes',
'Direct debit: no',
@ -1568,11 +1570,13 @@ def test_detail_pool_invoices(app, admin_user, draft):
'Due date: 31/10/2022',
'User1 Name1',
'Description\nAmount\nQuantity\nSubtotal',
'Event A - Foo! - Thu01, Fri02, Sat03\n1.00€\n1\n1.00€',
'Event A - Presence - Sun04, Mon05\n3.00€\n1\n3.00€',
'Agenda A',
'Event A\nFoo! Thu01, Fri02, Sat03\n1.00€\n1\n1.00€',
'Event A\nPresence Sun04, Mon05\n3.00€\n1\n3.00€',
'User2 Name2',
'Description\nAmount\nQuantity\nSubtotal',
'Event B - Foo! - Thu01, Fri02, Sat03\n2.00€\n1\n2.00€',
'Agenda B',
'Event B\nFoo! Thu01, Fri02, Sat03\n2.00€\n1\n2.00€',
'Payments',
'Payment\nDate\nType\nAmount',
'R%02d-%s-0000001\n%s\nCash\n1.00€'
@ -1628,12 +1632,13 @@ def test_detail_pool_invoices(app, admin_user, draft):
)
lines_resp = app.get(lines_url)
if draft:
assert len(lines_resp.pyquery('tr')) == 5
assert len(lines_resp.pyquery('tr')) == 6
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: no',
'Direct debit: no',
'User1 Name1',
'Description\nAmount\nQuantity\nSubtotal',
'Agenda A',
'01/09/2022 - Event AA\n1.00€\n1\n1.00€',
]
assert [PyQuery(a).attr('href') for a in lines_resp.pyquery('tr a')] == [
@ -1643,7 +1648,7 @@ def test_detail_pool_invoices(app, admin_user, draft):
% (regie.pk, campaign.pk, pool.pk, invoice_line21.pk),
]
else:
assert len(lines_resp.pyquery('tr')) == 13
assert len(lines_resp.pyquery('tr')) == 14
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: no',
'Direct debit: no',
@ -1652,6 +1657,7 @@ def test_detail_pool_invoices(app, admin_user, draft):
'Due date: 31/10/2022',
'User1 Name1',
'Description\nAmount\nQuantity\nSubtotal',
'Agenda A',
'01/09/2022 - Event AA\n1.00€\n1\n1.00€',
'Payments',
'Payment\nDate\nType\nAmount',

View File

@ -826,7 +826,7 @@ def test_regie_invoices(app, admin_user, orphan):
invoice1.pk,
)
lines_resp = app.get(lines_url)
assert len(lines_resp.pyquery('tr')) == 17
assert len(lines_resp.pyquery('tr')) == 19
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: yes',
'Direct debit: no',
@ -835,11 +835,13 @@ def test_regie_invoices(app, admin_user, orphan):
'Due date: 31/10/2022',
'User1 Name1',
'Description\nAmount\nQuantity\nSubtotal',
'Event A - Foo! - Thu01, Fri02, Sat03\n1.00€\n1\n1.00€',
'Event A - Presence - Sun04, Mon05\n3.00€\n1\n3.00€',
'Agenda A',
'Event A\nFoo! Thu01, Fri02, Sat03\n1.00€\n1\n1.00€',
'Event A\nPresence Sun04, Mon05\n3.00€\n1\n3.00€',
'User2 Name2',
'Description\nAmount\nQuantity\nSubtotal',
'Event B - Foo! - Thu01, Fri02, Sat03\n2.00€\n1\n2.00€',
'Agenda B',
'Event B\nFoo! Thu01, Fri02, Sat03\n2.00€\n1\n2.00€',
'Payments',
'Payment\nDate\nType\nAmount',
'R%02d-%s-0000001\n%s\nCash\n1.00€'
@ -894,7 +896,7 @@ def test_regie_invoices(app, admin_user, orphan):
invoice2.pk,
)
lines_resp = app.get(lines_url)
assert len(lines_resp.pyquery('tr')) == 14
assert len(lines_resp.pyquery('tr')) == 15
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: no',
'Direct debit: no',
@ -904,6 +906,7 @@ def test_regie_invoices(app, admin_user, orphan):
'Debit date: 15/11/2022',
'User1 Name1',
'Description\nAmount\nQuantity\nSubtotal',
'Agenda A',
'01/09/2022 - Event AA\n1.00€\n1\n1.00€',
'Payments',
'Payment\nDate\nType\nAmount',

View File

@ -2003,7 +2003,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1 + i),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'presence'}
},
@ -2022,7 +2026,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'presence'}
},
@ -2041,7 +2049,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-2', 'primary_event': 'event-1'}, # another agenda
event={
'agenda': 'agenda-2',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
}, # another agenda
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'presence'}
},
@ -2060,7 +2072,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 2',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-2'}, # another event
event={
'agenda': 'agenda-1',
'primary_event': 'event-2',
'start_datetime': '2022-09-01T12:00:00+02:00',
}, # another event
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'presence'}
},
@ -2079,7 +2095,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={
'booking_details': {'check_type': 'bar', 'check_type_group': 'foobar', 'status': 'presence'}
}, # another check_type
@ -2098,7 +2118,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobaz', 'status': 'presence'}
}, # another check_type_group
@ -2117,7 +2141,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'absence'}
}, # another status
@ -2136,7 +2164,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'presence'}
},
@ -2156,7 +2188,10 @@ def test_generate_invoices_from_lines_aggregation():
label='Foobar',
slug='foobar',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1'}, # no primary_event: non reccuring event, or injected line
event={
'agenda': 'agenda-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
}, # no primary_event: non reccuring event, or injected line
pricing_data={
'booking_details': {'check_type': 'foo', 'check_type_group': 'foobar', 'status': 'presence'}
},
@ -2175,7 +2210,11 @@ def test_generate_invoices_from_lines_aggregation():
DraftJournalLine.objects.create(
label='Event 1',
event_date=datetime.date(2022, 9, 1),
event={'agenda': 'agenda-1', 'primary_event': 'event-1'},
event={
'agenda': 'agenda-1',
'primary_event': 'event-1',
'start_datetime': '2022-09-01T12:00:00+02:00',
},
pricing_data={'booking_details': {'status': 'not-booked'}}, # not booked, ignored
amount=1,
user_external_id='user:1',
@ -2225,6 +2264,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'Foo!',
'dates': ['2022-09-01', '2022-09-02', '2022-09-03'],
'event_time': '12:00:00',
}
assert iline1.user_external_id == 'user:1'
assert iline1.user_first_name == 'UserFirst1'
@ -2272,6 +2312,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'Foo!',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline3.details == {
'agenda': 'agenda-2',
@ -2281,6 +2322,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'Foo!',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline4.details == {
'agenda': 'agenda-1',
@ -2290,6 +2332,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'Foo!',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline5.details == {
'agenda': 'agenda-1',
@ -2299,6 +2342,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'bar',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline6.details == {
'agenda': 'agenda-1',
@ -2308,6 +2352,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobaz',
'check_type_label': 'foo',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline7.details == {
'agenda': 'agenda-1',
@ -2317,6 +2362,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'foo',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline8.details == {
'agenda': 'agenda-1',
@ -2326,6 +2372,7 @@ def test_generate_invoices_from_lines_aggregation():
'check_type_group': 'foobar',
'check_type_label': 'Foo!',
'dates': ['2022-09-01'],
'event_time': '12:00:00',
}
assert iline9.details == {}