diff --git a/CHANGELOG b/CHANGELOG
index a67058f7a4..e48b5401df 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,4 @@
+* Manage invoicing of non-time based products
* Base invoicing on quantity and price
* Remove duration fields
diff --git a/message.xml b/message.xml
index a783da828d..211012405d 100644
--- a/message.xml
+++ b/message.xml
@@ -9,8 +9,11 @@ this repository contains the full copyright notices and license terms. -->
You cannot delete an invoiced line.
-
- There is no product on work "%(work)s".
+
+ To invoice work "%(work)s" you must define an account revenue for product "%(product)s".
+
+
+ To invoice work "%(work)s" you must configure a default account revenue.
There is no list price on work "%(work)s".
diff --git a/project.py b/project.py
index c083e4f4ba..11fa826406 100644
--- a/project.py
+++ b/project.py
@@ -15,7 +15,7 @@ from sql.operators import Concat
from trytond.i18n import gettext
from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import PoolMeta
-from trytond.pyson import Eval, Bool, PYSONEncoder
+from trytond.pyson import Eval, Bool, If, Id, PYSONEncoder
from trytond.pool import Pool
from trytond.transaction import Transaction
from trytond.wizard import Wizard, StateAction
@@ -36,8 +36,14 @@ class Effort:
def _get_quantity_to_invoice_effort(cls, works):
quantities = {}
for work in works:
- if work.progress == 1 and not work.invoice_line:
- quantities[work.id] = work.effort_hours
+ if (work.progress == 1
+ and work.list_price
+ and not work.invoice_line):
+ if work.price_list_hour:
+ quantity = work.effort_hours
+ else:
+ quantity = 1
+ quantities[work.id] = work.unit_to_invoice.round(quantity)
return quantities
@classmethod
@@ -58,9 +64,14 @@ class Effort:
invoice_line = id2invoice_lines[work.invoice_line.id]
invoice_currency = (invoice_line.invoice.currency
if invoice_line.invoice else invoice_line.currency)
- amounts[work.id] = Currency.compute(invoice_currency,
- Decimal(str(work.effort_hours)) * invoice_line.unit_price,
- currency)
+ if work.price_list_hour:
+ amount = (
+ Decimal(str(work.effort_hours))
+ * invoice_line.unit_price)
+ else:
+ amount = invoice_line.unit_price
+ amounts[work.id] = Currency.compute(
+ invoice_currency, amount, currency)
else:
amounts[work.id] = Decimal(0)
return amounts
@@ -112,13 +123,19 @@ class Progress:
(work.progress or 0)
- invoiced_progress.get(work.id, 0.0))
if delta > 0:
- quantities[work.id] = delta * work.effort_hours
+ quantity = delta
+ if work.price_list_hour:
+ quantity *= work.effort_hours
+ quantities[work.id] = work.unit_to_invoice.round(quantity)
return quantities
@property
def progress_to_invoice(self):
if self.quantity_to_invoice:
- return self.quantity_to_invoice / self.effort_hours
+ if self.price_list_hour:
+ return self.quantity_to_invoice / self.effort_hours
+ else:
+ return self.quantity_to_invoice
@classmethod
def _get_invoiced_amount_progress(cls, works):
@@ -150,8 +167,10 @@ class Progress:
for work_id, amount in cursor.fetchall():
if not isinstance(amount, Decimal):
amount = Decimal(str(amount))
- amounts[work_id] = (
- amount * Decimal(str(ids2work[work_id].effort_hours)))
+ work = ids2work[work_id]
+ if work.price_list_hour:
+ amount *= Decimal(str(work.effort_hours))
+ amounts[work_id] = amount
cursor.execute(*table.join(company,
condition=table.company == company.id
@@ -188,6 +207,14 @@ class Timesheet:
super().__setup__()
cls.project_invoice_method.selection.append(
('timesheet', 'On Timesheet'))
+ cls.product.domain = [
+ cls.product.domain,
+ If(Eval('invoice_method') == 'timesheet',
+ ('default_uom_category', '=', Id('product', 'uom_cat_time')),
+ ()),
+ ]
+ if 'invoice_method' not in cls.product.depends:
+ cls.product.depends.append('invoice_method')
@classmethod
def _get_quantity_to_invoice_timesheet(cls, works):
@@ -215,7 +242,7 @@ class Timesheet:
duration = durations[work.id]
if work.list_price:
hours = duration.total_seconds() / 60 / 60
- quantities[work.id] = hours
+ quantities[work.id] = work.unit_to_invoice.round(hours)
return quantities
@classmethod
@@ -481,7 +508,8 @@ class Work(Effort, Progress, Timesheet, metaclass=PoolMeta):
"Return a invoice line for the lines"
pool = Pool()
InvoiceLine = pool.get('account.invoice.line')
- Uom = pool.get('product.uom')
+ AccountConfiguration = pool.get('account.configuration')
+ account_config = AccountConfiguration(1)
quantity = sum(l['quantity'] for l in lines)
product = key['product']
@@ -489,24 +517,32 @@ class Work(Effort, Progress, Timesheet, metaclass=PoolMeta):
invoice_line = InvoiceLine()
invoice_line.type = 'line'
invoice_line.description = key['description']
- invoice_line.account = product.account_revenue_used
- if (key['unit']
- and key['unit'].category == product.default_uom.category):
- invoice_line.product = product
- invoice_line.unit_price = Uom.compute_price(
- key['unit'], key['unit_price'], product.default_uom)
- invoice_line.quantity = Uom.compute_qty(
- key['unit'], quantity, product.default_uom)
- invoice_line.unit = product.default_uom
+ if product:
+ invoice_line.account = product.account_revenue_used
+ if not invoice_line.account:
+ raise InvoicingError(
+ gettext(
+ 'project_invoice.msg_product_missing_account_revenue',
+ work=self.rec_name,
+ product=product.rec_name))
else:
- invoice_line.unit_price = key['unit_price']
- invoice_line.quantity = quantity
- invoice_line.unit = key['unit']
+ invoice_line.account = account_config.get_multivalue(
+ 'default_category_account_revenue')
+ if not invoice_line.account:
+ raise InvoicingError(
+ gettext('project_invoice.msg_missing_account_revenue',
+ work=self.rec_name))
+ invoice_line.product = product
+ invoice_line.unit_price = key['unit_price']
+ invoice_line.quantity = quantity
+ invoice_line.unit = key['unit']
taxes = []
pattern = invoice_line._get_tax_rule_pattern()
party = invoice.party
- for tax in product.customer_taxes_used:
+ original_taxes = (
+ product.customer_taxes_used if product else invoice.account.taxes)
+ for tax in original_taxes:
if party.customer_tax_rule:
tax_ids = party.customer_tax_rule.apply(tax, pattern)
if tax_ids:
@@ -543,11 +579,7 @@ class Work(Effort, Progress, Timesheet, metaclass=PoolMeta):
def _get_lines_to_invoice(self):
if self.quantity_to_invoice:
- if not self.product:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_product',
- work=self.rec_name))
- elif self.invoice_unit_price is None:
+ if self.invoice_unit_price is None:
raise InvoicingError(
gettext('project_invoice.msg_missing_list_price',
work=self.rec_name))
@@ -570,7 +602,10 @@ class Work(Effort, Progress, Timesheet, metaclass=PoolMeta):
pool = Pool()
ModelData = pool.get('ir.model.data')
Uom = pool.get('product.uom')
- return Uom(ModelData.get_id('product', 'uom_hour'))
+ if self.price_list_hour:
+ return Uom(ModelData.get_id('product', 'uom_hour'))
+ elif self.product:
+ return self.product.default_uom
def get_origins_to_invoice(self):
return super().get_origins_to_invoice()
diff --git a/tests/scenario_project_invoice_effort.rst b/tests/scenario_project_invoice_effort.rst
index f1fb8891c8..d8306dbd06 100644
--- a/tests/scenario_project_invoice_effort.rst
+++ b/tests/scenario_project_invoice_effort.rst
@@ -87,10 +87,11 @@ Create account category::
>>> account_category.account_revenue = revenue
>>> account_category.save()
-Create product::
+Create products::
>>> ProductUom = Model.get('product.uom')
>>> hour, = ProductUom.find([('name', '=', 'Hour')])
+ >>> unit, = ProductUom.find([('name', '=', 'Unit')])
>>> ProductTemplate = Model.get('product.template')
>>> template = ProductTemplate()
@@ -102,6 +103,15 @@ Create product::
>>> template.save()
>>> product, = template.products
+ >>> template = ProductTemplate()
+ >>> template.name = 'Service Fixed'
+ >>> template.default_uom = unit
+ >>> template.type = 'service'
+ >>> template.list_price = Decimal('50')
+ >>> template.account_category = account_category
+ >>> template.save()
+ >>> product_fixed, = template.products
+
Create a Project::
>>> set_user(project_user)
@@ -122,8 +132,13 @@ Create a Project::
>>> task_no_effort.name = "Task 2"
>>> task_no_effort.type = 'task'
>>> task_no_effort.effort_duration = None
+ >>> task_fixed = project.children.new()
+ >>> task_fixed.name = "Task 2"
+ >>> task_fixed.type = 'task'
+ >>> task_fixed.effort_duration = datetime.timedelta(hours=2)
+ >>> task_fixed.product = product_fixed
>>> project.save()
- >>> task, task_no_effort = project.children
+ >>> task, task_no_effort, task_fixed = project.children
Check project amounts::
@@ -160,6 +175,8 @@ Do project::
>>> set_user(project_user)
>>> task_no_effort.progress = 1
>>> task_no_effort.save()
+ >>> task_fixed.progress = 1
+ >>> task_fixed.save()
>>> project.progress = 1
>>> project.save()
@@ -167,7 +184,7 @@ Check project amounts::
>>> project.reload()
>>> project.amount_to_invoice
- Decimal('20.00')
+ Decimal('70.00')
>>> project.invoiced_amount
Decimal('100.00')
@@ -178,4 +195,4 @@ Invoice again project::
>>> project.amount_to_invoice
Decimal('0.00')
>>> project.invoiced_amount
- Decimal('120.00')
+ Decimal('170.00')
diff --git a/tests/scenario_project_invoice_progress.rst b/tests/scenario_project_invoice_progress.rst
index 4be7e14b2d..a8af65b887 100644
--- a/tests/scenario_project_invoice_progress.rst
+++ b/tests/scenario_project_invoice_progress.rst
@@ -91,6 +91,10 @@ Create product::
>>> ProductUom = Model.get('product.uom')
>>> hour, = ProductUom.find([('name', '=', 'Hour')])
+ >>> unit, = ProductUom.find([('name', '=', 'Unit')])
+ >>> unit.rounding = 0.01
+ >>> unit.digits = 2
+ >>> unit.save()
>>> ProductTemplate = Model.get('product.template')
>>> template = ProductTemplate()
@@ -102,6 +106,15 @@ Create product::
>>> template.save()
>>> product, = template.products
+ >>> template = ProductTemplate()
+ >>> template.name = 'Service Fixed'
+ >>> template.default_uom = unit
+ >>> template.type = 'service'
+ >>> template.list_price = Decimal('50')
+ >>> template.account_category = account_category
+ >>> template.save()
+ >>> product_fixed, = template.products
+
Create a Project::
>>> set_user(project_user)
@@ -114,14 +127,18 @@ Create a Project::
>>> project.project_invoice_method = 'progress'
>>> project.product = product
>>> project.effort_duration = datetime.timedelta(hours=1)
- >>> task = ProjectWork()
+ >>> task = project.children.new()
>>> task.name = 'Task 1'
>>> task.type = 'task'
>>> task.product = product
>>> task.effort_duration = datetime.timedelta(hours=5)
- >>> project.children.append(task)
+ >>> task_fixed = project.children.new()
+ >>> task_fixed.name = 'Task 2'
+ >>> task_fixed.type = 'task'
+ >>> task_fixed.product = product
+ >>> task_fixed.product = product_fixed
>>> project.save()
- >>> task, = project.children
+ >>> task, task_fixed = project.children
Check project amounts::
@@ -135,12 +152,14 @@ Do 50% of task::
>>> task.progress = 0.5
>>> task.save()
+ >>> task_fixed.progress = 0.5
+ >>> task_fixed.save()
Check project amounts::
>>> project.reload()
>>> project.amount_to_invoice
- Decimal('50.00')
+ Decimal('75.00')
>>> project.invoiced_amount
Decimal('0.00')
@@ -151,13 +170,15 @@ Invoice project::
>>> project.amount_to_invoice
Decimal('0.00')
>>> project.invoiced_amount
- Decimal('50.00')
+ Decimal('75.00')
-Do 75% of task and 80% of project::
+Do 75% and 90% of tasks and 80% of project::
>>> set_user(project_user)
>>> task.progress = 0.75
>>> task.save()
+ >>> task_fixed.progress = 0.9
+ >>> task_fixed.save()
>>> project.progress = 0.80
>>> project.save()
@@ -165,9 +186,9 @@ Check project amounts::
>>> project.reload()
>>> project.amount_to_invoice
- Decimal('41.00')
+ Decimal('61.00')
>>> project.invoiced_amount
- Decimal('50.00')
+ Decimal('75.00')
Invoice again project::
@@ -176,4 +197,4 @@ Invoice again project::
>>> project.amount_to_invoice
Decimal('0.00')
>>> project.invoiced_amount
- Decimal('91.00')
+ Decimal('136.00')
diff --git a/tryton.cfg b/tryton.cfg
index b63779f9cf..b09a7cf9ec 100644
--- a/tryton.cfg
+++ b/tryton.cfg
@@ -7,6 +7,7 @@ depends:
timesheet
account
account_invoice
+ account_product
product
xml:
project.xml