diff --git a/modules/sale/CHANGELOG b/modules/sale/CHANGELOG index 13b53a7834..c28186eddc 100644 --- a/modules/sale/CHANGELOG +++ b/modules/sale/CHANGELOG @@ -1,3 +1,4 @@ +* Manage expiration of quotation * Add relate to open products from sale Version 7.4.0 - 2024-11-04 diff --git a/modules/sale/__init__.py b/modules/sale/__init__.py index 7f8f812fa2..81313b422b 100644 --- a/modules/sale/__init__.py +++ b/modules/sale/__init__.py @@ -4,12 +4,17 @@ from trytond.pool import Pool from . import ( - configuration, invoice, party, product, sale, sale_reporting, stock) + configuration, invoice, ir, party, product, sale, sale_reporting, stock) def register(): Pool.register( + ir.Cron, stock.Move, + configuration.Configuration, + configuration.ConfigurationSequence, + configuration.ConfigurationSaleMethod, + configuration.ConfigurationQuotation, sale.Sale, sale.SaleIgnoredInvoice, sale.SaleRecreatedInvoice, @@ -31,9 +36,6 @@ def register(): sale.HandleShipmentExceptionAsk, sale.HandleInvoiceExceptionAsk, sale.ReturnSaleStart, - configuration.Configuration, - configuration.ConfigurationSequence, - configuration.ConfigurationSaleMethod, sale_reporting.Context, sale_reporting.Main, sale_reporting.MainTimeseries, diff --git a/modules/sale/configuration.py b/modules/sale/configuration.py index ae043a7200..6e0db022f8 100644 --- a/modules/sale/configuration.py +++ b/modules/sale/configuration.py @@ -1,5 +1,8 @@ # 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 trytond.model import ( ModelSingleton, ModelSQL, ModelView, ValueMixin, fields) from trytond.modules.company.model import ( @@ -11,6 +14,12 @@ sale_invoice_method = fields.Selection( 'get_sale_invoice_methods', "Sale Invoice Method") sale_shipment_method = fields.Selection( 'get_sale_shipment_methods', "Sale Shipment Method") +sale_quotation_validity = fields.TimeDelta( + "Sale Quotation Validity", + domain=['OR', + ('sale_quotation_validity', '>=', dt.timedelta()), + ('sale_quotation_validity', '=', None), + ]) def get_sale_methods(field_name): @@ -41,6 +50,7 @@ class Configuration( [Eval('context', {}).get('company', -1), None]), ('sequence_type', '=', Id('sale', 'sequence_type_sale')), ])) + sale_quotation_validity = fields.MultiValue(sale_quotation_validity) sale_invoice_method = fields.MultiValue(sale_invoice_method) get_sale_invoice_methods = get_sale_methods('invoice_method') sale_shipment_method = fields.MultiValue(sale_shipment_method) @@ -62,6 +72,8 @@ class Configuration( return pool.get('sale.configuration.sale_method') if field == 'sale_sequence': return pool.get('sale.configuration.sequence') + elif field == 'sale_quotation_validity': + return pool.get('sale.configuration.quotation') return super(Configuration, cls).multivalue_model(field) default_sale_sequence = default_func('sale_sequence') @@ -102,3 +114,8 @@ class ConfigurationSaleMethod(ModelSQL, ValueMixin): @classmethod def default_sale_shipment_method(cls): return 'order' + + +class ConfigurationQuotation(ModelSQL, ValueMixin): + __name__ = 'sale.configuration.quotation' + sale_quotation_validity = sale_quotation_validity diff --git a/modules/sale/doc/usage/process.inc.rst b/modules/sale/doc/usage/process.inc.rst index 9c55ced7ae..ef078ef223 100644 --- a/modules/sale/doc/usage/process.inc.rst +++ b/modules/sale/doc/usage/process.inc.rst @@ -28,6 +28,30 @@ In order to change these without needing to remove the sales lines you can use the :guilabel:`Modify Header` button to start the `Modify Header ` wizard. +.. _Sending a quotation: + +Sending a quotation +=================== + +A draft `Sale ` can be set to a quotation state with a +validity period to calculate an expiration date based from the quotation date. +The quotation can then be sent to the customer for confirmation. + +The validity of a quotation can be extended by resetting to draft and edit the +:guilabel:`Quotation Validity`. +In order to change the quotation date, you can use the :guilabel:`Modify +Header` button to start the `Modify Header ` wizard +which will update the prices. + +.. note:: + + A quotation with a sale date after the expiration date cannot be confirmed. + +.. note:: + + If an quotation reaches its expiration without being confirmed, it will be + automatically cancelled by a `scheduled task `. + .. _Shipping a sale: Shipping a sale diff --git a/modules/sale/ir.py b/modules/sale/ir.py new file mode 100644 index 0000000000..4befe53b6d --- /dev/null +++ b/modules/sale/ir.py @@ -0,0 +1,16 @@ +# 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 trytond.pool import PoolMeta + + +class Cron(metaclass=PoolMeta): + __name__ = 'ir.cron' + + @classmethod + def __setup__(cls): + super().__setup__() + cls.method.selection.extend([ + ('sale.sale|cancel_expired_quotation', + "Cancel Expired Sale Quotation"), + ]) diff --git a/modules/sale/sale.fodt b/modules/sale/sale.fodt index af5620ff23..027d3ed7f7 100644 --- a/modules/sale/sale.fodt +++ b/modules/sale/sale.fodt @@ -1,24 +1,24 @@ - LibreOffice/7.6.4.1$Linux_X86_64 LibreOffice_project/60$Build-12008-06-07T15:28:222009-01-10T16:03:331PT0S + LibreOffice/24.2.7.2$Linux_X86_64 LibreOffice_project/420$Build-22008-06-07T15:28:222009-01-10T16:03:331PT0S - 0 + 39582 0 - 48129 - 23973 + 48103 + 22915 true false view2 - 7856 - 9275 + 5339 + 46011 0 - 0 - 48128 - 23971 + 39582 + 48101 + 62495 0 0 false @@ -33,113 +33,116 @@ - true false - true - false - true - false - false - 0 - true - false - false - false - false - false - true - true - true - false + true + true + false + 0 + false + true + true + false + true + 0 + true + false + false + false + true false + true false - false false + false false true - true - false - false - false - false - false - false - true - false - false - 204959 - false - - true - false - false - 1972400 - true - false - false - false - 1 - true - true - false - false - false - false - true - true - true - true - false - true - 0 - - true - true - false - false - - 0 - true - false - false - false - high-resolution - false - true - false - false - false - false - false - false - false - true - true + false + false + false + false + true false - true - false - - false - false - false - false + false + true + false + false + false + false false - false - false - false - false - false - false - false - false - false - 0 - true - false - + false + high-resolution + false + 204959 + false true + false + + + true + + false + false + false + true + true + true + false + 0 + true + false + false + true + true + false + false + true + false + false + false + false + false + false + true + false + false + + false + false + false + 0 + false + true + + false + false + false + true + false + false + true + 1 + false + false + false + false + false + false + false + false + false + false + false + false + false + + true + 2203069 + true + false + false + true @@ -597,22 +600,22 @@ + + + + - + - - - - - - - + + + @@ -709,11 +712,14 @@ Description: <sale.description or ''> Reference: <sale.reference or ''> - Date: <format_date(sale.sale_date or today, sale.party.lang)> - Delivery Address: - <for each="line in sale.delivery_full_address.splitlines()"> - <line> - </for> + Date: <format_date((sale.quotation_date if sale.state in ['draft', 'quotation'] and sale.quotation_date else sale.sale_date) or today, sale.party.lang)> + <if test="sale.state in ['draft', 'quotation'] and sale.quotation_expire"> + Expiration Date: <format_date(sale.quotation_expire, sale.party.lang)> + </if> + Delivery Address: + <for each="line in sale.delivery_full_address.splitlines()"> + <line> + </for> @@ -769,25 +775,25 @@ <if test="line.product_name"> <line.product_name> </if> - <if test="line.description"> + <if test="line.description"> <for each="description in line.description.split('\n')"> <description> </for> </if> - <format_number_symbol(line.quantity, sale.party.lang, line.unit, digits=line.unit.digits) if line.unit else format_number(line.quantity, sale.party.lang)> + <format_number_symbol(line.quantity, sale.party.lang, line.unit, digits=line.unit.digits) if line.unit else format_number(line.quantity, sale.party.lang)> - <format_currency(line.unit_price, sale.party.lang, sale.currency, digits=line.__class__.unit_price.digits[1])> + <format_currency(line.unit_price, sale.party.lang, sale.currency, digits=line.__class__.unit_price.digits[1])> <for each="tax in line.taxes"> - <tax.description> + <tax.description> </for> - <format_currency(line.amount, sale.party.lang, sale.currency)> + <format_currency(line.amount, sale.party.lang, sale.currency)> @@ -811,14 +817,14 @@ <for each="description in (line.description or '').split('\n')"> - <description> + <description> </for> - <format_currency(line.amount, sale.party.lang, sale.currency)> + <format_currency(line.amount, sale.party.lang, sale.currency)> @@ -839,7 +845,6 @@ - <for each="description in (line.description or '').split('\n')"> @@ -898,6 +903,7 @@ + </for> @@ -912,7 +918,6 @@ - @@ -950,10 +955,10 @@ - <for each="comment in (sale.comment or '').split('\n')"> + <for each="comment in (sale.comment or '').split('\n')"> <comment> </for> - </for> + </for> \ No newline at end of file diff --git a/modules/sale/sale.py b/modules/sale/sale.py index 388f924ca2..31d17e638f 100644 --- a/modules/sale/sale.py +++ b/modules/sale/sale.py @@ -7,7 +7,8 @@ from decimal import Decimal from functools import partial from itertools import chain, groupby -from sql import Null +from sql import Literal, Null, operators +from sql.conditionals import Coalesce from sql.functions import CharLength from trytond import backend @@ -89,7 +90,39 @@ class Sale( states={ 'readonly': Eval('state') != 'draft', }) + quotation_date = fields.Date( + "Quotation Date", + states={ + 'readonly': ( + (Eval('state') != 'draft') + | (Eval('number') & Eval('lines'))), + 'invisible': ~Eval('state').in_(['draft', 'quotation']), + }, + help="When the quotation was edited.") + quotation_validity = fields.TimeDelta( + "Quotation Validity", + domain=['OR', + ('quotation_validity', '>=', datetime.timedelta()), + ('quotation_validity', '=', None), + ], + states={ + 'readonly': Eval('state') != 'draft', + 'invisible': ~Eval('state').in_(['draft', 'quotation']), + }, + help="How much time the quotation is valid.") + quotation_expire = fields.Function(fields.Date( + "Quotation Expire", + states={ + 'invisible': ~Eval('state').in_(['draft', 'quotation']), + }, + help="Until when the quotation is still valid."), + 'on_change_with_quotation_expire') sale_date = fields.Date('Sale Date', + domain=[ + If(Eval('quotation_expire') & (Eval('state') == 'confirmed'), + ('sale_date', '<=', Eval('quotation_expire')), + ()), + ], states={ 'readonly': ~Eval('state').in_(['draft', 'quotation']), 'required': ~Eval('state').in_( @@ -437,6 +470,8 @@ class Sale( @fields.depends('company') def on_change_company(self): + self.quotation_validity = self.default_quotation_validity( + company=self.company.id if self.company else None) self.payment_term = self.default_payment_term( company=self.company.id if self.company else None) self.invoice_method = self.default_invoice_method( @@ -444,6 +479,43 @@ class Sale( self.shipment_method = self.default_shipment_method( company=self.company.id if self.company else None) + @classmethod + def default_quotation_validity(cls, **pattern): + Config = Pool().get('sale.configuration') + config = Config(1) + return config.get_multivalue('sale_quotation_validity', **pattern) + + @fields.depends('company', 'quotation_date', 'quotation_validity') + def on_change_with_quotation_expire(self, name=None): + pool = Pool() + Date = pool.get('ir.date') + if self.quotation_validity is not None: + with Transaction().set_context( + company=self.company.id if self.company else None): + today = Date.today() + return (self.quotation_date or today) + self.quotation_validity + + @classmethod + def domain_quotation_expire(cls, domain, tables): + pool = Pool() + Date = pool.get('ir.date') + today = Date.today() + field = cls.quotation_expire._field + table, _ = tables[None] + name, operator, value = domain + Operator = fields.SQL_OPERATORS[operator] + column = ( + Coalesce(table.quotation_date, today) + Coalesce( + table.quotation_validity, datetime.timedelta())) + expression = Operator(column, field._domain_value(operator, value)) + if isinstance(expression, operators.In) and not expression.right: + expression = Literal(False) + elif isinstance(expression, operators.NotIn) and not expression.right: + expression = Literal(True) + expression = field._domain_add_null( + column, operator, value, expression) + return expression + @staticmethod def default_state(): return 'draft' @@ -1061,8 +1133,16 @@ class Sale( @Workflow.transition('quotation') @set_employee('quoted_by') def quote(cls, sales): - for sale in sales: - sale.check_for_quotation() + pool = Pool() + Date = pool.get('ir.date') + for company, c_sales in groupby(sales, key=lambda s: s.company): + with Transaction().set_context(company=company.id): + today = Date.today() + for sale in c_sales: + sale.check_for_quotation() + if not sale.quotation_date: + sale.quotation_date = today + cls.save(sales) cls.set_number(sales) @property @@ -1223,6 +1303,18 @@ class Sale( def modify_header(cls, sales): pass + @classmethod + def cancel_expired_quotation(cls): + pool = Pool() + Date = pool.get('ir.date') + today = Date.today() + sales = cls.search([ + ('state', '=', 'quotation'), + ('quotation_expire', '<', today), + ('company', '=', Transaction().context.get('company', -1)), + ]) + cls.cancel(sales) + class SaleIgnoredInvoice(ModelSQL): __name__ = 'sale.sale-ignored-account.invoice' diff --git a/modules/sale/sale.xml b/modules/sale/sale.xml index 559a8c0332..69667212cf 100644 --- a/modules/sale/sale.xml +++ b/modules/sale/sale.xml @@ -554,4 +554,12 @@ this repository contains the full copyright notices and license terms. --> + + + + sale.sale|cancel_expired_quotation + + days + + diff --git a/modules/sale/tests/scenario_sale_quotation.rst b/modules/sale/tests/scenario_sale_quotation.rst new file mode 100644 index 0000000000..7b0fa0cf91 --- /dev/null +++ b/modules/sale/tests/scenario_sale_quotation.rst @@ -0,0 +1,57 @@ +======================= +Sale Quotation Scenario +======================= + +Imports:: + + >>> import datetime as dt + + >>> from proteus import Model + >>> from trytond.modules.account.tests.tools import create_chart + >>> from trytond.modules.company.tests.tools import create_company + >>> from trytond.tests.tools import activate_modules, assertEqual + + >>> today = dt.date.today() + +Activate modules:: + + >>> config = activate_modules('sale', create_company, create_chart) + + >>> Cron = Model.get('ir.cron') + >>> Sale = Model.get('sale.sale') + >>> SaleConfiguration = Model.get('sale.configuration') + >>> Party = Model.get('party.party') + +Set quotation validity to 1 week:: + + >>> sale_configuration = SaleConfiguration(1) + >>> sale_configuration.sale_quotation_validity = dt.timedelta(weeks=1) + >>> sale_configuration.save() + +Create customer:: + + >>> customer = Party(name="Customer") + >>> customer.save() + +Create a quotation:: + + >>> sale = Sale(party=customer) + >>> sale.click('quote') + >>> sale.state + 'quotation' + >>> assertEqual(sale.quotation_date, today) + >>> assertEqual(sale.quotation_validity, dt.timedelta(weeks=1)) + >>> assertEqual(sale.quotation_expire, today + dt.timedelta(weeks=1)) + +Expire quotation:: + + >>> sale.quotation_date = today - dt.timedelta(weeks=2) + >>> sale.save() + + >>> cron, = Cron.find( + ... [('method', '=', 'sale.sale|cancel_expired_quotation')], limit=1) + >>> cron.click('run_once') + + >>> sale.reload() + >>> sale.state + 'cancelled' diff --git a/modules/sale/view/configuration_form.xml b/modules/sale/view/configuration_form.xml index 07bea14ff0..519fe5028d 100644 --- a/modules/sale/view/configuration_form.xml +++ b/modules/sale/view/configuration_form.xml @@ -10,6 +10,8 @@ this repository contains the full copyright notices and license terms. -->