diff --git a/modules/project_invoice/invoice.py b/modules/project_invoice/invoice.py
index 0fc5b547fe..769adb99b9 100644
--- a/modules/project_invoice/invoice.py
+++ b/modules/project_invoice/invoice.py
@@ -1,13 +1,114 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
+
+import datetime as dt
+
+from sql.aggregate import Sum
+
+from trytond.i18n import gettext
+from trytond.model import fields
+from trytond.modules.account_invoice.exceptions import (
+ InvoiceLineValidationError)
from trytond.pool import Pool, PoolMeta
-from trytond.tools import grouped_slice
+from trytond.tools import grouped_slice, reduce_ids
from trytond.transaction import Transaction
class InvoiceLine(metaclass=PoolMeta):
__name__ = 'account.invoice.line'
+ project_invoice_works = fields.One2Many(
+ 'project.work', 'invoice_line',
+ "Project Invoice Works", readonly=True)
+ project_invoice_progresses = fields.One2Many(
+ 'project.work.invoiced_progress', 'invoice_line',
+ "Project Invoice Progresses", readonly=True)
+ project_invoice_timesheet_duration = fields.Function(
+ fields.TimeDelta("Project Invoice Timesheet Duration"),
+ 'get_project_invoice_timesheet_duration')
+
+ @classmethod
+ def check_validate_project_invoice_quantity(cls, lines, field_names):
+ pool = Pool()
+ Lang = pool.get('ir.lang')
+ if field_names and not (field_names & {
+ 'quantity', 'project_invoice_works'}):
+ return
+ for line in lines:
+ project_invoice_quantity = line.project_invoice_quantity
+ if project_invoice_quantity is None:
+ continue
+ if line.unit:
+ project_invoice_quantity = line.unit.round(
+ project_invoice_quantity)
+ if line.quantity != project_invoice_quantity:
+ lang = Lang.get()
+ if line.unit:
+ quantity = lang.format_number_symbol(
+ project_invoice_quantity, line.unit)
+ else:
+ quantity = lang.format_number(project_invoice_quantity)
+ raise InvoiceLineValidationError(gettext(
+ 'project_invoice.msg_project_invoice_line_quantity',
+ invoice_line=line.rec_name,
+ quantity=quantity,
+ ))
+
+ @property
+ def project_invoice_quantity(self):
+ quantity = None
+ for work in self.project_invoice_works:
+ if quantity is None:
+ quantity = 0
+ if work.price_list_hour:
+ quantity += work.effort_hours
+ else:
+ quantity += 1
+ for progress in self.project_invoice_progresses:
+ if quantity is None:
+ quantity = 0
+ work = progress.work
+ if work.price_list_hour:
+ quantity += progress.progress * work.effort_hours
+ else:
+ quantity += progress.progress
+ if self.project_invoice_timesheet_duration is not None:
+ if quantity is None:
+ quantity = 0
+ quantity += (
+ self.project_invoice_timesheet_duration.total_seconds()
+ / 60 / 60)
+ return quantity
+
+ @classmethod
+ def get_project_invoice_timesheet_duration(cls, lines, name):
+ pool = Pool()
+ TimesheetLine = pool.get('timesheet.line')
+ cursor = Transaction().connection.cursor()
+ ts_line = TimesheetLine.__table__()
+
+ durations = dict.fromkeys(map(int, lines))
+ query = ts_line.select(
+ ts_line.invoice_line, Sum(ts_line.duration),
+ group_by=ts_line.invoice_line)
+ for sub_lines in grouped_slice(lines):
+ query.where = reduce_ids(
+ ts_line.invoice_line, map(int, sub_lines))
+ cursor.execute(*query)
+
+ for line_id, duration in cursor:
+ # SQLite uses float for SUM
+ if (duration is not None
+ and not isinstance(duration, dt.timedelta)):
+ duration = dt.timedelta(seconds=duration)
+ durations[line_id] = duration
+ return durations
+
+ @classmethod
+ def validate_fields(cls, lines, field_names):
+ super().validate_fields(lines, field_names)
+ cls.check_validate_project_invoice_quantity(lines, field_names)
+
@classmethod
def delete(cls, lines):
pool = Pool()
diff --git a/modules/project_invoice/message.xml b/modules/project_invoice/message.xml
index 524ee4be69..14f7feef9c 100644
--- a/modules/project_invoice/message.xml
+++ b/modules/project_invoice/message.xml
@@ -30,5 +30,8 @@ this repository contains the full copyright notices and license terms. -->
You cannot delete work "%(work)s" because it has been invoiced.
+
+ The quantity of project invoice line "%(invoice_line)s" must be "%(quantity)s"
+
diff --git a/modules/project_invoice/tests/scenario_project_invoice_effort.rst b/modules/project_invoice/tests/scenario_project_invoice_effort.rst
index c0fd8c8860..ddad0e454d 100644
--- a/modules/project_invoice/tests/scenario_project_invoice_effort.rst
+++ b/modules/project_invoice/tests/scenario_project_invoice_effort.rst
@@ -192,3 +192,16 @@ Invoice again project::
Decimal('0.00')
>>> project.invoiced_amount
Decimal('170.00')
+
+Try to change invoice line quantity::
+
+ >>> set_user(1)
+ >>> ProjectWork = Model.get('project.work')
+ >>> task = ProjectWork(task.id)
+ >>> task.invoice_line.quantity = 1
+ >>> task.invoice_line.save() # doctest: +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ InvoiceLineValidationError: ...
+ >>> task.invoice_line.quantity = 5
+ >>> task.invoice_line.save()
diff --git a/modules/project_invoice/tests/scenario_project_invoice_progress.rst b/modules/project_invoice/tests/scenario_project_invoice_progress.rst
index f4182b9a56..019877a1d3 100644
--- a/modules/project_invoice/tests/scenario_project_invoice_progress.rst
+++ b/modules/project_invoice/tests/scenario_project_invoice_progress.rst
@@ -194,3 +194,17 @@ Invoice again project::
Decimal('0.00')
>>> project.invoiced_amount
Decimal('136.00')
+
+Try to change invoice line quantity::
+
+ >>> set_user(1)
+ >>> ProjectWork = Model.get('project.work')
+ >>> task = ProjectWork(task.id)
+ >>> invoice_line = task.invoiced_progress[0].invoice_line
+ >>> invoice_line.quantity = 2.
+ >>> invoice_line.save() # doctest: +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ InvoiceLineValidationError: ...
+ >>> invoice_line.quantity = 2.5
+ >>> invoice_line.save()
diff --git a/modules/project_invoice/tests/scenario_project_invoice_timesheet.rst b/modules/project_invoice/tests/scenario_project_invoice_timesheet.rst
index 4dab1b1101..82557002be 100644
--- a/modules/project_invoice/tests/scenario_project_invoice_timesheet.rst
+++ b/modules/project_invoice/tests/scenario_project_invoice_timesheet.rst
@@ -225,3 +225,16 @@ Invoice again project::
>>> _, _, invoice = Invoice.find([], order=[('id', 'ASC')])
>>> invoice.total_amount
Decimal('80.00')
+
+Try to change invoice line quantity::
+
+ >>> set_user(1)
+ >>> TimesheetLine = Model.get('timesheet.line')
+ >>> line = TimesheetLine(line.id)
+ >>> line.invoice_line.quantity = 5
+ >>> line.invoice_line.save() # doctest: +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ InvoiceLineValidationError: ...
+ >>> line.invoice_line.quantity = 4
+ >>> line.invoice_line.save()