changeset 0367786244cb in modules/project_invoice:default
details:
https://hg.tryton.org/modules/project_invoice?cmd=changeset;node=0367786244cb
description:
Remove duration fields and base invoicing on quantity and price
issue7776
review64441002
diffstat:
CHANGELOG | 3 +
__init__.py | 8 +-
project.py | 643 ++++++++++++++++++++++
project.xml | 56 +
tests/scenario_project_invoice_effort.rst | 36 +-
tests/scenario_project_invoice_multiple_party.rst | 22 +-
tests/scenario_project_invoice_progress.rst | 36 +-
tests/scenario_project_invoice_timesheet.rst | 28 +-
tryton.cfg | 1 -
view/work_form.xml | 12 +-
view/work_invoiced_progress_form.xml | 7 +-
view/work_invoiced_progress_list.xml | 4 +-
work.py | 636 ---------------------
work.xml | 62 --
14 files changed, 763 insertions(+), 791 deletions(-)
diffs (1788 lines):
diff -r 5a78f819e58c -r 0367786244cb CHANGELOG
--- a/CHANGELOG Sun Mar 01 16:12:39 2020 +0100
+++ b/CHANGELOG Fri Mar 06 23:34:09 2020 +0100
@@ -1,3 +1,6 @@
+* Base invoicing on quantity and price
+* Remove duration fields
+
Version 5.4.0 - 2019-11-04
* Bug fixes (see mercurial logs for details)
* Add invoice method to project list view
diff -r 5a78f819e58c -r 0367786244cb __init__.py
--- a/__init__.py Sun Mar 01 16:12:39 2020 +0100
+++ b/__init__.py Fri Mar 06 23:34:09 2020 +0100
@@ -2,18 +2,18 @@
# this repository contains the full copyright notices and license terms.
from trytond.pool import Pool
-from . import work
+from . import project
from . import timesheet
from . import invoice
def register():
Pool.register(
- work.Work,
- work.WorkInvoicedProgress,
+ project.Work,
+ project.WorkInvoicedProgress,
timesheet.Line,
invoice.InvoiceLine,
module='project_invoice', type_='model')
Pool.register(
- work.OpenInvoice,
+ project.OpenInvoice,
module='project_invoice', type_='wizard')
diff -r 5a78f819e58c -r 0367786244cb project.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/project.py Fri Mar 06 23:34:09 2020 +0100
@@ -0,0 +1,643 @@
+# This file is part of Tryton. The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+
+from itertools import groupby
+from collections import defaultdict
+from decimal import Decimal
+import datetime
+
+from sql import Null
+from sql.aggregate import Sum
+from sql.functions import Extract
+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.pool import Pool
+from trytond.transaction import Transaction
+from trytond.wizard import Wizard, StateAction
+from trytond.tools import reduce_ids, grouped_slice
+
+from .exceptions import InvoicingError
+
+
+class Effort:
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls.project_invoice_method.selection.append(
+ ('effort', "On Effort"))
+
+ @classmethod
+ 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
+ return quantities
+
+ @classmethod
+ def _get_invoiced_amount_effort(cls, works):
+ pool = Pool()
+ InvoiceLine = pool.get('account.invoice.line')
+ Currency = pool.get('currency.currency')
+
+ invoice_lines = InvoiceLine.browse([
+ w.invoice_line.id for w in works
+ if w.invoice_line])
+
+ id2invoice_lines = dict((l.id, l) for l in invoice_lines)
+ amounts = {}
+ for work in works:
+ currency = work.company.currency
+ if work.invoice_line:
+ 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)
+ else:
+ amounts[work.id] = Decimal(0)
+ return amounts
+
+ def get_origins_to_invoice(self):
+ try:
+ origins = super().get_origins_to_invoice()
+ except AttributeError:
+ origins = []
+ if self.invoice_method == 'effort':
+ origins.append(self)
+ return origins
+
+
+class Progress:
+
+ invoiced_progress = fields.One2Many('project.work.invoiced_progress',
+ 'work', 'Invoiced Progress', readonly=True)
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls.project_invoice_method.selection.append(
+ ('progress', 'On Progress'))
+
+ @classmethod
+ def _get_quantity_to_invoice_progress(cls, works):
+ pool = Pool()
+ Progress = pool.get('project.work.invoiced_progress')
+
+ cursor = Transaction().connection.cursor()
+ table = cls.__table__()
+ progress = Progress.__table__()
+
+ invoiced_progress = {}
+ quantities = {}
+ for sub_works in grouped_slice(works):
+ sub_works = list(sub_works)
+ where = reduce_ids(table.id, [x.id for x in sub_works])
+ cursor.execute(*table.join(progress,
+ condition=progress.work == table.id
+ ).select(table.id, Sum(progress.progress),
+ where=where,
+ group_by=table.id))
+ invoiced_progress.update(dict(cursor.fetchall()))
+
+ for work in sub_works:
+ delta = (
+ (work.progress or 0)
+ - invoiced_progress.get(work.id, 0.0))
+ if delta > 0:
+ quantities[work.id] = delta * work.effort_hours
+ return quantities
+
+ @property
+ def progress_to_invoice(self):
+ if self.quantity_to_invoice:
+ return self.quantity_to_invoice / self.effort_hours
+
+ @classmethod
+ def _get_invoiced_amount_progress(cls, works):
+ pool = Pool()
+ Progress = pool.get('project.work.invoiced_progress')
+ InvoiceLine = pool.get('account.invoice.line')
+ Company = pool.get('company.company')
+ Currency = pool.get('currency.currency')
+
+ cursor = Transaction().connection.cursor()
+ table = cls.__table__()
+ progress = Progress.__table__()
+ invoice_line = InvoiceLine.__table__()
+ company = Company.__table__()
+
+ amounts = defaultdict(Decimal)
+ work2currency = {}
+ ids2work = dict((w.id, w) for w in works)
+ for sub_ids in grouped_slice(ids2work.keys()):
+ where = reduce_ids(table.id, sub_ids)
+ cursor.execute(*table.join(progress,
+ condition=progress.work == table.id
+ ).join(invoice_line,
+ condition=progress.invoice_line == invoice_line.id
+ ).select(table.id,
+ Sum(progress.progress * invoice_line.unit_price),
+ where=where,
+ group_by=table.id))
+ 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)))
+
+ cursor.execute(*table.join(company,
+ condition=table.company == company.id
+ ).select(table.id, company.currency,
+ where=where))
+ work2currency.update(cursor.fetchall())
+
+ currencies = Currency.browse(set(work2currency.values()))
+ id2currency = {c.id: c for c in currencies}
+
+ for work in works:
+ currency = id2currency[work2currency[work.id]]
+ amounts[work.id] = currency.round(Decimal(amounts[work.id]))
+ return amounts
+
+ def get_origins_to_invoice(self):
+ pool = Pool()
+ InvoicedProgress = pool.get('project.work.invoiced_progress')
+ try:
+ origins = super().get_origins_to_invoice()
+ except AttributeError:
+ origins = []
+ if self.invoice_method == 'progress':
+ invoiced_progress = InvoicedProgress(
+ work=self, progress=self.progress_to_invoice)
+ origins.append(invoiced_progress)
+ return origins
+
+
+class Timesheet:
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls.project_invoice_method.selection.append(
+ ('timesheet', 'On Timesheet'))
+
+ @classmethod
+ def _get_quantity_to_invoice_timesheet(cls, works):
+ pool = Pool()
+ TimesheetLine = pool.get('timesheet.line')
+ cursor = Transaction().connection.cursor()
+ line = TimesheetLine.__table__()
+
+ durations = defaultdict(datetime.timedelta)
+ twork2work = {tw.id: w.id for w in works for tw in w.timesheet_works}
+ for sub_ids in grouped_slice(twork2work.keys()):
+ red_sql = reduce_ids(line.work, sub_ids)
+ cursor.execute(*line.select(line.work, Sum(line.duration),
+ where=red_sql & (line.invoice_line == Null),
+ group_by=line.work))
+ for twork_id, duration in cursor.fetchall():
+ if duration:
+ # SQLite uses float for SUM
+ if not isinstance(duration, datetime.timedelta):
+ duration = datetime.timedelta(seconds=duration)
+ durations[twork2work[twork_id]] += duration
+
+ quantities = {}
+ for work in works:
+ duration = durations[work.id]
+ if work.list_price:
+ hours = duration.total_seconds() / 60 / 60
+ quantities[work.id] = hours
+ return quantities
+
+ @classmethod
+ def _get_invoiced_amount_timesheet(cls, works):
+ pool = Pool()
+ TimesheetWork = pool.get('timesheet.work')
+ TimesheetLine = pool.get('timesheet.line')
+ InvoiceLine = pool.get('account.invoice.line')
+ Company = pool.get('company.company')
+ Currency = pool.get('currency.currency')
+
+ cursor = Transaction().connection.cursor()
+ table = cls.__table__()
+ timesheet_work = TimesheetWork.__table__()
+ timesheet_line = TimesheetLine.__table__()
+ invoice_line = InvoiceLine.__table__()
+ company = Company.__table__()
+
+ amounts = {}
+ work2currency = {}
+ work_ids = [w.id for w in works]
+ for sub_ids in grouped_slice(work_ids):
+ where = reduce_ids(table.id, sub_ids)
+ cursor.execute(*table.join(timesheet_work,
+ condition=(
+ Concat(cls.__name__ + ',', table.id)
+ == timesheet_work.origin)
+ ).join(timesheet_line,
+ condition=timesheet_line.work == timesheet_work.id
+ ).join(invoice_line,
+ condition=timesheet_line.invoice_line == invoice_line.id
+ ).select(table.id,
+ Sum(timesheet_line.duration * invoice_line.unit_price),
+ where=where,
+ group_by=table.id))
+ amounts.update(cursor.fetchall())
+
+ cursor.execute(*table.join(company,
+ condition=table.company == company.id
+ ).select(table.id, company.currency,
+ where=where))
+ work2currency.update(cursor.fetchall())
+
+ currencies = Currency.browse(set(work2currency.values()))
+ id2currency = {c.id: c for c in currencies}
+
+ for work in works:
+ currency = id2currency[work2currency[work.id]]
+ amount = amounts.get(work.id, 0)
+ if isinstance(amount, datetime.timedelta):
+ amount = amount.total_seconds()
+ amount = amount / 60 / 60
+ amounts[work.id] = currency.round(Decimal(str(amount)))
+ return amounts
+
+ def get_origins_to_invoice(self):
+ try:
+ origins = super().get_origins_to_invoice()
+ except AttributeError:
+ origins = []
+ if self.invoice_method == 'timesheet':
+ origins.extend(
+ l for tw in self.timesheet_works
+ for l in tw.timesheet_lines
+ if not l.invoice_line)
+ return origins
+
+
+class Work(Effort, Progress, Timesheet, metaclass=PoolMeta):
+ __name__ = 'project.work'
+ project_invoice_method = fields.Selection([
+ ('manual', "Manual"),
+ ], "Invoice Method",
+ states={
+ 'readonly': Bool(Eval('invoiced_amount')),
+ 'required': Eval('type') == 'project',
+ 'invisible': Eval('type') != 'project',
+ },
+ depends=['invoiced_amount', 'type'])
+ invoice_method = fields.Function(fields.Selection(
+ 'get_invoice_methods', "Invoice Method"),
+ 'on_change_with_invoice_method')
+ quantity_to_invoice = fields.Function(
+ fields.Float("Quantity to Invoice"), '_get_invoice_values')
+ amount_to_invoice = fields.Function(fields.Numeric("Amount to Invoice",
+ digits=(16, Eval('currency_digits', 2)),
+ states={
+ 'invisible': Eval('invoice_method') == 'manual',
+ },
+ depends=['currency_digits', 'invoice_method']),
+ 'get_total')
+ invoiced_amount = fields.Function(fields.Numeric('Invoiced Amount',
+ digits=(16, Eval('currency_digits', 2)),
+ states={
+ 'invisible': Eval('invoice_method') == 'manual',
+ },
+ depends=['currency_digits', 'invoice_method']),
+ 'get_total')
+ invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
+ readonly=True)
+
+ @classmethod
+ def __setup__(cls):
+ super(Work, cls).__setup__()
+ cls._buttons.update({
+ 'invoice': {
+ 'invisible': ((Eval('type') != 'project')
+ | (Eval('project_invoice_method', 'manual')
+ == 'manual')),
+ 'readonly': ~Eval('amount_to_invoice'),
+ 'depends': [
+ 'type', 'project_invoice_method', 'amount_to_invoice'],
+ },
+ })
+
+ @staticmethod
+ def default_project_invoice_method():
+ return 'manual'
+
+ @classmethod
+ def copy(cls, records, default=None):
+ if default is None:
+ default = {}
+ else:
+ default = default.copy()
+ default.setdefault('invoice_line', None)
+ return super(Work, cls).copy(records, default=default)
+
+ @classmethod
+ def get_invoice_methods(cls):
+ field = 'project_invoice_method'
+ return cls.fields_get(field)[field]['selection']
+
+ @fields.depends('type', 'project_invoice_method',
+ 'parent', '_parent_parent.invoice_method')
+ def on_change_with_invoice_method(self, name=None):
+ if self.type == 'project':
+ return self.project_invoice_method
+ elif self.parent:
+ return self.parent.invoice_method
+ else:
+ return 'manual'
+
+ @classmethod
+ def default_quantity_to_invoice(cls):
+ return 0
+
+ @classmethod
+ def _get_quantity_to_invoice_manual(cls, works):
+ return {}
+
+ @classmethod
+ def _get_amount_to_invoice(cls, works):
+ amounts = {}
+ for work in works:
+ amounts[work.id] = work.company.currency.round(
+ (work.invoice_unit_price or 0)
+ * Decimal(str(work.quantity_to_invoice)))
+ return amounts
+
+ @classmethod
+ def default_invoiced_amount(cls):
+ return Decimal(0)
+
+ @classmethod
+ def _get_invoiced_amount_manual(cls, works):
+ return {}
+
+ @classmethod
+ def _get_invoice_values(cls, works, name):
+ default = getattr(cls, 'default_%s' % name)
+ amounts = dict.fromkeys((w.id for w in works), default())
+ method2works = defaultdict(list)
+ for work in works:
+ method2works[work.invoice_method].append(work)
+ for method, m_works in method2works.items():
+ method = getattr(cls, '_get_%s_%s' % (name, method))
+ # Re-browse for cache alignment
+ amounts.update(method(cls.browse(m_works)))
+ return amounts
+
+ @classmethod
+ def _get_invoiced_amount(cls, works):
+ return cls._get_invoice_values(works, 'invoiced_amount')
+
+ @classmethod
+ @ModelView.button
+ def invoice(cls, works):
+ pool = Pool()
+ Invoice = pool.get('account.invoice')
+
+ invoices = []
+ uninvoiced = works[:]
+ while uninvoiced:
+ work = uninvoiced.pop(0)
+ invoice_lines, uninvoiced_children = (
+ work._get_all_lines_to_invoice())
+ uninvoiced.extend(uninvoiced_children)
+ if not invoice_lines:
+ continue
+ invoice = work._get_invoice()
+ invoice.save()
+ invoices.append(invoice)
+ for key, lines in groupby(invoice_lines,
+ key=work._group_lines_to_invoice_key):
+ lines = list(lines)
+ key = dict(key)
+ invoice_line = work._get_invoice_line(key, invoice, lines)
+ invoice_line.invoice = invoice.id
+ invoice_line.save()
+ origins = defaultdict(list)
+ for line in lines:
+ for origin in line['origins']:
+ origins[origin.__class__].append(origin)
+ # TODO: remove when _check_access ignores record rule
+ with Transaction().set_user(0):
+ for klass, records in origins.items():
+ klass.save(records) # Store first new origins
+ klass.write(records, {
+ 'invoice_line': invoice_line.id,
+ })
+ Invoice.update_taxes(invoices)
+
+ def _get_invoice(self):
+ "Return invoice for the work"
+ pool = Pool()
+ Invoice = pool.get('account.invoice')
+ Journal = pool.get('account.journal')
+
+ journals = Journal.search([
+ ('type', '=', 'revenue'),
+ ], limit=1)
+ if journals:
+ journal, = journals
+ else:
+ journal = None
+
+ if not self.party:
+ raise InvoicingError(
+ gettext('project_invoice.msg_missing_party',
+ work=self.rec_name))
+
+ return Invoice(
+ company=self.company,
+ type='out',
+ journal=journal,
+ party=self.party,
+ invoice_address=self.party.address_get(type='invoice'),
+ currency=self.company.currency,
+ account=self.party.account_receivable_used,
+ payment_term=self.party.customer_payment_term,
+ description=self.name,
+ )
+
+ def _group_lines_to_invoice_key(self, line):
+ "The key to group lines"
+ return (('product', line['product']),
+ ('unit', line['unit']),
+ ('unit_price', line['unit_price']),
+ ('description', line['description']))
+
+ def _get_invoice_line(self, key, invoice, lines):
+ "Return a invoice line for the lines"
+ pool = Pool()
+ InvoiceLine = pool.get('account.invoice.line')
+ Uom = pool.get('product.uom')
+
+ quantity = sum(l['quantity'] for l in lines)
+ product = key['product']
+
+ 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
+ else:
+ 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:
+ if party.customer_tax_rule:
+ tax_ids = party.customer_tax_rule.apply(tax, pattern)
+ if tax_ids:
+ taxes.extend(tax_ids)
+ continue
+ taxes.append(tax.id)
+ if party.customer_tax_rule:
+ tax_ids = party.customer_tax_rule.apply(None, pattern)
+ if tax_ids:
+ taxes.extend(tax_ids)
+ invoice_line.taxes = taxes
+ return invoice_line
+
+ def _test_group_invoice(self):
+ return (self.company, self.party)
+
+ def _get_all_lines_to_invoice(self, test=None):
+ "Return lines for work and children"
+ lines = []
+ if test is None:
+ test = self._test_group_invoice()
+ uninvoiced_children = []
+ lines += self._get_lines_to_invoice()
+ for children in self.children:
+ if children.type == 'project':
+ if test != children._test_group_invoice():
+ uninvoiced_children.append(children)
+ continue
+ child_lines, uninvoiced = children._get_all_lines_to_invoice(
+ test=test)
+ lines.extend(child_lines)
+ uninvoiced_children.extend(uninvoiced)
+ return lines, uninvoiced_children
+
+ 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:
+ raise InvoicingError(
+ gettext('project_invoice.msg_missing_list_price',
+ work=self.rec_name))
+ return [{
+ 'product': self.product,
+ 'quantity': self.quantity_to_invoice,
+ 'unit': self.unit_to_invoice,
+ 'unit_price': self.invoice_unit_price,
+ 'origins': self.get_origins_to_invoice(),
+ 'description': self.name,
+ }]
+ return []
+
+ @property
+ def invoice_unit_price(self):
+ return self.list_price
+
+ @property
+ def unit_to_invoice(self):
+ pool = Pool()
+ ModelData = pool.get('ir.model.data')
+ Uom = pool.get('product.uom')
+ return Uom(ModelData.get_id('product', 'uom_hour'))
+
+ def get_origins_to_invoice(self):
+ return super().get_origins_to_invoice()
+
+
+class WorkInvoicedProgress(ModelView, ModelSQL):
+ 'Work Invoiced Progress'
+ __name__ = 'project.work.invoiced_progress'
+ work = fields.Many2One('project.work', 'Work', ondelete='RESTRICT',
+ select=True)
+ progress = fields.Float('Progress', required=True,
+ domain=[
+ ('progress', '>=', 0),
+ ])
+ invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
+ ondelete='CASCADE')
+
+ @classmethod
+ def __register__(cls, module_name):
+ cursor = Transaction().connection.cursor()
+ table = cls.__table_handler__(module_name)
+ sql_table = cls.__table__()
+ pool = Pool()
+ Work = pool.get('project.work')
+ work = Work.__table__()
+
+ created_progress = not table.column_exist('progress')
+ effort_exist = table.column_exist('effort_duration')
+
+ super().__register__(module_name)
+
+ # Migration from 5.0: Effort renamed into to progress
+ if created_progress and effort_exist:
+ # Don't use UPDATE FROM because SQLite does not support it.
+ value = work.select(
+ (Extract('EPOCH', sql_table.effort_duration)
+ / Extract('EPOCH', work.effort_duration)),
+ where=work.id == sql_table.work)
+ cursor.execute(*sql_table.update([sql_table.progress], [value]))
+
+
+class OpenInvoice(Wizard):
+ 'Open Invoice'
+ __name__ = 'project.open_invoice'
+ start_state = 'open_'
+ open_ = StateAction('account_invoice.act_invoice_form')
+
+ def do_open_(self, action):
+ pool = Pool()
+ Work = pool.get('project.work')
+ works = Work.search([
+ ('parent', 'child_of', Transaction().context['active_ids']),
+ ])
+ invoice_ids = set()
+ for work in works:
+ if work.invoice_line and work.invoice_line.invoice:
+ invoice_ids.add(work.invoice_line.invoice.id)
+ for twork in work.timesheet_works:
+ for timesheet_line in twork.timesheet_lines:
+ if (timesheet_line.invoice_line
+ and timesheet_line.invoice_line.invoice):
+ invoice_ids.add(timesheet_line.invoice_line.invoice.id)
+ if work.invoiced_progress:
+ for progress in work.invoiced_progress:
+ invoice_ids.add(progress.invoice_line.invoice.id)
+ encoder = PYSONEncoder()
+ action['pyson_domain'] = encoder.encode(
+ [('id', 'in', list(invoice_ids))])
+ action['pyson_search_value'] = encoder.encode([])
+ return action, {}
diff -r 5a78f819e58c -r 0367786244cb project.xml
--- a/project.xml Sun Mar 01 16:12:39 2020 +0100
+++ b/project.xml Fri Mar 06 23:34:09 2020 +0100
@@ -10,5 +10,61 @@
<field name="user" ref="res.user_admin"/>
<field name="group" ref="group_project_invoice"/>
</record>
+
+ <record model="ir.ui.view" id="work_view_list">
+ <field name="model">project.work</field>
+ <field name="inherit" ref="project.work_view_list"/>
+ <field name="name">work_list</field>
+ </record>
+ <record model="ir.ui.view" id="work_view_form">
+ <field name="model">project.work</field>
+ <field name="inherit" ref="project.work_view_form"/>
+ <field name="name">work_form</field>
+ </record>
+
+ <record model="ir.model.button" id="work_invoice_button">
+ <field name="name">invoice</field>
+ <field name="string">Invoice</field>
+ <field name="model" search="[('model', '=', 'project.work')]"/>
+ </record>
+ <record model="ir.model.button-res.group"
+ id="work_invoice_button_group_project_invoice">
+ <field name="button" ref="work_invoice_button"/>
+ <field name="group" ref="group_project_invoice"/>
+ </record>
+
+ <record model="ir.ui.view" id="work_invoiced_progress_view_form">
+ <field name="model">project.work.invoiced_progress</field>
+ <field name="type">form</field>
+ <field name="name">work_invoiced_progress_form</field>
+ </record>
+ <record model="ir.ui.view" id="work_invoiced_progress_view_list">
+ <field name="model">project.work.invoiced_progress</field>
+ <field name="type">tree</field>
+ <field name="name">work_invoiced_progress_list</field>
+ </record>
+ <record model="ir.model.access" id="access_work_invoiced_progress">
+ <field name="model"
+ search="[('model', '=', 'project.work.invoiced_progress')]"/>
+ <field name="perm_read" eval="True"/>
+ <field name="perm_write" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_delete" eval="False"/>
+ </record>
+
+ <record model="ir.action.wizard" id="open_invoice">
+ <field name="name">Invoices</field>
+ <field name="wiz_name">project.open_invoice</field>
+ <field name="model">project.work</field>
+ </record>
+ <record model="ir.action.keyword" id="open_invoice_keyword">
+ <field name="keyword">form_relate</field>
+ <field name="model">project.work,-1</field>
+ <field name="action" ref="open_invoice"/>
+ </record>
+ <record model="ir.action-res.group" id="open_invoice-group_invoice">
+ <field name="action" ref="open_invoice"/>
+ <field name="group" ref="account.group_account"/>
+ </record>
</data>
</tryton>
diff -r 5a78f819e58c -r 0367786244cb tests/scenario_project_invoice_effort.rst
--- a/tests/scenario_project_invoice_effort.rst Sun Mar 01 16:12:39 2020 +0100
+++ b/tests/scenario_project_invoice_effort.rst Fri Mar 06 23:34:09 2020 +0100
@@ -125,39 +125,33 @@
>>> project.save()
>>> task, task_no_effort = project.children
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice
- datetime.timedelta(0)
>>> project.invoiced_amount
Decimal('0')
+ >>> project.amount_to_invoice
+ Decimal('0.00')
Do 1 task::
>>> task.progress = 1
>>> task.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice == datetime.timedelta(0, 18000)
- True
>>> project.invoiced_amount
Decimal('0')
+ >>> project.amount_to_invoice
+ Decimal('100.00')
Invoice project::
>>> set_user(project_invoice_user)
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 18000)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('100.00')
@@ -169,13 +163,11 @@
>>> project.progress = 1
>>> project.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration == datetime.timedelta(0, 18000)
- True
- >>> project.duration_to_invoice == datetime.timedelta(0, 3600)
- True
+ >>> project.amount_to_invoice
+ Decimal('20.00')
>>> project.invoiced_amount
Decimal('100.00')
@@ -183,9 +175,7 @@
>>> set_user(project_invoice_user)
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 21600)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('120.00')
diff -r 5a78f819e58c -r 0367786244cb
tests/scenario_project_invoice_multiple_party.rst
--- a/tests/scenario_project_invoice_multiple_party.rst Sun Mar 01 16:12:39
2020 +0100
+++ b/tests/scenario_project_invoice_multiple_party.rst Fri Mar 06 23:34:09
2020 +0100
@@ -83,13 +83,11 @@
>>> project.save()
>>> subproject, = project.children
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('0')
@@ -100,23 +98,19 @@
>>> project.progress = 1
>>> project.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice == datetime.timedelta(0, 21600)
- True
+ >>> project.amount_to_invoice
+ Decimal('120.00')
>>> project.invoiced_amount
Decimal('0')
Invoice project::
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 21600)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('120.00')
diff -r 5a78f819e58c -r 0367786244cb tests/scenario_project_invoice_progress.rst
--- a/tests/scenario_project_invoice_progress.rst Sun Mar 01 16:12:39
2020 +0100
+++ b/tests/scenario_project_invoice_progress.rst Fri Mar 06 23:34:09
2020 +0100
@@ -123,13 +123,11 @@
>>> project.save()
>>> task, = project.children
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('0.00')
@@ -138,13 +136,11 @@
>>> task.progress = 0.5
>>> task.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice == datetime.timedelta(0, 9000)
- True
+ >>> project.amount_to_invoice
+ Decimal('50.00')
>>> project.invoiced_amount
Decimal('0.00')
@@ -152,10 +148,8 @@
>>> set_user(project_invoice_user)
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 9000)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('50.00')
@@ -167,13 +161,11 @@
>>> project.progress = 0.80
>>> project.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration == datetime.timedelta(0, 9000)
- True
- >>> project.duration_to_invoice == datetime.timedelta(0, 7380)
- True
+ >>> project.amount_to_invoice
+ Decimal('41.00')
>>> project.invoiced_amount
Decimal('50.00')
@@ -181,9 +173,7 @@
>>> set_user(project_invoice_user)
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 16380)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('91.00')
diff -r 5a78f819e58c -r 0367786244cb
tests/scenario_project_invoice_timesheet.rst
--- a/tests/scenario_project_invoice_timesheet.rst Sun Mar 01 16:12:39
2020 +0100
+++ b/tests/scenario_project_invoice_timesheet.rst Fri Mar 06 23:34:09
2020 +0100
@@ -143,13 +143,11 @@
>>> line.work, = project.timesheet_works
>>> line.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration
- datetime.timedelta(0)
- >>> project.duration_to_invoice == datetime.timedelta(0, 18000)
- True
+ >>> project.amount_to_invoice
+ Decimal('100.00')
>>> project.invoiced_amount
Decimal('0.00')
@@ -157,10 +155,8 @@
>>> set_user(project_invoice_user)
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 18000)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('100.00')
@@ -174,13 +170,11 @@
>>> line.work, = task.timesheet_works
>>> line.save()
-Check project duration::
+Check project amounts::
>>> project.reload()
- >>> project.invoiced_duration == datetime.timedelta(0, 18000)
- True
- >>> project.duration_to_invoice == datetime.timedelta(0, 14400)
- True
+ >>> project.amount_to_invoice
+ Decimal('80.00')
>>> project.invoiced_amount
Decimal('100.00')
@@ -188,9 +182,7 @@
>>> set_user(project_invoice_user)
>>> project.click('invoice')
- >>> project.invoiced_duration == datetime.timedelta(0, 32400)
- True
- >>> project.duration_to_invoice
- datetime.timedelta(0)
+ >>> project.amount_to_invoice
+ Decimal('0.00')
>>> project.invoiced_amount
Decimal('180.00')
diff -r 5a78f819e58c -r 0367786244cb tryton.cfg
--- a/tryton.cfg Sun Mar 01 16:12:39 2020 +0100
+++ b/tryton.cfg Fri Mar 06 23:34:09 2020 +0100
@@ -10,6 +10,5 @@
product
xml:
project.xml
- work.xml
timesheet.xml
message.xml
diff -r 5a78f819e58c -r 0367786244cb view/work_form.xml
--- a/view/work_form.xml Sun Mar 01 16:12:39 2020 +0100
+++ b/view/work_form.xml Fri Mar 06 23:34:09 2020 +0100
@@ -8,15 +8,13 @@
<label name="project_invoice_method"/>
<field name="project_invoice_method"/>
</xpath>
- <xpath expr="/form/notebook/page[@id='general']/separator[@name='comment']"
- position="before">
- <newline/>
- <label name="invoiced_duration"/>
- <field name="invoiced_duration"/>
+ <xpath
expr="/form/notebook/page[@id='general']/field[@name='total_effort']"
position="after">
+ <label name="amount_to_invoice"/>
+ <field name="amount_to_invoice"/>
+ </xpath>
+ <xpath expr="/form/notebook/page[@id='general']/field[@name='revenue']"
position="after">
<label name="invoiced_amount"/>
<field name="invoiced_amount"/>
- <label name="duration_to_invoice"/>
- <field name="duration_to_invoice"/>
</xpath>
<xpath expr="/form/notebook/page[@id='general']/group/group[@id='buttons']"
position="inside">
diff -r 5a78f819e58c -r 0367786244cb view/work_invoiced_progress_form.xml
--- a/view/work_invoiced_progress_form.xml Sun Mar 01 16:12:39 2020 +0100
+++ b/view/work_invoiced_progress_form.xml Fri Mar 06 23:34:09 2020 +0100
@@ -5,8 +5,11 @@
<label name="work"/>
<field name="work"/>
<newline/>
- <label name="effort_duration"/>
- <field name="effort_duration"/>
+ <label name="progress"/>
+ <group col="2" id="progress">
+ <field name="progress" factor="100" xexpand="0"/>
+ <label name="progress" string="%" xalign="0.0" xexpand="1"/>
+ </group>
<label name="invoice_line"/>
<field name="invoice_line"/>
</form>
diff -r 5a78f819e58c -r 0367786244cb view/work_invoiced_progress_list.xml
--- a/view/work_invoiced_progress_list.xml Sun Mar 01 16:12:39 2020 +0100
+++ b/view/work_invoiced_progress_list.xml Fri Mar 06 23:34:09 2020 +0100
@@ -3,6 +3,8 @@
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="work" expand="2"/>
- <field name="effort_duration"/>
+ <field name="progress" factor="100">
+ <suffix string="%" name="progress"/>
+ </field>
<field name="invoice_line" expand="1"/>
</tree>
diff -r 5a78f819e58c -r 0367786244cb work.py
--- a/work.py Sun Mar 01 16:12:39 2020 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,636 +0,0 @@
-# This file is part of Tryton. The COPYRIGHT file at the top level of
-# this repository contains the full copyright notices and license terms.
-
-
-from itertools import groupby
-from collections import defaultdict
-from decimal import Decimal
-import datetime
-
-from sql import Null
-from sql.aggregate import Sum
-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.pool import Pool
-from trytond.transaction import Transaction
-from trytond.wizard import Wizard, StateAction
-from trytond.tools import reduce_ids, grouped_slice
-
-from .exceptions import InvoicingError
-
-INVOICE_METHODS = [
- ('manual', 'Manual'),
- ('effort', 'On Effort'),
- ('progress', 'On Progress'),
- ('timesheet', 'On Timesheet'),
- ]
-
-
-class Work(metaclass=PoolMeta):
- __name__ = 'project.work'
- project_invoice_method = fields.Selection(INVOICE_METHODS,
- 'Invoice Method',
- states={
- 'readonly': Bool(Eval('invoiced_duration')),
- 'required': Eval('type') == 'project',
- 'invisible': Eval('type') != 'project',
- },
- depends=['invoiced_duration', 'type'])
- invoice_method = fields.Function(fields.Selection(INVOICE_METHODS,
- 'Invoice Method'), 'on_change_with_invoice_method')
- invoiced_duration = fields.Function(fields.TimeDelta('Invoiced Duration',
- 'company_work_time',
- states={
- 'invisible': Eval('invoice_method') == 'manual',
- },
- depends=['invoice_method']), 'get_total')
- duration_to_invoice = fields.Function(fields.TimeDelta(
- 'Duration to Invoice', 'company_work_time',
- states={
- 'invisible': Eval('invoice_method') == 'manual',
- },
- depends=['invoice_method']), 'get_total')
- invoiced_amount = fields.Function(fields.Numeric('Invoiced Amount',
- digits=(16, Eval('currency_digits', 2)),
- states={
- 'invisible': Eval('invoice_method') == 'manual',
- },
- depends=['currency_digits', 'invoice_method']),
- 'get_total')
- invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
- readonly=True)
- invoiced_progress = fields.One2Many('project.work.invoiced_progress',
- 'work', 'Invoiced Progress', readonly=True)
-
- @classmethod
- def __setup__(cls):
- super(Work, cls).__setup__()
- cls._buttons.update({
- 'invoice': {
- 'invisible': ((Eval('type') != 'project')
- | (Eval('project_invoice_method', 'manual')
- == 'manual')),
- 'readonly': ~Eval('duration_to_invoice'),
- 'depends': ['type', 'project_invoice_method',
- 'duration_to_invoice']
- },
- })
-
- @staticmethod
- def default_project_invoice_method():
- return 'manual'
-
- @classmethod
- def copy(cls, records, default=None):
- if default is None:
- default = {}
- else:
- default = default.copy()
- default.setdefault('invoice_line', None)
- return super(Work, cls).copy(records, default=default)
-
- @fields.depends('type', 'project_invoice_method',
- 'parent', '_parent_parent.invoice_method')
- def on_change_with_invoice_method(self, name=None):
- if self.type == 'project':
- return self.project_invoice_method
- elif self.parent:
- return self.parent.invoice_method
- else:
- return 'manual'
-
- @staticmethod
- def default_invoiced_duration():
- return datetime.timedelta()
-
- @staticmethod
- def _get_invoiced_duration_manual(works):
- return {}
-
- @staticmethod
- def _get_invoiced_duration_effort(works):
- return dict((w.id, w.effort_duration) for w in works
- if w.invoice_line and w.effort_duration)
-
- @staticmethod
- def _get_invoiced_duration_progress(works):
- durations = {}
- for work in works:
- durations[work.id] = sum((p.effort_duration
- for p in work.invoiced_progress if p.effort_duration),
- datetime.timedelta())
- return durations
-
- @classmethod
- def _get_invoiced_duration_timesheet(cls, works):
- return cls._get_duration_timesheet(works, True)
-
- @staticmethod
- def default_duration_to_invoice():
- return datetime.timedelta()
-
- @staticmethod
- def _get_duration_to_invoice_manual(works):
- return {}
-
- @staticmethod
- def _get_duration_to_invoice_effort(works):
- return dict((w.id, w.effort_duration) for w in works
- if w.progress == 1 and not w.invoice_line and w.effort_duration)
-
- @staticmethod
- def _get_duration_to_invoice_progress(works):
- durations = {}
- for work in works:
- if work.progress is None or work.effort_duration is None:
- continue
- effort_to_invoice = datetime.timedelta(
- hours=work.effort_hours * work.progress)
- effort_invoiced = sum(
- (p.effort_duration
- for p in work.invoiced_progress),
- datetime.timedelta())
- if effort_to_invoice > effort_invoiced:
- durations[work.id] = effort_to_invoice - effort_invoiced
- else:
- durations[work.id] = datetime.timedelta()
- return durations
-
- @classmethod
- def _get_duration_to_invoice_timesheet(cls, works):
- return cls._get_duration_timesheet(works, False)
-
- @staticmethod
- def default_invoiced_amount():
- return Decimal(0)
-
- @staticmethod
- def _get_invoiced_amount_manual(works):
- return {}
-
- @staticmethod
- def _get_invoiced_amount_effort(works):
- pool = Pool()
- InvoiceLine = pool.get('account.invoice.line')
- Currency = pool.get('currency.currency')
-
- invoice_lines = InvoiceLine.browse([
- w.invoice_line.id for w in works
- if w.invoice_line])
-
- id2invoice_lines = dict((l.id, l) for l in invoice_lines)
- amounts = {}
- for work in works:
- currency = work.company.currency
- if work.invoice_line:
- 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)
- else:
- amounts[work.id] = Decimal(0)
- return amounts
-
- @classmethod
- def _get_invoiced_amount_progress(cls, works):
- pool = Pool()
- Progress = pool.get('project.work.invoiced_progress')
- InvoiceLine = pool.get('account.invoice.line')
- Company = pool.get('company.company')
- Currency = pool.get('currency.currency')
-
- cursor = Transaction().connection.cursor()
- table = cls.__table__()
- progress = Progress.__table__()
- invoice_line = InvoiceLine.__table__()
- company = Company.__table__()
-
- amounts = defaultdict(Decimal)
- work2currency = {}
- work_ids = [w.id for w in works]
- for sub_ids in grouped_slice(work_ids):
- where = reduce_ids(table.id, sub_ids)
- cursor.execute(*table.join(progress,
- condition=progress.work == table.id
- ).join(invoice_line,
- condition=progress.invoice_line == invoice_line.id
- ).select(table.id,
- Sum(progress.effort_duration * invoice_line.unit_price),
- where=where,
- group_by=table.id))
- for work_id, amount in cursor.fetchall():
- if isinstance(amount, datetime.timedelta):
- amount = amount.total_seconds()
- # Amount computed in second instead of hours
- if amount is not None:
- amount /= 60 * 60
- else:
- amount = 0
- amounts[work_id] = amount
-
- cursor.execute(*table.join(company,
- condition=table.company == company.id
- ).select(table.id, company.currency,
- where=where))
- work2currency.update(cursor.fetchall())
-
- currencies = Currency.browse(set(work2currency.values()))
- id2currency = {c.id: c for c in currencies}
-
- for work in works:
- currency = id2currency[work2currency[work.id]]
- amounts[work.id] = currency.round(Decimal(amounts[work.id]))
- return amounts
-
- @classmethod
- def _get_invoiced_amount_timesheet(cls, works):
- pool = Pool()
- TimesheetWork = pool.get('timesheet.work')
- TimesheetLine = pool.get('timesheet.line')
- InvoiceLine = pool.get('account.invoice.line')
- Company = pool.get('company.company')
- Currency = pool.get('currency.currency')
-
- cursor = Transaction().connection.cursor()
- table = cls.__table__()
- timesheet_work = TimesheetWork.__table__()
- timesheet_line = TimesheetLine.__table__()
- invoice_line = InvoiceLine.__table__()
- company = Company.__table__()
-
- amounts = {}
- work2currency = {}
- work_ids = [w.id for w in works]
- for sub_ids in grouped_slice(work_ids):
- where = reduce_ids(table.id, sub_ids)
- cursor.execute(*table.join(timesheet_work,
- condition=(
- Concat(cls.__name__ + ',', table.id)
- == timesheet_work.origin)
- ).join(timesheet_line,
- condition=timesheet_line.work == timesheet_work.id
- ).join(invoice_line,
- condition=timesheet_line.invoice_line == invoice_line.id
- ).select(table.id,
- Sum(timesheet_line.duration * invoice_line.unit_price),
- where=where,
- group_by=table.id))
- amounts.update(cursor.fetchall())
-
- cursor.execute(*table.join(company,
- condition=table.company == company.id
- ).select(table.id, company.currency,
- where=where))
- work2currency.update(cursor.fetchall())
-
- currencies = Currency.browse(set(work2currency.values()))
- id2currency = {c.id: c for c in currencies}
-
- for work in works:
- currency = id2currency[work2currency[work.id]]
- amount = amounts.get(work.id, 0)
- if isinstance(amount, datetime.timedelta):
- amount = amount.total_seconds()
- amount = amount / 60 / 60
- amounts[work.id] = currency.round(Decimal(str(amount)))
- return amounts
-
- @staticmethod
- def _get_duration_timesheet(works, invoiced):
- pool = Pool()
- TimesheetLine = pool.get('timesheet.line')
- cursor = Transaction().connection.cursor()
- line = TimesheetLine.__table__()
-
- durations = defaultdict(datetime.timedelta)
- twork2work = {tw.id: w.id for w in works for tw in w.timesheet_works}
- for sub_ids in grouped_slice(twork2work.keys()):
- red_sql = reduce_ids(line.work, sub_ids)
- if invoiced:
- where = line.invoice_line != Null
- else:
- where = line.invoice_line == Null
- cursor.execute(*line.select(line.work, Sum(line.duration),
- where=red_sql & where,
- group_by=line.work))
- for twork_id, duration in cursor.fetchall():
- if duration:
- # SQLite uses float for SUM
- if not isinstance(duration, datetime.timedelta):
- duration = datetime.timedelta(seconds=duration)
- durations[twork2work[twork_id]] += duration
- return durations
-
- @classmethod
- def _get_invoice_values(cls, works, name):
- default = getattr(cls, 'default_%s' % name)
- durations = dict.fromkeys((w.id for w in works), default())
- method2works = defaultdict(list)
- for work in works:
- method2works[work.invoice_method].append(work)
- for method, m_works in method2works.items():
- method = getattr(cls, '_get_%s_%s' % (name, method))
- # Re-browse for cache alignment
- durations.update(method(cls.browse(m_works)))
- return durations
-
- @classmethod
- def _get_invoiced_duration(cls, works):
- return cls._get_invoice_values(works, 'invoiced_duration')
-
- @classmethod
- def _get_duration_to_invoice(cls, works):
- return cls._get_invoice_values(works, 'duration_to_invoice')
-
- @classmethod
- def _get_invoiced_amount(cls, works):
- return cls._get_invoice_values(works, 'invoiced_amount')
-
- @classmethod
- @ModelView.button
- def invoice(cls, works):
- pool = Pool()
- Invoice = pool.get('account.invoice')
-
- invoices = []
- uninvoiced = works[:]
- while uninvoiced:
- work = uninvoiced.pop(0)
- invoice_lines, uninvoiced_children = work._get_lines_to_invoice()
- uninvoiced.extend(uninvoiced_children)
- if not invoice_lines:
- continue
- invoice = work._get_invoice()
- invoice.save()
- invoices.append(invoice)
- for key, lines in groupby(invoice_lines,
- key=work._group_lines_to_invoice_key):
- lines = list(lines)
- key = dict(key)
- invoice_line = work._get_invoice_line(key, invoice, lines)
- invoice_line.invoice = invoice.id
- invoice_line.save()
- origins = {}
- for line in lines:
- origin = line['origin']
- origins.setdefault(origin.__class__, []).append(origin)
- # TODO: remove when _check_access ignores record rule
- with Transaction().set_user(0):
- for klass, records in origins.items():
- klass.save(records) # Store first new origins
- klass.write(records, {
- 'invoice_line': invoice_line.id,
- })
- Invoice.update_taxes(invoices)
-
- def _get_invoice(self):
- "Return invoice for the work"
- pool = Pool()
- Invoice = pool.get('account.invoice')
- Journal = pool.get('account.journal')
-
- journals = Journal.search([
- ('type', '=', 'revenue'),
- ], limit=1)
- if journals:
- journal, = journals
- else:
- journal = None
-
- if not self.party:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_party',
- work=self.rec_name))
-
- return Invoice(
- company=self.company,
- type='out',
- journal=journal,
- party=self.party,
- invoice_address=self.party.address_get(type='invoice'),
- currency=self.company.currency,
- account=self.party.account_receivable_used,
- payment_term=self.party.customer_payment_term,
- description=self.name,
- )
-
- def _group_lines_to_invoice_key(self, line):
- "The key to group lines"
- return (('product', line['product']),
- ('unit', line['unit']),
- ('unit_price', line['unit_price']),
- ('description', line['description']))
-
- def _get_invoice_line(self, key, invoice, lines):
- "Return a invoice line for the lines"
- pool = Pool()
- InvoiceLine = pool.get('account.invoice.line')
- Uom = pool.get('product.uom')
-
- quantity = sum(l['quantity'] for l in lines)
- product = key['product']
-
- 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
- else:
- 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:
- if party.customer_tax_rule:
- tax_ids = party.customer_tax_rule.apply(tax, pattern)
- if tax_ids:
- taxes.extend(tax_ids)
- continue
- taxes.append(tax.id)
- if party.customer_tax_rule:
- tax_ids = party.customer_tax_rule.apply(None, pattern)
- if tax_ids:
- taxes.extend(tax_ids)
- invoice_line.taxes = taxes
- return invoice_line
-
- def _get_lines_to_invoice_manual(self):
- return []
-
- def _get_lines_to_invoice_effort(self):
- pool = Pool()
- ModelData = pool.get('ir.model.data')
- Uom = pool.get('product.uom')
-
- hour = Uom(ModelData.get_id('product', 'uom_hour'))
-
- if (not self.invoice_line
- and self.effort_hours
- and self.progress == 1):
- if not self.product:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_product',
- work=self.rec_name))
- elif self.list_price is None:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_list_price',
- work=self.rec_name))
- return [{
- 'product': self.product,
- 'quantity': self.effort_hours,
- 'unit': hour,
- 'unit_price': self.list_price,
- 'origin': self,
- 'description': self.name,
- }]
- return []
-
- def _get_lines_to_invoice_progress(self):
- pool = Pool()
- InvoicedProgress = pool.get('project.work.invoiced_progress')
- ModelData = pool.get('ir.model.data')
- Uom = pool.get('product.uom')
-
- hour = Uom(ModelData.get_id('product', 'uom_hour'))
-
- if self.progress is None or self.effort_duration is None:
- return []
-
- invoiced_progress = sum(x.effort_hours for x in self.invoiced_progress)
- quantity = self.effort_hours * self.progress - invoiced_progress
- if self.product:
- quantity = Uom.compute_qty(
- hour, quantity, self.product.default_uom)
- if quantity > 0:
- if not self.product:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_product',
- work=self.rec_name))
- elif self.list_price is None:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_list_price',
- work=self.rec_name))
- invoiced_progress = InvoicedProgress(work=self,
- effort_duration=datetime.timedelta(hours=quantity))
- return [{
- 'product': self.product,
- 'quantity': quantity,
- 'unit': hour,
- 'unit_price': self.list_price,
- 'origin': invoiced_progress,
- 'description': self.name,
- }]
- return []
-
- def _get_lines_to_invoice_timesheet(self):
- pool = Pool()
- ModelData = pool.get('ir.model.data')
- Uom = pool.get('product.uom')
-
- hour = Uom(ModelData.get_id('product', 'uom_hour'))
- if (self.timesheet_works
- and any(tw.timesheet_lines for tw in self.timesheet_works)):
- if not self.product:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_product',
- work=self.rec_name))
- elif self.list_price is None:
- raise InvoicingError(
- gettext('project_invoice.msg_missing_list_price',
- work=self.rec_name))
- return [{
- 'product': self.product,
- 'quantity': l.hours,
- 'unit': hour,
- 'unit_price': self.list_price,
- 'origin': l,
- 'description': self.name,
- }
- for tw in self.timesheet_works
- for l in tw.timesheet_lines
- if not l.invoice_line]
- return []
-
- def _test_group_invoice(self):
- return (self.company, self.party)
-
- def _get_lines_to_invoice(self, test=None):
- "Return lines for work and children"
- lines = []
- if test is None:
- test = self._test_group_invoice()
- uninvoiced_children = []
- lines += getattr(self, '_get_lines_to_invoice_%s' %
- self.invoice_method)()
- for children in self.children:
- if children.type == 'project':
- if test != children._test_group_invoice():
- uninvoiced_children.append(children)
- continue
- child_lines, uninvoiced = children._get_lines_to_invoice(test=test)
- lines.extend(child_lines)
- uninvoiced_children.extend(uninvoiced)
- return lines, uninvoiced_children
-
-
-class WorkInvoicedProgress(ModelView, ModelSQL):
- 'Work Invoiced Progress'
- __name__ = 'project.work.invoiced_progress'
- work = fields.Many2One('project.work', 'Work', ondelete='RESTRICT',
- select=True)
- effort_duration = fields.TimeDelta('Effort', 'company_work_time')
- invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
- ondelete='CASCADE')
-
- @property
- def effort_hours(self):
- if not self.effort_duration:
- return 0
- return self.effort_duration.total_seconds() / 60 / 60
-
-
-class OpenInvoice(Wizard):
- 'Open Invoice'
- __name__ = 'project.open_invoice'
- start_state = 'open_'
- open_ = StateAction('account_invoice.act_invoice_form')
-
- def do_open_(self, action):
- pool = Pool()
- Work = pool.get('project.work')
- works = Work.search([
- ('parent', 'child_of', Transaction().context['active_ids']),
- ])
- invoice_ids = set()
- for work in works:
- if work.invoice_line and work.invoice_line.invoice:
- invoice_ids.add(work.invoice_line.invoice.id)
- for twork in work.timesheet_works:
- for timesheet_line in twork.timesheet_lines:
- if (timesheet_line.invoice_line
- and timesheet_line.invoice_line.invoice):
- invoice_ids.add(timesheet_line.invoice_line.invoice.id)
- if work.invoiced_progress:
- for progress in work.invoiced_progress:
- invoice_ids.add(progress.invoice_line.invoice.id)
- encoder = PYSONEncoder()
- action['pyson_domain'] = encoder.encode(
- [('id', 'in', list(invoice_ids))])
- action['pyson_search_value'] = encoder.encode([])
- return action, {}
diff -r 5a78f819e58c -r 0367786244cb work.xml
--- a/work.xml Sun Mar 01 16:12:39 2020 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-<?xml version="1.0"?>
-<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
-this repository contains the full copyright notices and license terms. -->
-<tryton>
- <data>
- <record model="ir.ui.view" id="work_view_list">
- <field name="model">project.work</field>
- <field name="inherit" ref="project.work_view_list"/>
- <field name="name">work_list</field>
- </record>
- <record model="ir.ui.view" id="work_view_form">
- <field name="model">project.work</field>
- <field name="inherit" ref="project.work_view_form"/>
- <field name="name">work_form</field>
- </record>
-
- <record model="ir.model.button" id="work_invoice_button">
- <field name="name">invoice</field>
- <field name="string">Invoice</field>
- <field name="model" search="[('model', '=', 'project.work')]"/>
- </record>
- <record model="ir.model.button-res.group"
- id="work_invoice_button_group_project_invoice">
- <field name="button" ref="work_invoice_button"/>
- <field name="group" ref="group_project_invoice"/>
- </record>
-
- <record model="ir.ui.view" id="work_invoiced_progress_view_form">
- <field name="model">project.work.invoiced_progress</field>
- <field name="type">form</field>
- <field name="name">work_invoiced_progress_form</field>
- </record>
- <record model="ir.ui.view" id="work_invoiced_progress_view_list">
- <field name="model">project.work.invoiced_progress</field>
- <field name="type">tree</field>
- <field name="name">work_invoiced_progress_list</field>
- </record>
- <record model="ir.model.access" id="access_work_invoiced_progress">
- <field name="model"
- search="[('model', '=', 'project.work.invoiced_progress')]"/>
- <field name="perm_read" eval="True"/>
- <field name="perm_write" eval="False"/>
- <field name="perm_create" eval="False"/>
- <field name="perm_delete" eval="False"/>
- </record>
-
- <record model="ir.action.wizard" id="open_invoice">
- <field name="name">Invoices</field>
- <field name="wiz_name">project.open_invoice</field>
- <field name="model">project.work</field>
- </record>
- <record model="ir.action.keyword" id="open_invoice_keyword">
- <field name="keyword">form_relate</field>
- <field name="model">project.work,-1</field>
- <field name="action" ref="open_invoice"/>
- </record>
- <record model="ir.action-res.group" id="open_invoice-group_invoice">
- <field name="action" ref="open_invoice"/>
- <field name="group" ref="account.group_account"/>
- </record>
- </data>
-</tryton>