diff --git a/stock_reorder_forecast/README.rst b/stock_reorder_forecast/README.rst new file mode 100644 index 000000000000..06bcb554dc9d --- /dev/null +++ b/stock_reorder_forecast/README.rst @@ -0,0 +1,171 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====================== +Stock Reorder Forecast +====================== + + +Allows to predict date when stock levels will reach minimum by +analizying sales volume in a period and therefore to trigger RFQ's ahead +of time + +Extends stock to calculate period turnover and ultimate date of order +(the date where we reach minimum stock) +This module allows to create RFQ's by checking the product form and +examining the ultimate purchase value. + +The ultimate purchase value is the date we forecast this product will not be +available. It is obtained by calculating the average sales rate of this +product and predicting at this rate how long will it take for the +stock to reach 0 at this speed. + +The period upon wich we calculate this average rate be personalized, default is: + +TURNOVER_PERIOD = Amount of time to calculate average (default 365) +TURNOVER_AVERAGE = Average sale rate in that period. +Ultimate Purchase = Day in the future where stock should finish at current +rate. + +All values are (period, average) are kept on (in order of importance): + * Supplier Info + * Partner + * Category + +if the values are not set on supplier info it will default to values on +partner, if not set on supplier will default to category, if not set anywhere +will default to Hardcoded values (ir.config.parameters period=365 days). + +THe user can also trigger orders from the supplier form. There is a wizard +that would allow to order: + + * All the products provided by this partner (in required amounts + considering turnover_average, current stock and maximum_stock) + * All the products provided by this partner as primary supplier + + This wizard also provides an overview of existing RFQ lines + +The turnover average and the ultimate purchase derived by it are calculated in +bulk by a daily cron job. + + +Configuration +============= + +To configure this module, you need to: +set turnover_period stock_period_max , stock_period_min on: + + * Product + * Supplier Infos + * Partners + * Categories + * Default value + +These values will be taken in this order of priority (highest to lowest) +if the values in product, Supplier Info, Partners, Categories are all not +set it will revert to Default Value, defined in installation data is a +company parameter. + +Also set the frequency of turnover and purchase date calculation by setting +in Automatic Actions the execution of cron job "Purchase Proposal Refresh" +(by default set at once a day). + + +Usage +===== + +Set on product and/or partner(supplier) and/or product category the values +of turnover period. + +Make sure the cron job "Purchase Proposal Refresh" is activated, launch it +manually the first time in order to have all "ultimate dates" for products +calculated. Set the cron job time/date at a convenient time and frequency, this +job will refresh all stats used for forcasting ultimate purchase date. If you +have a high volume of sales and do frequent resupplies it is advisable to +launch it multiple times a day. + + +View products to see all products with an ultimate order date, for that +interface you can generate a RFQ to desired date. + +You can also view from the partner/supplier form all products ordered by this +partner. + +#. Go to ... + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/{repo_id}/{branch} + +.. repo_id is available in https://github.com/OCA/maintainer-tools/blob/master/tools/repos_with_ids.txt +.. branch is "9.0" for example + + +Known issues / Roadmap +====================== +* Does not support Multicompany, calculation of stats will allways work on + cross-company products purchases and pickings. It will only calculate outgoing + moves , not internal moves. So the stats will be representative of all + companies global stats (all sales from all companies/turnover period of + product). + + Implementing a full multicompany support will require additional support + datastrutures. + + from a functional stand point, the global stats may be still usefull in some + multicompany configurations, not all. + + +* Another useful feature would be to trigger RFQ's automatically. Currently + the users receive stats and can press a button to make a RFQ based on their + decisions. we could make options to make an automatic RFQ when ultimate + purchase gets up to X days from now. + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Giovanni Francesco Capalbo +* Holger Brunn +* Hans Van Dijk +* Ronald Portier + +Funders +------- + +The development of this module has been financially supported by: + +* Therp B.V. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. + +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 diff --git a/stock_reorder_forecast/__init__.py b/stock_reorder_forecast/__init__.py new file mode 100644 index 000000000000..276ca7d8bf70 --- /dev/null +++ b/stock_reorder_forecast/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import models +from . import wizards diff --git a/stock_reorder_forecast/__openerp__.py b/stock_reorder_forecast/__openerp__.py new file mode 100644 index 000000000000..519d7d287232 --- /dev/null +++ b/stock_reorder_forecast/__openerp__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Stock reorder forecast", + "version": "9.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Stock", + "summary": "Predict date stock levels will reach minimum and trigger RFQ", + "depends": [ + 'product', + 'stock', + 'sale', + 'purchase' + ], + "demo": [ + 'demo/data.xml', + ], + "data": [ + 'data/ir_config_parameter.xml', + 'wizards/purchase_wizard.xml', + 'wizards/purchase_supplier_wizard.xml', + 'views/product_product.xml', + "views/product_template.xml", + 'views/product_supplierinfo.xml', + 'views/product_category.xml', + 'views/partner_view.xml', + 'data/cron.xml', + ], + "installable": True, +} diff --git a/stock_reorder_forecast/data/cron.xml b/stock_reorder_forecast/data/cron.xml new file mode 100644 index 000000000000..33a3411028ea --- /dev/null +++ b/stock_reorder_forecast/data/cron.xml @@ -0,0 +1,16 @@ + + + + + Purchase Proposal Refresh + + 1 + days + -1 + + + + + + + diff --git a/stock_reorder_forecast/data/ir_config_parameter.xml b/stock_reorder_forecast/data/ir_config_parameter.xml new file mode 100644 index 000000000000..9b5ab2e6d42c --- /dev/null +++ b/stock_reorder_forecast/data/ir_config_parameter.xml @@ -0,0 +1,24 @@ + + + + + default_turnover_period + 365 + + + + default_period_min + 91 + + + + default_period_max + 185 + + + + default_purchase_multiple + 1.0 + + + diff --git a/stock_reorder_forecast/demo/data.xml b/stock_reorder_forecast/demo/data.xml new file mode 100644 index 000000000000..9cca5f8ca1d8 --- /dev/null +++ b/stock_reorder_forecast/demo/data.xml @@ -0,0 +1,116 @@ + + + + + TMPL1 + + + + + + TMPL2 + + + + + + TMPL3 + + + + + + + + product_noperiod + + product + + + + PERIOD90 + + product + 90 + + + + + PERIOD180 + + product + 180 + + + + + + + + + + 2 + 1 + + + + + + + 1 + 1 + + + + + + + 1 + 1 + + + + + + New partner + + + demo@demo.com + center street + belleville + 5431 + + + + + + + 1 + 1 + + + + NOPERIOD + + + PERIOD30 + 30 + + + PERIOD180 + 180 + + + PERIOD360 + 360 + + + + + diff --git a/stock_reorder_forecast/models/__init__.py b/stock_reorder_forecast/models/__init__.py new file mode 100644 index 000000000000..8a9b70b4cf2b --- /dev/null +++ b/stock_reorder_forecast/models/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import product_product +from . import product_category +from . import product_supplierinfo +from . import purchase_order +from . import purchase_order_line +from . import res_partner +from . import procurement_order +from . import product_template diff --git a/stock_reorder_forecast/models/procurement_order.py b/stock_reorder_forecast/models/procurement_order.py new file mode 100644 index 000000000000..c22b3d83fdf6 --- /dev/null +++ b/stock_reorder_forecast/models/procurement_order.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import api, models + + +class ProcurementOrder(models.Model): + _inherit = 'procurement.order' + + @api.model + def _run(self, procurement): + if (procurement.rule_id and procurement.rule_id.action == 'buy'): + # disable making PO's from procurement orders + return True + return super(ProcurementOrder, self)._run(procurement) diff --git a/stock_reorder_forecast/models/product_category.py b/stock_reorder_forecast/models/product_category.py new file mode 100644 index 000000000000..099ec18fba9f --- /dev/null +++ b/stock_reorder_forecast/models/product_category.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import fields, models + + +class ProductCategory(models.Model): + _inherit = 'product.category' + + stock_period_min = fields.Integer( + 'Minimum days of stock', help="Minimum stock in days of turnover. " + "Used by the purchase proposal.") + stock_period_max = fields.Integer( + 'Maximium days of stock', help="Maximum stock in days turnover to " + "resupply for. Used by the purchase proposal.") + turnover_period = fields.Integer( + 'Turnover period', help="Turnover days to calculate average turnover " + "per day. Used by the purchase proposal.") diff --git a/stock_reorder_forecast/models/product_product.py b/stock_reorder_forecast/models/product_product.py new file mode 100644 index 000000000000..d0d64ea5d1fb --- /dev/null +++ b/stock_reorder_forecast/models/product_product.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# © 2012-2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from datetime import date, datetime, timedelta +from openerp import api, fields, models +from openerp.tools import float_round, DEFAULT_SERVER_DATE_FORMAT +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT +from openerp.addons.decimal_precision import get_precision + + +class ProductProduct(models.Model): + + _inherit = 'product.product' + + stock_period_max = fields.Integer( + 'Maximium days stock', help='Maximum stock in days turnover to ' + 'resupply for. Used by the purchase proposal.') + stock_period_min = fields.Integer( + 'Minimium days stock', help='Miminum stock in days turnover to ' + 'resupply for. Used by the purchase proposal.') + turnover_period = fields.Integer( + string='Turnover period', + help='Turnover days to calculate average ' + 'turnover per day. Used by the purchase proposal.') + turnover_average = fields.Float( + 'Turnover per day', digits=get_precision('Purchase Price'), + readonly=True, help='Average turnover per day. Used by the purchase ' + 'proposal.') + ultimate_purchase = fields.Date( + 'Ultimate purchase', readonly=True, help='Ultimate date to purchase ' + 'for not running out of stock. Used by the purchase proposal.') + automatic_purchase_days = fields.Integer( + "Trigger automatic purchase proposal on first supplier", + default=0, + help="Trigger an automatic RFQ on first (default supplier) if ultimate" + "purchase is less than these days away" + ) + is_buy = fields.Boolean("Does the product have Buy Route?", + compute='_compute_isbuy', store=True) + + @api.multi + @api.depends('product_tmpl_id.route_ids') + def _compute_isbuy(self): + """ + Returns true if 'Buy' is amongst routes for this product + """ + for this in self: + prd_routes = this.product_tmpl_id.route_ids.ids + buy_routes = this.product_tmpl_id._get_buy_route() + this.is_buy = any(x in buy_routes for x in prd_routes) + + def has_purchase_draft(self): + sql = """ + select count(id) from purchase_order_line + where product_id = %s + and order_id in (select id from purchase_order where + state='draft') + """ + self.env.cr.execute(sql, (self.id, )) + return bool(self.env.cr.fetchone()[0]) + + def _get_ultimate_purchase( + self, stock_period_min, turnover_average): + for this in self: + stock_days = int(float_round(((( + this.virtual_available or 0 + ) - stock_period_min) / turnover_average) + .5, 0)) + if this.has_purchase_draft(): + return False + if stock_days < 0: + return fields.Date.to_string( + date.today()) + return fields.Date.to_string( + date.today() + timedelta(days=stock_days)) + + def _get_turnover_period(self): + product_age = date.today() - datetime.strptime( + self.create_date, DEFAULT_SERVER_DATETIME_FORMAT + ).date() + if not self.turnover_period: + self.turnover_period = int(self.env['ir.config_parameter'].search( + [('key', '=', 'default_turnover_period')])[0].value) + if product_age.days < self.turnover_period: + if product_age.days == 0: + return 1 + return product_age.days + return self.turnover_period + + @api.model + def calc_purchase_date(self): + # Defauts if not specified + stock_per_min_default = self.env['ir.config_parameter'].search( + [('key', '=', 'default_period_min')])[0].value + stock_per_max_default = self.env['ir.config_parameter'].search( + [('key', '=', 'default_period_max')])[0].value + + # calculate turnover_average over a certain period (turnover_period) + # The turnover_period can be stored per product, supplier or + # product category (in order of precedence). + # The delivery and purchase period can be stored per supplier_info, + # supplier or product category. + # Defaults are codes in the cr.execute parameters. + # One query retrieves turnover_average and stock_period_min per prd. + # Each product is updated with turnover_average and ultimate_purchase + # ( = now - stock_period_min + virtual stock / turnover_average). + # In case the turnover period exceeds the products age the latter + # defaults + # The WITH clause determines the parameters per product. + # The final SELECT calculates turnover and detects procurements. + # Called by cronjob every day. + + sql = """ + WITH TP AS + (SELECT + PP.id AS product_id, + COALESCE( + NULLIF(PS.stock_period_min, 0), + NULLIF(RP.stock_period_min, 0), + NULLIF(PC.stock_period_min, 0), + %s + ) + AS stock_period_min, + COALESCE( + NULLIF(PP.stock_period_max, 0), + NULLIF(RP.stock_period_max, 0), + NULLIF(PC.stock_period_max, 0), + %s + ) + AS stock_period_max + FROM product_product PP + JOIN product_template PT ON PP.product_tmpl_id = PT.id + LEFT JOIN product_supplierinfo PS + ON PS.product_id = PP.id + LEFT JOIN res_partner RP ON RP.id = PS.name AND RP.active + LEFT JOIN product_category PC ON PC.id = PT.categ_id + WHERE PP.active + ) + SELECT + TP.product_id AS id, + MAX(TP.stock_period_min) AS stock_period_min, + MAX(TP.stock_period_max) AS stock_period_max + FROM TP + GROUP BY TP.product_id + """ + self.env.cr.execute( + sql, + ( + stock_per_min_default, + stock_per_max_default, + ) + ) + sqlresult = self.env.cr.fetchall() + for product_id, stock_period_min, stock_period_max in sqlresult: + this = self.env['product.product'].browse(product_id) + turnover_period = this._get_turnover_period() + # turnover_period may be 0 for new products + if turnover_period == 0: + turnover_period = 1 + sales = self.env['sale.order'].search([( + 'date_order', + '>=', + date.strftime( + date.today() - timedelta(days=turnover_period), + DEFAULT_SERVER_DATE_FORMAT + ) + )]) + line_qty = self.env['sale.order.line'].search( + [ + ('product_id', '=', product_id), + ('state', 'not in', ['draft', 'cancel', 'sent']), + ('order_id', 'in', sales.ids) + ]).mapped('product_uom_qty') + turnover_average = float_round( + sum(line_qty)/turnover_period, + self._fields['turnover_average'].digits[1] + ) + if turnover_average != 0.0 and this.is_buy: + up_val = this._get_ultimate_purchase( + stock_period_min, turnover_average + ) + values = { + 'turnover_average': turnover_average, + 'ultimate_purchase': up_val, + 'stock_period_max': stock_period_max, + 'stock_period_min': stock_period_min, + } + else: # remove data when supply method no longer = "buy" + values = { + 'turnover_average': 0, + 'ultimate_purchase': False, + } + this.write(values) + return True diff --git a/stock_reorder_forecast/models/product_supplierinfo.py b/stock_reorder_forecast/models/product_supplierinfo.py new file mode 100644 index 000000000000..3d2464e01671 --- /dev/null +++ b/stock_reorder_forecast/models/product_supplierinfo.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# © 2013-2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import fields, models + + +class ProductSupplierinfo(models.Model): + _inherit = 'product.supplierinfo' + + purchase_multiple = fields.Float( + 'Purchase multiple', + help="Purchase in multiples of. Used by the purchase proposal.") + stock_period_min = fields.Integer( + 'Minimum days stock', help="Minimum stock in days of turnover. Used by" + " the purchase proposal.") diff --git a/stock_reorder_forecast/models/product_template.py b/stock_reorder_forecast/models/product_template.py new file mode 100644 index 000000000000..54f30fed7500 --- /dev/null +++ b/stock_reorder_forecast/models/product_template.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + turnover_average_aggregated = fields.Float( + 'Turnover average', compute='_compute_turnover_average_aggregated', + readonly=True, store=True, + help='Average turnover of product variants per day. ' + 'Used by the purchase proposal.') + + @api.multi + @api.depends('product_variant_ids.turnover_average') + def _compute_turnover_average_aggregated(self): + for this in self: + this.turnover_average_aggregated = sum( + this.mapped('product_variant_ids.turnover_average') + ) diff --git a/stock_reorder_forecast/models/purchase_order.py b/stock_reorder_forecast/models/purchase_order.py new file mode 100644 index 000000000000..ace87f346348 --- /dev/null +++ b/stock_reorder_forecast/models/purchase_order.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import api, models + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + @api.multi + def update_proposal(self): + rfqs = self.filtered(lambda x: x.state == 'draft') + rfqs.mapped('order_line').mapped('product_id').write( + {'ultimate_purchase': False} + ) + # set the partner's ultimate_purchase as the soonest + # of all the UP's in the products where he is supplier. + self.env['res.partner'].sql_update_partner() + return True + + @api.model + def create(self, vals): + purchase = super(PurchaseOrder, self).create(vals=vals) + purchase.update_proposal() + return purchase + + @api.multi + def write(self, vals): + result = super(PurchaseOrder, self).write(vals) + if result: + self.update_proposal() + return result diff --git a/stock_reorder_forecast/models/purchase_order_line.py b/stock_reorder_forecast/models/purchase_order_line.py new file mode 100644 index 000000000000..d08a9dc9447f --- /dev/null +++ b/stock_reorder_forecast/models/purchase_order_line.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, api +from openerp.tools.float_utils import float_round + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + # NOTE in 9.0 the onchanges have finally been ported to new API + + def _get_stock_period_max(self): + if self.product_id.stock_period_max > 0: + res = self.product_id.stock_period_max + elif self.partner_id.stock_period_max > 0: + res = self.partner_id.stock_period_max + elif self.product_id.product_tmpl_id.categ_id.stock_period_max > 0: + res = self.product_id.product_tmpl_id.categ_id.stock_period_max + else: + # TODO understand how to create integer parameters + res = float(self.env['ir.config_parameter'].search( + [('key', '=', 'default_period_max')])[0].value) + return res + + def _get_purchase_multiple(self): + for supplier in self.product_id.seller_ids: + if supplier.purchase_multiple: + return supplier.purchase_multiple + return float(self.env['ir.config_parameter'].search( + [('key', '=', 'default_purchase_multiple')])[0].value) + + @api.onchange('product_id') + def onchange_product_id(self): + # This will trigger also _onchange_quantity and _suggest_quantity + # Because of the purchase proposal we calculate the qty when set to 0" + if self.product_qty == 0 and self.product_id: + product = self.product_id + stock_period_max = self._get_stock_period_max() + purchase_multiple = self._get_purchase_multiple() + stock = product.virtual_available + turnover_average = product.turnover_average + qty = float_round( + turnover_average * stock_period_max - stock, 0 + ) + self.product_qty = int( + (qty + purchase_multiple - 1) / + purchase_multiple + ) * purchase_multiple + super(PurchaseOrderLine, self).onchange_product_id() diff --git a/stock_reorder_forecast/models/res_partner.py b/stock_reorder_forecast/models/res_partner.py new file mode 100644 index 000000000000..7d9d8ab79182 --- /dev/null +++ b/stock_reorder_forecast/models/res_partner.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +''' +Additional fields for the purchase proposal +''' +from openerp import api, fields, models + + +class ResPartner(models.Model): + + _inherit = 'res.partner' + + @api.model + def sql_update_partner(self): + sql = """ + UPDATE res_partner RP + SET ultimate_purchase = + (SELECT MIN(PP.ultimate_purchase) + FROM product_supplierinfo PS + JOIN product_product PP + ON PS.product_id = PP.id AND PS.sequence = 1 + AND PP.active AND NOT PP.ultimate_purchase IS NULL + WHERE PS.name = RP.id), + write_date = NOW() AT TIME ZONE 'UTC', write_uid = %s + WHERE active AND (supplier OR NOT ultimate_purchase IS NULL); + """ + self.env.cr.execute(sql, [self.env.uid]) + + @api.multi + def _compute_product_supplierinfo_primary(self): + """given a partner, return a list of products it provides + as primary supplier""" + self.env.cr.execute( + """ + with min_select as ( + select product_tmpl_id, min(sequence) as min_sequence from + product_supplierinfo group by product_tmpl_id + ) + select + name, array_agg(product_tmpl_id) + from product_supplierinfo as sup_select + where name in %s and sequence in ( + select min_sequence from min_select + where + min_select.product_tmpl_id = sup_select.product_tmpl_id + ) + group by name + """, + (tuple(self.ids),) + ) + # this used to return an {userid : [product_template]} + # we need product_product + partner2products = dict(self.env.cr.fetchall()) + for this in self: + if this.id in partner2products.keys(): + p_products = self.env['product.product'].search([ + ('product_tmpl_id', 'in', partner2products[this.id]) + ]).ids + this.primary_product_ids = p_products + + @api.multi + def _compute_product_supplierinfo(self): + """given a partner, return a list of all products it provides""" + self.env.cr.execute( + """with templates as ( + select name, product_tmpl_id as T from product_supplierinfo + where name in %s + ) + select templates.name, array_agg(id) from product_product + inner join templates on ( + product_product.product_tmpl_id = templates.T + ) group by templates.name + """, (tuple(self.ids),) + ) + partner2products = dict(self.env.cr.fetchall()) + for this in self: + if this.id in partner2products.keys(): + this.product_ids = partner2products[this.id] + + stock_period_min = fields.Integer( + 'Minimum days stock', + help="Minimum days of stock to hold. " + "Used by the purchase proposal." + ) + stock_period_max = fields.Integer( + 'Maximum days stock', + help="""Period in days to resupply for. + Used by the purchase proposal.""") + turnover_period = fields.Integer( + 'Turnover period', + help="""Turnover period in daysto calculate average + turnover per week. Used by the purchase proposal.""") + ultimate_purchase = fields.Date( + 'Ultimate purchase', + readonly="1", + help="""Ultimate date to purchase for not running out of + stock for any product supplied by this supplier. Used by the + purchase proposal.""") + + product_ids = fields.Many2many( + 'product.product', + compute='_compute_product_supplierinfo', + string="Products", + ) + primary_product_ids = fields.Many2many( + 'product.product', + compute='_compute_product_supplierinfo_primary', + string="Products", + ) diff --git a/stock_reorder_forecast/static/description/icon.png b/stock_reorder_forecast/static/description/icon.png new file mode 100644 index 000000000000..4c7ab302908e Binary files /dev/null and b/stock_reorder_forecast/static/description/icon.png differ diff --git a/stock_reorder_forecast/tests/__init__.py b/stock_reorder_forecast/tests/__init__.py new file mode 100644 index 000000000000..a3b35c81355b --- /dev/null +++ b/stock_reorder_forecast/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import test_stock_reorder_forecast +from . import test_purchase_order_line_onchange +from . import test_wizards diff --git a/stock_reorder_forecast/tests/test_purchase_order_line_onchange.py b/stock_reorder_forecast/tests/test_purchase_order_line_onchange.py new file mode 100644 index 000000000000..01ad9ed5869b --- /dev/null +++ b/stock_reorder_forecast/tests/test_purchase_order_line_onchange.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- + +from datetime import datetime +from openerp.tests.common import TransactionCase +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class TestOnchangeProductId(TransactionCase): + + def setUp(self): + super(TestOnchangeProductId, self).setUp() + self.fiscal_position_model = self.env['account.fiscal.position'] + self.fiscal_position_tax_model = self.env[ + 'account.fiscal.position.tax'] + self.tax_model = self.env['account.tax'] + self.po_model = self.env['purchase.order'] + self.po_line_model = self.env['purchase.order.line'] + self.res_partner_model = self.env['res.partner'] + self.product_tmpl_model = self.env['product.template'] + self.product_model = self.env['product.product'] + self.product_uom_model = self.env['product.uom'] + self.supplierinfo_model = self.env["product.supplierinfo"] + + def test_onchange_product_purchase_line(self): + # get uom + uom_id = self.product_uom_model.search([('name', '=', 'Unit(s)')])[0] + + # create a partner + partner_id = self.res_partner_model.create(dict(name="Testpartner")) + + # create a fiscal position + + fp_id = self.fiscal_position_model.create( + {'name': "fiscal position", 'sequence': 1} + ) + + # taxes + + tax_include_id = self.tax_model.create({'name': "Include tax", + 'amount': '21.00', + 'price_include': True, + 'type_tax_use': 'purchase'}) + + # create tmpl and product + + category_per30 = self.env.ref( + 'stock_reorder_forecast.cat_period_30') + product_tmpl_id = self.product_tmpl_model.create( + {'name': "TESTPRDTMPL", + 'list_price': 88, + 'supplier_taxes_id': [(6, 0, [tax_include_id.id])], + 'categ_id': category_per30.id, } + ) + + supplier_new = self.supplierinfo_model.create({ + 'name': partner_id.id, + 'price': 900.0, + }) + product_id = self.product_model.create( + {'product_tmpl_id': product_tmpl_id.id, + 'seller_ids': [(6, 0, [supplier_new.id])], } + ) + # supplier + # create a purchase order, quantity 0.0 to trigger main branch of + # onchange method + purchase_order = self.po_model.create({ + 'partner_id': partner_id.id, + 'fiscal_position_id': fp_id.id, + }) + orderline = self.po_line_model.create( + {'order_id': purchase_order.id, + 'name': product_id.name, + 'product_id': product_id.id, + 'product_qty': 0.0, + 'product_uom': uom_id.id, + 'price_unit': 121.0, + 'date_planned': datetime.today().strftime( + DEFAULT_SERVER_DATETIME_FORMAT), } + ) + self.assertEqual( + 1.0, orderline._get_purchase_multiple() + ) + supplier_new.write({'purchase_multiple': 3.0}) + self.assertEqual( + 3.0, orderline._get_purchase_multiple() + ) + self.assertEqual( + 185, orderline._get_stock_period_max() + ) + category_per30.write({'stock_period_max': 55}) + self.assertEqual( + 55, orderline._get_stock_period_max() + ) + partner_id.write({'stock_period_max': 43}) + self.assertEqual( + 43, orderline._get_stock_period_max() + ) + product_id.write({'stock_period_max': 3}) + self.assertEqual( + 3, orderline._get_stock_period_max() + ) + orderline.onchange_product_id() + self.assertEqual(1.0, orderline.product_qty) diff --git a/stock_reorder_forecast/tests/test_stock_reorder_forecast.py b/stock_reorder_forecast/tests/test_stock_reorder_forecast.py new file mode 100644 index 000000000000..002be68bef08 --- /dev/null +++ b/stock_reorder_forecast/tests/test_stock_reorder_forecast.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, timedelta + +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT +from openerp.tools import DEFAULT_SERVER_DATE_FORMAT +from openerp.tests.common import TransactionCase + + +class TestStockReorderForecast(TransactionCase): + + def setUp(self): + super(TestStockReorderForecast, self).setUp() + self.model_data_obj = self.env['ir.model.data'] + self.sale_order_obj = self.env['sale.order'] + self.product_obj = self.env['product.product'] + self.stock_pack_obj = self.env['stock.pack.operation'] + self.picking_obj = self.env['stock.picking'] + self.move_obj = self.env['stock.move'] + self.partner_delta_id = self.model_data_obj.xmlid_to_res_id( + 'base.res_partner_4' + ) + self.partner_agrolite_id = self.model_data_obj.xmlid_to_res_id( + 'base.res_partner_2' + ) + self.picking_type_in = self.model_data_obj.xmlid_to_res_id( + 'stock.picking_type_in' + ) + self.picking_type_out = self.model_data_obj.xmlid_to_res_id( + 'stock.picking_type_out' + ) + self.supplier_location = self.model_data_obj.xmlid_to_res_id( + 'stock.stock_location_suppliers' + ) + self.stock_location = self.model_data_obj.xmlid_to_res_id( + 'stock.stock_location_stock' + ) + + self.product_noper = self.env.ref( + 'stock_reorder_forecast.product_noper') + self.product_period90 = self.env.ref( + 'stock_reorder_forecast.product_period90') + self.product_period180 = self.env.ref( + 'stock_reorder_forecast.product_period180') + self.supplier1 = self.env.ref( + 'stock_reorder_forecast.product_supplierinfo_1' + ) + + def test_calc_purchase_date(self): + self.product_obj.calc_purchase_date() + # No sale orders and product with no period + self.assertEqual(0.0, self.product_noper.turnover_average) + self.assertEqual(False, self.product_noper.ultimate_purchase) + # No sale orders and product with period 90 + self.assertEqual(0.0, self.product_period90.turnover_average) + self.assertEqual(False, self.product_period90.ultimate_purchase) + # test _get_turnover_period + # turnover period will be 1 because this p-roduct has been just created + # we predate it after + + self.assertEqual(1, self.product_period90._get_turnover_period()) + self.assertEqual( + 1, + self.product_noper._get_turnover_period() + ) + # Create sale order for product noperiod + so1 = self.sale_order_obj.create({ + 'partner_id': self.partner_agrolite_id, + 'partner_invoice_id': self.partner_agrolite_id, + 'partner_shipping_id': self.partner_agrolite_id, + 'order_line': [(0, 0, {'name': self.product_noper.name, + 'product_id': self.product_noper.id, + 'product_uom_qty': 184, + 'product_uom': self.product_noper.uom_id.id, + 'price_unit': 75})], + 'pricelist_id': self.env.ref('product.list0').id, }) + self.product_obj.calc_purchase_date() + # should still be a still 0 + + self.assertEqual(0.0, self.product_noper.turnover_average) + self.assertEqual(False, self.product_noper.ultimate_purchase) + self.assertEqual(0.00, self.product_period90.turnover_average) + self.assertEqual(False, self.product_period90.ultimate_purchase) + so1.action_confirm() + self.product_obj.calc_purchase_date() + self.assertEqual(184.0, self.product_noper.turnover_average) + self.assertEqual(0.0, self.product_period90.turnover_average) + # create and confirm 2 sale orders for the 90 day period + so2 = self.sale_order_obj.create({ + 'partner_id': self.partner_agrolite_id, + 'partner_invoice_id': self.partner_agrolite_id, + 'partner_shipping_id': self.partner_agrolite_id, + 'order_line': [(0, 0, {'name': self.product_period90.name, + 'product_id': self.product_period90.id, + 'product_uom_qty': 20, + 'product_uom': + self.product_period90.uom_id.id, + 'price_unit': 33})], + 'pricelist_id': self.env.ref('product.list0').id, }) + so3 = self.sale_order_obj.create({ + 'partner_id': self.partner_agrolite_id, + 'partner_invoice_id': self.partner_agrolite_id, + 'partner_shipping_id': self.partner_agrolite_id, + 'order_line': [(0, 0, {'name': self.product_period90.name, + 'product_id': self.product_period90.id, + 'product_uom_qty': 20, + 'product_uom': + self.product_period90.uom_id.id, + 'price_unit': 33})], + 'pricelist_id': self.env.ref('product.list0').id, }) + # confirm orders + so2.action_confirm() + so3.action_confirm() + self.product_obj.calc_purchase_date() + # verify rate + self.assertEqual(40.0, self.product_period90.turnover_average) + # make a sale order older than 90 days and verify it does not influence + # the resulting turnover_average + + # extra check these products are newly created so the period will not + # be the days specified in their turnover period + # the turnover average is calculated on ONE DAY because the product age + # ( time from creation date) is 1 day and therefore less than the + # default product period. + + # pre-date magic field create date for product_period90 (2 years) + sql = "update product_product set create_date=%s where id = %s" + self.env.cr.execute( + sql, ( + (date.today() - timedelta(days=730)).strftime( + DEFAULT_SERVER_DATETIME_FORMAT), + self.product_period90.id + ) + ) + self.product_obj.calc_purchase_date() + # sold 40 elements in 90 days + self.assertEqual(40, self.product_period90.turnover_average) + so_old = self.sale_order_obj.create({ + 'partner_id': self.partner_agrolite_id, + 'partner_invoice_id': self.partner_agrolite_id, + 'partner_shipping_id': self.partner_agrolite_id, + 'order_line': [(0, 0, {'name': self.product_period90.name, + 'product_id': self.product_period90.id, + 'product_uom_qty': 20, + 'product_uom': + self.product_period90.uom_id.id, + 'price_unit': 33})], + 'pricelist_id': self.env.ref('product.list0').id, }) + # pre-date the magic field create_date for sale order + # fix, the calc function looks at date_order, not at created date + so_old.action_confirm() + sql = "update sale_order set date_order=%s where id = %s" + self.env.cr.execute( + sql, ( + (date.today() - timedelta(days=120)).strftime( + DEFAULT_SERVER_DATETIME_FORMAT), + so_old.id + ) + ) + self.product_obj.calc_purchase_date() + # verify turnover average is still the same, because latest SO is + # before the current turnover period for this product (90) + # sold 40 elements in the past 90 days , 60 overall + self.assertEqual(0.44, self.product_period90.turnover_average) + # verify order of period fetching + # product_noper with supplier without turnover_period and category + # without turnover_period and verify it gets turnover_period default + # verify that parnter associated to supplier has no turnover_period + self.assertEqual(False, self.supplier1.name.turnover_period) + # turnover period is allways 1 for a new product + self.assertEqual(1, self.product_noper._get_turnover_period()) + # sold 184 pieces , turnover period == product age == 1 + self.assertEqual(184, self.product_noper.turnover_average) + # pre-date magic field create date for product_period90 (2 years) + sql = "update product_product set create_date=%s where id = %s" + self.env.cr.execute( + sql, ( + (date.today() - timedelta(days=730)).strftime( + DEFAULT_SERVER_DATETIME_FORMAT), + self.product_noper.id + ) + ) + self.product_noper.write({'turnover_period': 10}) + self.product_obj.calc_purchase_date() + # test _get_turnover_period this time it will remain 10 (old product) + self.assertEqual(10, self.product_noper._get_turnover_period()) + # sold 184 pieces , period 10 days + self.product_obj.calc_purchase_date() + self.assertEqual(18.4, self.product_noper.turnover_average) + + # assign turnover_period to supplier and verify it gets that + self.product_noper.write({'turnover_period': 5}) + self.product_obj.calc_purchase_date() + # test _get_turnover_period + self.assertEqual(5, self.product_noper._get_turnover_period()) + self.product_obj.calc_purchase_date() + self.assertEqual(36.8, self.product_noper.turnover_average) + # assign turnover period to product itself and veify it supercedes all + self.product_noper.write({'turnover_period': 7}) + # test _get_turnover_period + self.assertEqual(7, self.product_noper._get_turnover_period()) + self.product_obj.calc_purchase_date() + self.assertEqual( + 26.29, self.product_noper.turnover_average) + + # increase stock and verify ultimate purchase + # ============ STOCK TEST ========== + # create an incoming shipment for 500 pieces + # of product_period180, period190 still has no RFQ so ultimate + # purchase will not be false + picking_in = self.picking_obj.create({ + 'partner_id': self.partner_delta_id, + 'picking_type_id': self.picking_type_in, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + self.move_obj.create({ + 'name': self.product_noper.name, + 'product_id': self.product_period180.id, + 'product_uom_qty': 500, + 'product_uom': self.product_period180.uom_id.id, + 'picking_id': picking_in.id, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + picking_in.action_confirm() + picking_in.do_prepare_partial() + self.stock_pack_obj.search( + [('product_id', '=', self.product_period180.id), + ('picking_id', '=', picking_in.id)]).write({'product_qty': 500}) + # Transfer Incoming Shipment. + picking_in.do_transfer() + self.assertEqual(500, self.product_period180.qty_available) + # MUST DESTROY ALL PO'S GENERATED SO WE CAN GET THE RIGHT ULTIMATE + # DATE(NOT FALSE) + self.env.cr.execute("UPDATE PURCHASE_ORDER SET STATE='cancel'") + so4 = self.sale_order_obj.create({ + 'partner_id': self.partner_agrolite_id, + 'partner_invoice_id': self.partner_agrolite_id, + 'partner_shipping_id': self.partner_agrolite_id, + 'order_line': [(0, 0, {'name': self.product_period180.name, + 'product_id': self.product_period180.id, + 'product_uom_qty': 20, + 'product_uom': + self.product_period180.uom_id.id, + 'price_unit': 33})], + 'pricelist_id': self.env.ref('product.list0').id, }) + # pre-date the magic field create_date for sale order + sql = "update sale_order set create_date=%s where id = %s" + so4.action_confirm() + # clean up draft purchase order to test ultimate purchase correctly + self.env.cr.execute("UPDATE PURCHASE_ORDER SET STATE='cancel'") + self.product_obj.calc_purchase_date() + self.assertEqual( + (date.today() + timedelta(days=20)).strftime( + DEFAULT_SERVER_DATE_FORMAT + ), + self.product_period180.ultimate_purchase + ) + + def test_supplier_calc(self): + # verify supplier product_ids + # verify supplier primary product_ids + # create a new partner and a new supplier info, + # new_supplier COMES WITH A PRIMARY PRODUCT PRIMARY_PERIOD180 + + new_supplier = self.env.ref( + 'stock_reorder_forecast.product_supplierinfo_new' + ) + new_supplier.name._compute_product_supplierinfo() + new_supplier.name._compute_product_supplierinfo_primary() + # verify that the resuser associated to supplier1 has the correct prds + # add another supplier info (with product) and verify + # compute_product_supplierinfo + # WE ALREADY HAVE supplier1 as primary supplier for + # product_noper + self.env['product.supplierinfo'].create({ + 'product_tmpl_id': self.env.ref( + 'stock_reorder_forecast.product_template2').id, + 'product_id': self.product_period90.id, + 'name': new_supplier.name.id, + 'delay': 1, + 'min_qty': 1, + 'sequence': 2, + }) + + # set another suppliernfo as primary for product_period90 + # it's primary because it has the highest sequence for that product + + self.env['product.supplierinfo'].create({ + 'product_tmpl_id': self.env.ref( + 'stock_reorder_forecast.product_template2').id, + 'product_id': self.product_period180.id, + 'name': new_supplier.name.id, + 'delay': 1, + 'min_qty': 1, + 'sequence': 2, + }) + + # supplier1 is the primary + self.env['product.supplierinfo'].create({ + 'product_tmpl_id': self.env.ref( + 'stock_reorder_forecast.product_template2').id, + 'product_id': self.product_period180.id, + 'name': self.supplier1.name.id, + 'delay': 1, + 'min_qty': 1, + 'sequence': 1, + }) + new_supplier.name._compute_product_supplierinfo() + new_supplier.name._compute_product_supplierinfo_primary() + + # Verify that the primary products for supplier new supplier + # is still unique + self.assertEqual( + self.product_period180.id in + new_supplier.name.primary_product_ids.ids, True + ) + # give new_supplier. name another primary product , product_noper + self.env['product.supplierinfo'].create({ + 'product_tmpl_id': self.env.ref( + 'stock_reorder_forecast.product_template1').id, + 'product_id': self.product_noper.id, + 'name': new_supplier.name.id, + 'delay': 1, + 'min_qty': 1, + 'sequence': 1, + }) + # test PRIMARY PRODUCTS AND PRODUCTS + self.assertEqual( + set(new_supplier.name.product_ids.ids), + set( + self.env['product.product'].search( + [('name', 'in', ['PERIOD90', 'PERIOD180'])]).ids + ) + ) + self.assertEqual( + set(new_supplier.name.primary_product_ids.ids), + set(self.env['product.product'].search( + [('name', 'in', ['PERIOD180'])]).ids + ) + ) diff --git a/stock_reorder_forecast/tests/test_wizards.py b/stock_reorder_forecast/tests/test_wizards.py new file mode 100644 index 000000000000..c5e04ea38aa0 --- /dev/null +++ b/stock_reorder_forecast/tests/test_wizards.py @@ -0,0 +1,164 @@ + +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, timedelta + +from openerp.tools import DEFAULT_SERVER_DATE_FORMAT +from openerp.tests.common import TransactionCase + + +class TestWizards(TransactionCase): + + def setUp(self): + super(TestWizards, self).setUp() + self.model_data_obj = self.env['ir.model.data'] + self.product_obj = self.env['product.product'] + self.sale_order_obj = self.env['sale.order'] + self.partner_agrolite_id = self.model_data_obj.xmlid_to_res_id( + 'base.res_partner_2' + ) + self.supplier1 = self.env.ref( + 'stock_reorder_forecast.product_supplierinfo_1' + ) + + self.product_noper = self.env.ref( + 'stock_reorder_forecast.product_noper' + ) + self.supplier3 = self.env.ref( + 'stock_reorder_forecast.product_supplierinfo_3' + ) + self.product_period180 = self.env.ref( + 'stock_reorder_forecast.product_period180') + + def test_wizards(self): + # check calcultation of ultimate date + # Verify PO creation (correct date and correct stock) + so1 = self.sale_order_obj.create({ + 'partner_id': self.partner_agrolite_id, + 'partner_invoice_id': self.partner_agrolite_id, + 'partner_shipping_id': self.partner_agrolite_id, + 'order_line': [(0, 0, { + 'name': self.product_period180.name, + 'product_id': self.product_period180.id, + 'product_uom_qty': 184, + 'product_uom': self.product_period180.uom_id.id, + 'price_unit': 75})], + 'pricelist_id': self.env.ref('product.list0').id, }) + dateplanned = (date.today() + timedelta(days=0)).strftime( + DEFAULT_SERVER_DATE_FORMAT) + self.assertEqual(False, self.product_period180.has_purchase_draft()) + # Wizard note: ultimate_purchase_to will be date planned + self.supplier3.name.write({ + 'stock_period_min': 12, 'stock_period_max': 89}) + + self.product_obj.calc_purchase_date() + self.assertEqual(False, self.product_period180.ultimate_purchase) + wiz_dict = { + 'product': self.product_period180.id, + 'supplier': self.supplier3.id, + 'name': self.supplier3.name, + 'stock_period_min': self.supplier3.name.stock_period_min, + 'stock_period_max': self.supplier3.name.stock_period_max, + 'ultimate_purchase_to': dateplanned, + } + tstwiz = self.env['purchase.purchase_wizard'].create(wiz_dict) + self.assertEqual(False, self.product_period180.has_purchase_draft()) + purchase = tstwiz.create_rfq() + # will still be false because we did not update average + self.assertEqual(False, self.product_period180.has_purchase_draft()) + # Recalculate params and relaunch the wizard + self.product_obj.calc_purchase_date() + self.assertEqual(False, self.product_period180.ultimate_purchase) + purchase = tstwiz.create_rfq() + # Still false because no sale confirmed and therefore average still 0 + self.assertEqual(False, self.product_period180.has_purchase_draft()) + self.assertEqual(False, self.product_period180.ultimate_purchase) + res = tstwiz.with_context( + active_ids=self.product_period180.ids).default_get([]) + self.assertEqual(False, res['ultimate_purchase']) + self.assertEqual(0.0, res['stock_avl']) + # product ultimate purchase should be false now, + so1.action_confirm() + self.product_obj.calc_purchase_date() + # testing update_proposal + self.assertEqual( + date.today().strftime(DEFAULT_SERVER_DATE_FORMAT), + self.product_period180.ultimate_purchase + ) + purchase = tstwiz.create_rfq() + # verify PO date and PO quantity + self.assertEqual(1, len(purchase)) + self.assertEqual(False, self.product_period180.ultimate_purchase) + self.assertEqual( + tstwiz._get_qty(self.product_period180, self.supplier3, + self.supplier3.name.stock_period_max), + purchase.order_line[0].product_qty + ) + # ==== testing qty less than 0 + # make stock available > 0 so quantity to order will be < 0 + # create an incoming shipment for 5 pieces of product_noper + # noper will have stock 5, therefore qty calculated for purchase will + # be -5, the calc_qty function will return 0.0. this check allows us + # not to generate POL's for products we don't need _get_qty <0 means do + # not order. + stock_pack_obj = self.env['stock.pack.operation'] + picking_obj = self.env['stock.picking'] + move_obj = self.env['stock.move'] + picking_type_in = self.model_data_obj.xmlid_to_res_id( + 'stock.picking_type_in') + supplier_location = self.model_data_obj.xmlid_to_res_id( + 'stock.stock_location_suppliers') + stock_location = self.model_data_obj.xmlid_to_res_id( + 'stock.stock_location_stock') + partner_delta_id = self.model_data_obj.xmlid_to_res_id( + 'base.res_partner_4') + picking_in = picking_obj.create({ + 'partner_id': partner_delta_id, + 'picking_type_id': picking_type_in, + 'location_id': supplier_location, + 'location_dest_id': stock_location}) + move_obj.create({ + 'name': self.product_noper.name, + 'product_id': self.product_noper.id, + 'product_uom_qty': 5, + 'product_uom': self.product_noper.uom_id.id, + 'picking_id': picking_in.id, + 'location_id': supplier_location, + 'location_dest_id': stock_location}) + picking_in.action_confirm() + picking_in.do_prepare_partial() + stock_pack_obj.search( + [('product_id', '=', self.product_noper.id), + ('picking_id', '=', picking_in.id)]).write({'product_qty': 5}) + # Transfer Incoming Shipment. + picking_in.do_transfer() + self.assertEqual(5, self.product_noper.qty_available) + self.assertEqual( + tstwiz._get_qty(self.product_noper, self.supplier1, + self.supplier1.name.stock_period_max), + 0 + ) + partner = self.supplier3.name + wiz_primary_dict = { + 'name': partner.id, + 'stock_period_min': self.supplier3.name.stock_period_min, + 'stock_period_max': self.supplier3.name.stock_period_max, + 'ultimate_purchase_to': dateplanned, + 'primary_supplier_only': False, + } + tst_primary_wiz = self.env['purchase.purchase_supplier_wizard'].create( + wiz_primary_dict + ) + self.product_obj.calc_purchase_date() + purchase = tst_primary_wiz.create_partner_rfq() + # the wizard is called with an active id of res.partner + res = tst_primary_wiz.with_context( + active_ids=[partner.id]).default_get([]) + self.assertEqual( + False, + res['ultimate_purchase']) + # testing update_proposal + self.assertEqual( + False, + partner.ultimate_purchase) diff --git a/stock_reorder_forecast/views/partner_view.xml b/stock_reorder_forecast/views/partner_view.xml new file mode 100644 index 000000000000..cbf8976f262c --- /dev/null +++ b/stock_reorder_forecast/views/partner_view.xml @@ -0,0 +1,71 @@ + + + + res.partner.purchase.property.form.technotrading + res.partner + form + + 36 + + + + + + + + + +