diff --git a/addons/purchase_requisition/__init__.py b/addons/purchase_requisition/__init__.py index d6210b1285d..33bbab569d0 100644 --- a/addons/purchase_requisition/__init__.py +++ b/addons/purchase_requisition/__init__.py @@ -1,3 +1,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import models +from . import wizard diff --git a/addons/purchase_requisition/__manifest__.py b/addons/purchase_requisition/__manifest__.py index 4e017b90795..921e4025b7c 100644 --- a/addons/purchase_requisition/__manifest__.py +++ b/addons/purchase_requisition/__manifest__.py @@ -22,6 +22,20 @@ are agreements you have with vendors to benefit from a predetermined pricing. 'views/purchase_requisition_views.xml', 'report/purchase_requisition_report.xml', 'report/report_purchaserequisition.xml', + 'wizard/purchase_requisition_alternative_warning.xml', + 'wizard/purchase_requisition_create_alternative.xml', ], + 'assets': { + 'web.assets_backend': [ + 'purchase_requisition/static/src/js/purchase_order_alternatives_widget.js', + 'purchase_requisition/static/src/js/purchase_order_compare_list_renderer.js', + 'purchase_requisition/static/src/js/purchase_order_line_compare_list_controller.js', + 'purchase_requisition/static/src/js/purchase_order_line_compare_list_renderer.js', + 'purchase_requisition/static/src/js/purchase_order_line_compare_list_view.js', + ], + 'web.assets_qweb': [ + 'purchase_requisition/static/src/xml/purchase_order_line_compare.xml', + ], + }, 'license': 'LGPL-3', } diff --git a/addons/purchase_requisition/data/purchase_requisition_demo.xml b/addons/purchase_requisition/data/purchase_requisition_demo.xml index 50098da1813..b999bd6c732 100644 --- a/addons/purchase_requisition/data/purchase_requisition_demo.xml +++ b/addons/purchase_requisition/data/purchase_requisition_demo.xml @@ -55,5 +55,40 @@ 60 10 + + + + + + + + + + + + + + 100 + 5 + + + + + + + + + + + + + 120 + 4 + + + + diff --git a/addons/purchase_requisition/models/purchase.py b/addons/purchase_requisition/models/purchase.py index 6637b816974..50c1072faf9 100644 --- a/addons/purchase_requisition/models/purchase.py +++ b/addons/purchase_requisition/models/purchase.py @@ -1,16 +1,46 @@ # -*- encoding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from collections import defaultdict + +from odoo import api, fields, models, _, Command from odoo.tools import get_lang +class PurchaseOrderGroup(models.Model): + _name = 'purchase.order.group' + _description = "Technical model to group PO for call to tenders" + + order_ids = fields.One2many('purchase.order', 'purchase_group_id') + + def write(self, vals): + res = super().write(vals) + # when len(POs) == 1, only linking PO to itself at this point => self implode (delete) group + self.filtered(lambda g: len(g.order_ids) <= 1).unlink() + return res + + class PurchaseOrder(models.Model): _inherit = 'purchase.order' requisition_id = fields.Many2one('purchase.requisition', string='Purchase Agreement', copy=False) is_quantity_copy = fields.Selection(related='requisition_id.is_quantity_copy', readonly=False) + purchase_group_id = fields.Many2one('purchase.order.group') + alternative_po_ids = fields.One2many( + 'purchase.order', related='purchase_group_id.order_ids', readonly=False, + domain="[('id', '!=', id), ('state', 'in', ['draft', 'sent', 'to approve'])]", + string="Alternative POs", check_company=True, + help="Other potential purchase orders for purchasing products") + has_alternatives = fields.Boolean( + "Has Alternatives", compute='_compute_has_alternatives', + help="Whether or not this purchase order is linked to another purchase order as an alternative.") + + @api.depends('purchase_group_id') + def _compute_has_alternatives(self): + self.has_alternatives = False + self.filtered(lambda po: po.purchase_group_id).has_alternatives = True + @api.onchange('requisition_id') def _onchange_requisition_id(self): if not self.requisition_id: @@ -78,6 +108,19 @@ class PurchaseOrder(models.Model): self.order_line = order_lines def button_confirm(self): + if self.alternative_po_ids and not self.env.context.get('skip_alternative_check', False): + alternative_po_ids = self.alternative_po_ids.filtered(lambda po: po.state in ['draft', 'sent', 'to approve'] and po.id not in self.ids) + if alternative_po_ids: + view = self.env.ref('purchase_requisition.purchase_requisition_alternative_warning_form') + return { + 'name': _('Alternative Warning'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'purchase.requisition.alternative.warning', + 'views': [(view.id, 'form')], + 'target': 'new', + 'context': dict(self.env.context, default_alternative_po_ids=alternative_po_ids.ids, default_po_ids=self.ids), + } res = super(PurchaseOrder, self).button_confirm() for po in self: if not po.requisition_id: @@ -92,6 +135,13 @@ class PurchaseOrder(models.Model): @api.model_create_multi def create(self, vals_list): orders = super().create(vals_list) + if self.env.context.get('origin_po_id'): + # po created as an alt to another PO: + origin_po_id = self.env['purchase.order'].browse(self.env.context.get('origin_po_id')) + if origin_po_id.purchase_group_id: + origin_po_id.purchase_group_id.order_ids |= orders + else: + self.env['purchase.order.group'].create({'order_ids': [Command.set(origin_po_id.ids + orders.ids)]}) mt_note = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') for order in orders: if order.requisition_id: @@ -102,13 +152,98 @@ class PurchaseOrder(models.Model): return orders def write(self, vals): + if vals.get('purchase_group_id', False): + # store in case linking to a PO with existing linkages + orig_purchase_group = self.purchase_group_id result = super(PurchaseOrder, self).write(vals) if vals.get('requisition_id'): self.message_post_with_view('mail.message_origin_link', values={'self': self, 'origin': self.requisition_id, 'edit': True}, subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')) + if vals.get('alternative_po_ids', False): + if not self.purchase_group_id and len(self.alternative_po_ids + self) > len(self): + # this can create a new group + delete an existing one (or more) when linking to already linked PO(s), but this is + # simplier than additional logic checking if exactly 1 exists or merging multiple groups if > 1 + self.env['purchase.order.group'].create({'order_ids': [Command.set(self.ids + self.alternative_po_ids.ids)]}) + elif self.purchase_group_id and len(self.alternative_po_ids + self) <= 1: + # write in purchase group isn't called so we have to manually unlink obsolete groups here + self.purchase_group_id.unlink() + if vals.get('purchase_group_id', False): + # the write is for multiple POs => don't double count the POs of the final group + additional_groups = orig_purchase_group - self.purchase_group_id + if additional_groups: + additional_pos = (additional_groups.order_ids - self.purchase_group_id.order_ids) + additional_groups.unlink() + if additional_pos: + self.purchase_group_id.order_ids |= additional_pos + return result + def action_create_alternative(self): + ctx = dict(**self.env.context, default_origin_po_id=self.id) + return { + 'name': _('Create alternative'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'purchase.requisition.create.alternative', + 'view_id': self.env.ref('purchase_requisition.purchase_requisition_create_alternative_form').id, + 'target': 'new', + 'context': ctx, + } + + def action_compare_alternative_lines(self): + best_price_ids, best_date_ids, best_price_unit_ids = self.get_tender_best_lines() + ctx = dict( + self.env.context, + search_default_groupby_product=True, + params={ + 'best_price_ids': best_price_ids, + 'best_date_ids': best_date_ids, + 'best_price_unit_ids': best_price_unit_ids, + }, + ) + view_id = self.env.ref('purchase_requisition.purchase_order_line_compare_tree').id + return { + 'name': _('Compare Order Lines'), + 'type': 'ir.actions.act_window', + 'view_mode': 'list', + 'res_model': 'purchase.order.line', + 'views': [(view_id, "list")], + 'domain': [('order_id', 'in', (self | self.alternative_po_ids).ids)], + 'context': ctx, + } + + def get_tender_best_lines(self): + product_to_best_price_line = defaultdict(lambda: self.env['purchase.order.line']) + product_to_best_date_line = defaultdict(lambda: self.env['purchase.order.line']) + product_to_best_price_unit = defaultdict(lambda: self.env['purchase.order.line']) + order_lines = self.order_line | self.alternative_po_ids.order_line + for line in order_lines: + if not line.product_qty or not line.price_subtotal or line.state in ['cancel', 'purchase', 'done']: + continue + if not product_to_best_price_line[line.product_id] or product_to_best_price_line[line.product_id][0].price_subtotal > line.price_subtotal: + product_to_best_price_line[line.product_id] = line + elif product_to_best_price_line[line.product_id][0].price_subtotal == line.price_subtotal: + product_to_best_price_line[line.product_id] |= line + if not product_to_best_date_line[line.product_id] or product_to_best_date_line[line.product_id][0].date_planned > line.date_planned: + product_to_best_date_line[line.product_id] = line + elif product_to_best_date_line[line.product_id][0].date_planned == line.date_planned: + product_to_best_date_line[line.product_id] |= line + if not product_to_best_price_unit[line.product_id] or product_to_best_price_unit[line.product_id][0].price_unit > line.price_unit: + product_to_best_price_unit[line.product_id] = line + elif product_to_best_price_unit[line.product_id][0].price_unit == line.price_unit: + product_to_best_price_unit[line.product_id] |= line + best_price_ids = set() + best_date_ids = set() + best_price_unit_ids = set() + for lines in product_to_best_price_line.values(): + best_price_ids.update(lines.ids) + for lines in product_to_best_date_line.values(): + best_date_ids.update(lines.ids) + for lines in product_to_best_price_unit.values(): + best_price_unit_ids.update(lines.ids) + return list(best_price_ids), list(best_date_ids), list(best_price_unit_ids) + class PurchaseOrderLine(models.Model): _inherit = 'purchase.order.line' @@ -130,3 +265,33 @@ class PurchaseOrderLine(models.Model): pol.name = name break super(PurchaseOrderLine, po_lines_without_requisition)._compute_price_unit_and_date_planned_and_name() + + def action_clear_quantities(self): + zeroed_lines = self.filtered(lambda l: l.state not in ['cancel', 'purchase', 'done']) + zeroed_lines.write({'product_qty': 0}) + if len(self) > len(zeroed_lines): + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Some not cleared"), + 'message': _("Some quantities were not cleared because their status is not a RFQ status."), + 'sticky': False, + } + } + return False + + def action_choose(self): + order_lines = (self.order_id | self.order_id.alternative_po_ids).mapped('order_line') + order_lines = order_lines.filtered(lambda l: l.product_qty and l.product_id.id in self.product_id.ids and l.id not in self.ids) + if order_lines: + return order_lines.action_clear_quantities() + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Nothing to clear"), + 'message': _("There are no quantites to clear."), + 'sticky': False, + } + } diff --git a/addons/purchase_requisition/security/ir.model.access.csv b/addons/purchase_requisition/security/ir.model.access.csv index 504a88faa04..42f43cad6f9 100644 --- a/addons/purchase_requisition/security/ir.model.access.csv +++ b/addons/purchase_requisition/security/ir.model.access.csv @@ -5,3 +5,6 @@ access_purchase_requisition,purchase.requisition,model_purchase_requisition,purc access_purchase_requisition_line_purchase_user,purchase.requisition.line,model_purchase_requisition_line,purchase.group_purchase_user,1,1,1,1 access_purchase_requisition_manager,purchase.requisition manager,model_purchase_requisition,purchase.group_purchase_manager,1,0,0,0 access_purchase_requisition_line_manager,purchase.requisition.line manager,model_purchase_requisition_line,purchase.group_purchase_manager,1,0,0,0 +access_purchase_requisition_alternative_warning, purchase.requisition.alternative.warning,model_purchase_requisition_alternative_warning,purchase.group_purchase_user,1,1,1,1 +access_purchase_requisition_create_alternative, purchase.requisition.create.alternative,model_purchase_requisition_create_alternative,purchase.group_purchase_user,1,1,1,1 +access_purchase_requisition_purchase_order_group, purchase.order.group,model_purchase_order_group,purchase.group_purchase_user,1,1,1,1 diff --git a/addons/purchase_requisition/static/src/js/purchase_order_alternatives_widget.js b/addons/purchase_requisition/static/src/js/purchase_order_alternatives_widget.js new file mode 100644 index 00000000000..33aceb34d1a --- /dev/null +++ b/addons/purchase_requisition/static/src/js/purchase_order_alternatives_widget.js @@ -0,0 +1,52 @@ +/** @odoo-module **/ + +import field_registry from 'web.field_registry'; +import { FieldMany2Many } from 'web.relational_fields'; +import PurchaseOrderCompareListRenderer from '@purchase_requisition/js/purchase_order_compare_list_renderer'; + +const FieldMany2ManySameWindow = FieldMany2Many.extend({ + + /** + * @override + */ + init: function (parent, name, record, options) { + this._super(...arguments); + this.isMany2Many = true; + }, + + /** + * Override to ensure we can't unlink a PO from itself (i.e. confusing behavior) + * @override + */ + _getRenderer: function () { + if (this.view.arch.tag === 'tree') { + return PurchaseOrderCompareListRenderer; + } + return this._super(...arguments); + }, + + /** + * Override to open record in same window w/breadcrumb extended + * @override + */ + _onOpenRecord: function (ev) { + ev.stopPropagation(); + const targetId = ev.target.state.data.find(obj => { + return obj.id === ev.data.id; + }).res_id; + // don't re-open current record + if (targetId !== this.record.res_id) { + this._rpc({ + model: this.model, + method: 'get_formview_action', + args: [[targetId]], + context: this.record.getContext(this.recordParams), + domain: this.record.getDomain(this.recordParams), + }).then(action => { + this.trigger_up('do_action', {action: action}); + }); + } + } +}); + +field_registry.add('many2many_same_window', FieldMany2ManySameWindow); diff --git a/addons/purchase_requisition/static/src/js/purchase_order_compare_list_renderer.js b/addons/purchase_requisition/static/src/js/purchase_order_compare_list_renderer.js new file mode 100644 index 00000000000..cb345c9b48e --- /dev/null +++ b/addons/purchase_requisition/static/src/js/purchase_order_compare_list_renderer.js @@ -0,0 +1,26 @@ +/** @odoo-module **/ + +import ListRenderer from 'web.ListRenderer'; + + +const PurchaseOrderCompareListRenderer = ListRenderer.extend({ + init: function (parent, state, params) { + this._super(...arguments); + this.active_id = parent.res_id; + }, + + /** + * @override + * @private + * @returns {Promise} + */ + _renderRow: function (record) { + const $tr = this._super(...arguments); + if (record.res_id === this.active_id && $tr.find('.o_list_record_remove').length) { + $tr.find(".o_list_record_remove").remove(); + } + return $tr; + } +}); + +export default PurchaseOrderCompareListRenderer; diff --git a/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_controller.js b/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_controller.js new file mode 100644 index 00000000000..c197299c271 --- /dev/null +++ b/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_controller.js @@ -0,0 +1,66 @@ +/** @odoo-module **/ + +import ListController from 'web.ListController'; + +const PurchaseOrderLineCompareListController = ListController.extend({ + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + init: function (parent, model, renderer, params) { + this.context = renderer.state.getContext(); + this.best_date_ids = this.context.params.best_date_ids; + this.best_price_ids = this.context.params.best_price_ids; + this.best_price_unit_ids = this.context.params.best_price_unit_ids; + return this._super(...arguments); + }, + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + /** + * @override + */ + _onButtonClicked: function (ev) { + if (ev.data.attrs.class && ev.data.attrs.class.split(' ').includes('o_clear_qty_buttons')) { + ev.stopPropagation(); + var self = this; + return this._callButtonAction(ev.data.attrs, ev.data.record).then(() => { + const context = this.model.localData[0] && this.model.localData[0].getContext() || {}; + return self._rpc({ + model: "purchase.order", + method: 'get_tender_best_lines', + args: [self.context.active_id], + context: context, + }).then((best_lines) => { + self.renderer.best_date_ids = best_lines[0] || []; + self.renderer.best_price_ids = best_lines[1] || []; + self.renderer.best_price_unit_ids = best_lines[2] || []; + self.reload(); + }); + }); + } else { + this._super.apply(this, arguments); + } + }, + + _onHeaderButtonClicked: async function (node) { + await this._super(...arguments); + if (node.attrs.name && node.attrs.name === 'action_clear_quantities') { + const context = this.model.localData[0] && this.model.localData[0].getContext() || {}; + const best_lines = await this._rpc({ + model: 'purchase.order', + method: 'get_tender_best_lines', + args: this.context.active_ids, + context, + }); + this.renderer.best_date_ids = best_lines[0]; + this.renderer.best_price_ids = best_lines[1]; + this.renderer.best_price_unit_ids = best_lines[2]; + this.reload(); + } + }, +}); + +export default PurchaseOrderLineCompareListController; diff --git a/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_renderer.js b/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_renderer.js new file mode 100644 index 00000000000..9bf90ea4dc3 --- /dev/null +++ b/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_renderer.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import ListRenderer from 'web.ListRenderer'; + + +const PurchaseOrderLineCompareListRenderer = ListRenderer.extend({ + + init: function (parent, state, params) { + this._super(...arguments); + this.best_date_ids = this.state.context.params.best_date_ids; + this.best_price_ids = this.state.context.params.best_price_ids; + this.best_price_unit_ids = this.state.context.params.best_price_unit_ids; + }, + + /** + * @override + * @private + * @returns {Promise} + */ + _renderRow: function (record) { + const $tr = this._super(...arguments); + if (this.best_date_ids.includes(record.res_id)) { + $tr.find('[name="date_planned"]').toggleClass('text-success'); + } + if (this.best_price_ids.includes(record.res_id)) { + $tr.find('[name="price_subtotal"]').toggleClass('text-success'); + } + if (this.best_price_unit_ids.includes(record.res_id)) { + $tr.find('[name="price_unit"]').toggleClass('text-success'); + } + return $tr; + } +}); + +export default PurchaseOrderLineCompareListRenderer; diff --git a/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_view.js b/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_view.js new file mode 100644 index 00000000000..96782055cde --- /dev/null +++ b/addons/purchase_requisition/static/src/js/purchase_order_line_compare_list_view.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +import ListView from 'web.ListView'; +import PurchaseOrderLineCompareListController from '@purchase_requisition/js/purchase_order_line_compare_list_controller'; +import PurchaseOrderLineCompareListRenderer from '@purchase_requisition/js/purchase_order_line_compare_list_renderer'; +import viewRegistry from 'web.view_registry'; + +const PurchaseOrderLineCompareListView = ListView.extend({ + config: Object.assign({}, ListView.prototype.config, { + Controller: PurchaseOrderLineCompareListController, + Renderer: PurchaseOrderLineCompareListRenderer, + }), +}); + +viewRegistry.add('purchase_order_line_compare', PurchaseOrderLineCompareListView); + +export default PurchaseOrderLineCompareListView; diff --git a/addons/purchase_requisition/static/src/xml/purchase_order_line_compare.xml b/addons/purchase_requisition/static/src/xml/purchase_order_line_compare.xml new file mode 100644 index 00000000000..b538760be28 --- /dev/null +++ b/addons/purchase_requisition/static/src/xml/purchase_order_line_compare.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/addons/purchase_requisition/tests/test_purchase_requisition.py b/addons/purchase_requisition/tests/test_purchase_requisition.py index 0e8d27e8b9a..8f13844fce9 100644 --- a/addons/purchase_requisition/tests/test_purchase_requisition.py +++ b/addons/purchase_requisition/tests/test_purchase_requisition.py @@ -2,6 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.purchase_requisition.tests.common import TestPurchaseRequisitionCommon +from odoo import Command +from odoo.tests import Form + +from datetime import timedelta class TestPurchaseRequisition(TestPurchaseRequisitionCommon): @@ -112,3 +116,102 @@ class TestPurchaseRequisition(TestPurchaseRequisitionCommon): ('partner_id', '=', vendor.id) ]) - supplier_info self.assertEqual(new_si.purchase_requisition_id, requisition_blanket, 'the blanket order is not linked to the supplier info') + + def test_07_alternative_purchases_wizards(self): + """Directly link POs to each other as 'Alternatives': check that wizards and + their flows correctly work.""" + orig_po = self.env['purchase.order'].create({ + 'partner_id': self.res_partner_1.id, + }) + unit_price = 50 + po_form = Form(orig_po) + with po_form.order_line.new() as line: + line.product_id = self.product_09 + line.product_qty = 5.0 + line.price_unit = unit_price + po_form.save() + + # first flow: check that creating an alt PO correctly auto-links both POs to each other + action = orig_po.action_create_alternative() + alt_po_wiz = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context'])) + alt_po_wiz.partner_id = self.res_partner_1 + alt_po_wiz.copy_products = True + alt_po_wiz = alt_po_wiz.save() + alt_po_wiz.action_create_alternative() + self.assertEqual(len(orig_po.alternative_po_ids), 2, "Original PO should be auto-linked to itself and newly created PO") + + # check alt po was created with correct values + alt_po_1 = orig_po.alternative_po_ids.filtered(lambda po: po.id != orig_po.id) + self.assertEqual(orig_po.order_line.product_id, alt_po_1.order_line.product_id, "Alternative PO should have copied the product to purchase from original PO") + self.assertEqual(orig_po.order_line.product_qty, alt_po_1.order_line.product_qty, "Alternative PO should have copied the qty to purchase from original PO") + self.assertEqual(len(alt_po_1.alternative_po_ids), 2, "Newly created PO should be auto-linked to itself and original PO") + + # check compare POLs correctly calcs best date/price PO lines: orig_po.date_planned = best & alt_po.price = best + alt_po_1.order_line.date_planned += timedelta(days=1) + alt_po_1.order_line.price_unit = unit_price - 10 + action = orig_po.action_compare_alternative_lines() + params = action['context']['params'] + best_price_pol = self.env['purchase.order.line'].browse(params['best_price_ids']) + best_date_pol = self.env['purchase.order.line'].browse(params['best_date_ids']) + self.assertEqual(best_price_pol.order_id.id, alt_po_1.id, "Best price PO line was not correctly calculated") + self.assertEqual(best_date_pol.order_id.id, orig_po.id, "Best date PO line was not correctly calculated") + + # second flow: create extra alt PO, check that all 3 POs are correctly auto-linked + action = orig_po.action_create_alternative() + alt_po_wiz = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context'])) + alt_po_wiz.partner_id = self.res_partner_1 + alt_po_wiz.copy_products = True + alt_po_wiz = alt_po_wiz.save() + alt_po_wiz.action_create_alternative() + self.assertEqual(len(orig_po.alternative_po_ids), 3, "Original PO should be auto-linked to newly created alternative PO") + self.assertEqual(len(alt_po_1.alternative_po_ids), 3, "Alternative PO should be auto-linked to newly created alternative PO") + alt_po_2 = orig_po.alternative_po_ids.filtered(lambda po: po.id not in [alt_po_1.id, orig_po.id]) + self.assertEqual(len(alt_po_2.alternative_po_ids), 3, "All alternative POs should be auto-linked to each other") + + # third flow: confirm one of the POs when alt POs are a mix of confirmed + RFQs + alt_po_2.write({'state': 'purchase'}) + action = orig_po.button_confirm() + warning_wiz = Form(self.env['purchase.requisition.alternative.warning'].with_context(**action['context'])) + warning_wiz = warning_wiz.save() + self.assertEqual(warning_wiz.alternative_po_count, 1, "POs not in a RFQ status should not be listed as possible to cancel") + warning_wiz.action_cancel_alternatives() + self.assertEqual(alt_po_1.state, 'cancel', "Alternative PO should have been cancelled") + self.assertEqual(orig_po.state, 'purchase', "Original PO should have been confirmed") + + def test_08_purchases_multi_linkages(self): + """Directly link POs to each other as 'Alternatives': check linking/unlinking + POs that are already linked correctly work.""" + pos = [] + for _ in range(5): + pos += self.env['purchase.order'].create({ + 'partner_id': self.res_partner_1.id, + }).ids + pos = self.env['purchase.order'].browse(pos) + po_1, po_2, po_3, po_4, po_5 = pos + + po_1.alternative_po_ids |= po_2 + po_3.alternative_po_ids |= po_4 + groups = self.env['purchase.order.group'].search([('order_ids', 'in', pos.ids)]) + self.assertEqual(len(po_1.alternative_po_ids), 2, "PO1 and PO2 should only be linked to each other") + self.assertEqual(len(po_3.alternative_po_ids), 2, "PO3 and PO4 should only be linked to each other") + self.assertEqual(len(groups), 2, "There should only be 2 groups: (PO1,PO2) and (PO3,PO4)") + + # link non-linked PO to already linked PO + po_5.alternative_po_ids |= po_4 + groups = self.env['purchase.order.group'].search([('order_ids', 'in', pos.ids)]) + self.assertEqual(len(po_3.alternative_po_ids), 3, "PO3 should now be linked to PO4 and PO5") + self.assertEqual(len(po_4.alternative_po_ids), 3, "PO4 should now be linked to PO3 and PO5") + self.assertEqual(len(po_5.alternative_po_ids), 3, "PO5 should now be linked to PO3 and PO4") + self.assertEqual(len(groups), 2, "There should only be 2 groups: (PO1,PO2) and (PO3,PO4,PO5)") + + # link already linked PO to already linked PO + po_5.alternative_po_ids |= po_1 + groups = self.env['purchase.order.group'].search([('order_ids', 'in', pos.ids)]) + self.assertEqual(len(po_1.alternative_po_ids), 5, "All 5 POs should be linked to each other now") + self.assertEqual(len(groups), 1, "There should only be 1 group containing all 5 POs (other group should have auto-deleted") + + # remove all links, make sure group auto-deletes + (pos - po_5).alternative_po_ids = [Command.clear()] + groups = self.env['purchase.order.group'].search([('order_ids', 'in', pos.ids)]) + self.assertEqual(len(po_5.alternative_po_ids), 0, "Last PO should auto unlink from itself since group should have auto-deleted") + self.assertEqual(len(groups), 0, "The group should have auto-deleted") diff --git a/addons/purchase_requisition/views/purchase_views.xml b/addons/purchase_requisition/views/purchase_views.xml index f4172aee4be..a8b67a4c63b 100644 --- a/addons/purchase_requisition/views/purchase_views.xml +++ b/addons/purchase_requisition/views/purchase_views.xml @@ -9,10 +9,39 @@ - - + + + + + + +

Create a call for tender by adding alternative request for quotations to different vendors. + Make your choice by selecting the best combination of lead time, OTD and/or total amount. + By comparing product lines you can also decide to order some products from one vendor and others from another vendor.

+
+ +

+