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