Начальное наполнение
This commit is contained in:
parent
1c931224c9
commit
3bb28b5f0b
4
__init__.py
Normal file
4
__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
37
__manifest__.py
Normal file
37
__manifest__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
{
|
||||||
|
'name': 'Purchase Agreements',
|
||||||
|
'version': '0.1',
|
||||||
|
'category': 'Inventory/Purchase',
|
||||||
|
'description': """
|
||||||
|
This module allows you to manage your Purchase Agreements.
|
||||||
|
===========================================================
|
||||||
|
|
||||||
|
Manage calls for tenders and blanket orders. Calls for tenders are used to get
|
||||||
|
competing offers from different vendors and select the best ones. Blanket orders
|
||||||
|
are agreements you have with vendors to benefit from a predetermined pricing.
|
||||||
|
""",
|
||||||
|
'depends': ['purchase'],
|
||||||
|
'demo': ['data/purchase_requisition_demo.xml'],
|
||||||
|
'data': [
|
||||||
|
'security/purchase_requisition_security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/purchase_requisition_data.xml',
|
||||||
|
'views/product_views.xml',
|
||||||
|
'views/purchase_views.xml',
|
||||||
|
'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_requisition/static/src/views/*/**.js',
|
||||||
|
'purchase_requisition/static/src/*/**.scss',
|
||||||
|
'purchase_requisition/static/src/*/**.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
17
data/purchase_requisition_data.xml
Normal file
17
data/purchase_requisition_data.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="type_single" model="purchase.requisition.type">
|
||||||
|
<field name="name">Blanket Order</field>
|
||||||
|
<field name="sequence">3</field>
|
||||||
|
<field name="quantity_copy">none</field>
|
||||||
|
</record>
|
||||||
|
<record id="seq_blanket_order" model="ir.sequence">
|
||||||
|
<field name="name">Blanket Order</field>
|
||||||
|
<field name="code">purchase.requisition.blanket.order</field>
|
||||||
|
<field name="prefix">BO</field>
|
||||||
|
<field name="padding">5</field>
|
||||||
|
<field name="company_id" eval="False"></field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
90
data/purchase_requisition_demo.xml
Normal file
90
data/purchase_requisition_demo.xml
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!--Blanket Order Agreement-->
|
||||||
|
<record id="bo_requisition" model="purchase.requisition">
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="vendor_id" ref="base.res_partner_1"/>
|
||||||
|
<field name="type_id" ref="type_single"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="bo_requisition_line" model="purchase.requisition.line">
|
||||||
|
<field name="requisition_id" ref="bo_requisition"/>
|
||||||
|
<field name="product_id" ref="product.product_product_13"/>
|
||||||
|
<field name="product_uom_id" ref="uom.product_uom_unit"/>
|
||||||
|
<field name="product_qty">100</field>
|
||||||
|
<field name="price_unit">60</field>
|
||||||
|
</record>
|
||||||
|
<function model="purchase.requisition" name="action_in_progress" eval="[[ref('bo_requisition')]]"/>
|
||||||
|
|
||||||
|
<!--Resource: purchase.order-->
|
||||||
|
<record id="rfq1" model="purchase.order">
|
||||||
|
<field name="partner_id" ref="base.res_partner_1"/>
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="requisition_id" ref="bo_requisition"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rfq1_line" model="purchase.order.line">
|
||||||
|
<field name="order_id" ref="rfq1"/>
|
||||||
|
<field name="name" model="purchase.order.line" eval="obj().env.ref('product.product_product_13').partner_ref"/>
|
||||||
|
<field name="date_planned" eval="time.strftime('%Y-%m-10')"/>
|
||||||
|
<field name="product_id" ref="product.product_product_13"/>
|
||||||
|
<field name="product_uom" ref="uom.product_uom_unit"/>
|
||||||
|
<field name="price_unit">60</field>
|
||||||
|
<field name="product_qty">25</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<function model="purchase.order" name="button_confirm" eval="[[ref('rfq1')]]"/>
|
||||||
|
|
||||||
|
<record id="rfq2" model="purchase.order">
|
||||||
|
<field name="partner_id" ref="base.res_partner_1"/>
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="requisition_id" ref="bo_requisition"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rfq2_line" model="purchase.order.line">
|
||||||
|
<field name="order_id" ref="rfq2"/>
|
||||||
|
<field name="name" model="purchase.order.line" eval="obj().env.ref('product.product_product_13').partner_ref"/>
|
||||||
|
<field name="date_planned" eval="time.strftime('%Y-%m-15')"/>
|
||||||
|
<field name="product_id" ref="product.product_product_13"/>
|
||||||
|
<field name="product_uom" ref="uom.product_uom_unit"/>
|
||||||
|
<field name="price_unit">60</field>
|
||||||
|
<field name="product_qty">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Call to Tender, i.e. linked RFQs -->
|
||||||
|
<!-- Note that forcecreate="0" is added to this demo data due to product type being updated to 'product'
|
||||||
|
by stock, but upgrade issue will occur since 'product' type won't exist yet in purchase when trying
|
||||||
|
to add the new PO lines (i.e. stock product type update won't exist yet, but it will be in the db) -->
|
||||||
|
<record id="rfq3" model="purchase.order" forcecreate="0">
|
||||||
|
<field name="partner_id" ref="base.res_partner_1"/>
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
</record>
|
||||||
|
<record id="rfq3_line" model="purchase.order.line" forcecreate="0">
|
||||||
|
<field name="order_id" ref="rfq3"/>
|
||||||
|
<field name="name" model="purchase.order.line" eval="obj().env.ref('product.product_product_13').partner_ref"/>
|
||||||
|
<field name="date_planned" eval="time.strftime('%Y-%m-10')"/>
|
||||||
|
<field name="product_id" ref="product.product_product_13"/>
|
||||||
|
<field name="product_uom" ref="uom.product_uom_unit"/>
|
||||||
|
<field name="price_unit">100</field>
|
||||||
|
<field name="product_qty">5</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rfq4" model="purchase.order" forcecreate="0">
|
||||||
|
<field name="partner_id" ref="base.res_partner_2"/>
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
</record>
|
||||||
|
<record id="rfq4_line" model="purchase.order.line" forcecreate="0">
|
||||||
|
<field name="order_id" ref="rfq4"/>
|
||||||
|
<field name="name" model="purchase.order.line" eval="obj().env.ref('product.product_product_13').partner_ref"/>
|
||||||
|
<field name="date_planned" eval="time.strftime('%Y-%m-15')"/>
|
||||||
|
<field name="product_id" ref="product.product_product_13"/>
|
||||||
|
<field name="product_uom" ref="uom.product_uom_unit"/>
|
||||||
|
<field name="price_unit">120</field>
|
||||||
|
<field name="product_qty">4</field>
|
||||||
|
</record>
|
||||||
|
<record id="rfq_group" model="purchase.order.group" forcecreate="0">
|
||||||
|
<field name="order_ids" eval="[Command.set([ref('purchase_requisition.rfq3'), ref('purchase_requisition.rfq4')])]"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
1134
i18n/af.po
Normal file
1134
i18n/af.po
Normal file
File diff suppressed because it is too large
Load Diff
1130
i18n/am.po
Normal file
1130
i18n/am.po
Normal file
File diff suppressed because it is too large
Load Diff
1273
i18n/ar.po
Normal file
1273
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
1139
i18n/az.po
Normal file
1139
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
1257
i18n/bg.po
Normal file
1257
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
1135
i18n/bs.po
Normal file
1135
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
1291
i18n/ca.po
Normal file
1291
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
1259
i18n/cs.po
Normal file
1259
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
1253
i18n/da.po
Normal file
1253
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
1288
i18n/de.po
Normal file
1288
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
1136
i18n/el.po
Normal file
1136
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
1137
i18n/en_GB.po
Normal file
1137
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
1288
i18n/es.po
Normal file
1288
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
1294
i18n/es_419.po
Normal file
1294
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/es_BO.po
Normal file
1132
i18n/es_BO.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/es_CL.po
Normal file
1132
i18n/es_CL.po
Normal file
File diff suppressed because it is too large
Load Diff
1134
i18n/es_CO.po
Normal file
1134
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/es_CR.po
Normal file
1132
i18n/es_CR.po
Normal file
File diff suppressed because it is too large
Load Diff
1135
i18n/es_DO.po
Normal file
1135
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
1134
i18n/es_EC.po
Normal file
1134
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
1134
i18n/es_PE.po
Normal file
1134
i18n/es_PE.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/es_PY.po
Normal file
1132
i18n/es_PY.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/es_VE.po
Normal file
1132
i18n/es_VE.po
Normal file
File diff suppressed because it is too large
Load Diff
1293
i18n/et.po
Normal file
1293
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/eu.po
Normal file
1132
i18n/eu.po
Normal file
File diff suppressed because it is too large
Load Diff
1254
i18n/fa.po
Normal file
1254
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
1292
i18n/fi.po
Normal file
1292
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/fo.po
Normal file
1132
i18n/fo.po
Normal file
File diff suppressed because it is too large
Load Diff
1290
i18n/fr.po
Normal file
1290
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/fr_BE.po
Normal file
1132
i18n/fr_BE.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/fr_CA.po
Normal file
1132
i18n/fr_CA.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/gl.po
Normal file
1132
i18n/gl.po
Normal file
File diff suppressed because it is too large
Load Diff
1138
i18n/gu.po
Normal file
1138
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
1259
i18n/he.po
Normal file
1259
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/hi.po
Normal file
1132
i18n/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
1149
i18n/hr.po
Normal file
1149
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
1256
i18n/hu.po
Normal file
1256
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
1279
i18n/id.po
Normal file
1279
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
1130
i18n/is.po
Normal file
1130
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
1289
i18n/it.po
Normal file
1289
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
1259
i18n/ja.po
Normal file
1259
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/ka.po
Normal file
1132
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/kab.po
Normal file
1132
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
1136
i18n/km.po
Normal file
1136
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
1263
i18n/ko.po
Normal file
1263
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
1130
i18n/lb.po
Normal file
1130
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/lo.po
Normal file
1132
i18n/lo.po
Normal file
File diff suppressed because it is too large
Load Diff
1262
i18n/lt.po
Normal file
1262
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
1254
i18n/lv.po
Normal file
1254
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
1137
i18n/mk.po
Normal file
1137
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
1152
i18n/mn.po
Normal file
1152
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
1143
i18n/nb.po
Normal file
1143
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
1286
i18n/nl.po
Normal file
1286
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
1278
i18n/pl.po
Normal file
1278
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
1246
i18n/pt.po
Normal file
1246
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
1286
i18n/pt_BR.po
Normal file
1286
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
1233
i18n/purchase_requisition.pot
Normal file
1233
i18n/purchase_requisition.pot
Normal file
File diff suppressed because it is too large
Load Diff
1145
i18n/ro.po
Normal file
1145
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
1293
i18n/ru.po
Normal file
1293
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
1245
i18n/sk.po
Normal file
1245
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
1253
i18n/sl.po
Normal file
1253
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
1132
i18n/sq.po
Normal file
1132
i18n/sq.po
Normal file
File diff suppressed because it is too large
Load Diff
1274
i18n/sr.po
Normal file
1274
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
1136
i18n/sr@latin.po
Normal file
1136
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
1260
i18n/sv.po
Normal file
1260
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
1271
i18n/th.po
Normal file
1271
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
1287
i18n/tr.po
Normal file
1287
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
1272
i18n/uk.po
Normal file
1272
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
1272
i18n/vi.po
Normal file
1272
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
1261
i18n/zh_CN.po
Normal file
1261
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
1260
i18n/zh_TW.po
Normal file
1260
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
5
models/__init__.py
Normal file
5
models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import purchase
|
||||||
|
from . import product
|
||||||
|
from . import purchase_requisition
|
22
models/product.py
Normal file
22
models/product.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierInfo(models.Model):
|
||||||
|
_inherit = 'product.supplierinfo'
|
||||||
|
|
||||||
|
purchase_requisition_id = fields.Many2one('purchase.requisition', related='purchase_requisition_line_id.requisition_id', string='Agreement')
|
||||||
|
purchase_requisition_line_id = fields.Many2one('purchase.requisition.line')
|
||||||
|
|
||||||
|
|
||||||
|
class ProductProduct(models.Model):
|
||||||
|
_inherit = 'product.product'
|
||||||
|
|
||||||
|
def _prepare_sellers(self, params=False):
|
||||||
|
sellers = super(ProductProduct, self)._prepare_sellers(params=params)
|
||||||
|
if params and params.get('order_id'):
|
||||||
|
return sellers.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id)
|
||||||
|
else:
|
||||||
|
return sellers
|
329
models/purchase.py
Normal file
329
models/purchase.py
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _, Command
|
||||||
|
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, 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='Blanket Order', 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:
|
||||||
|
return
|
||||||
|
|
||||||
|
self = self.with_company(self.company_id)
|
||||||
|
requisition = self.requisition_id
|
||||||
|
if self.partner_id:
|
||||||
|
partner = self.partner_id
|
||||||
|
else:
|
||||||
|
partner = requisition.vendor_id
|
||||||
|
payment_term = partner.property_supplier_payment_term_id
|
||||||
|
|
||||||
|
FiscalPosition = self.env['account.fiscal.position']
|
||||||
|
fpos = FiscalPosition.with_company(self.company_id)._get_fiscal_position(partner)
|
||||||
|
|
||||||
|
self.partner_id = partner.id
|
||||||
|
self.fiscal_position_id = fpos.id
|
||||||
|
self.payment_term_id = payment_term.id
|
||||||
|
self.company_id = requisition.company_id.id
|
||||||
|
self.currency_id = requisition.currency_id.id
|
||||||
|
if not self.origin or requisition.name not in self.origin.split(', '):
|
||||||
|
if self.origin:
|
||||||
|
if requisition.name:
|
||||||
|
self.origin = self.origin + ', ' + requisition.name
|
||||||
|
else:
|
||||||
|
self.origin = requisition.name
|
||||||
|
self.notes = requisition.description
|
||||||
|
self.date_order = fields.Datetime.now()
|
||||||
|
|
||||||
|
if requisition.type_id.line_copy != 'copy':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create PO lines if necessary
|
||||||
|
order_lines = []
|
||||||
|
for line in requisition.line_ids:
|
||||||
|
# Compute name
|
||||||
|
product_lang = line.product_id.with_context(
|
||||||
|
lang=partner.lang or self.env.user.lang,
|
||||||
|
partner_id=partner.id
|
||||||
|
)
|
||||||
|
name = product_lang.display_name
|
||||||
|
if product_lang.description_purchase:
|
||||||
|
name += '\n' + product_lang.description_purchase
|
||||||
|
|
||||||
|
# Compute taxes
|
||||||
|
taxes_ids = fpos.map_tax(line.product_id.supplier_taxes_id.filtered(lambda tax: tax.company_id == requisition.company_id)).ids
|
||||||
|
|
||||||
|
# Compute quantity and price_unit
|
||||||
|
if line.product_uom_id != line.product_id.uom_po_id:
|
||||||
|
product_qty = line.product_uom_id._compute_quantity(line.product_qty, line.product_id.uom_po_id)
|
||||||
|
price_unit = line.product_uom_id._compute_price(line.price_unit, line.product_id.uom_po_id)
|
||||||
|
else:
|
||||||
|
product_qty = line.product_qty
|
||||||
|
price_unit = line.price_unit
|
||||||
|
|
||||||
|
if requisition.type_id.quantity_copy != 'copy':
|
||||||
|
product_qty = 0
|
||||||
|
|
||||||
|
# Create PO line
|
||||||
|
order_line_values = line._prepare_purchase_order_line(
|
||||||
|
name=name, product_qty=product_qty, price_unit=price_unit,
|
||||||
|
taxes_ids=taxes_ids)
|
||||||
|
order_lines.append((0, 0, order_line_values))
|
||||||
|
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': _("What about the alternative Requests for Quotations?"),
|
||||||
|
'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:
|
||||||
|
continue
|
||||||
|
if po.requisition_id.type_id.exclusive == 'exclusive':
|
||||||
|
others_po = po.requisition_id.mapped('purchase_ids').filtered(lambda r: r.id != po.id)
|
||||||
|
others_po.button_cancel()
|
||||||
|
if po.state not in ['draft', 'sent', 'to approve']:
|
||||||
|
po.requisition_id.action_done()
|
||||||
|
return res
|
||||||
|
|
||||||
|
@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)]})
|
||||||
|
for order in orders:
|
||||||
|
if order.requisition_id:
|
||||||
|
order.message_post_with_source(
|
||||||
|
'mail.message_origin_link',
|
||||||
|
render_values={'self': order, 'origin': order.requisition_id},
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
|
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'):
|
||||||
|
for order in self:
|
||||||
|
order.message_post_with_source(
|
||||||
|
'mail.message_origin_link',
|
||||||
|
render_values={'self': order, 'origin': order.requisition_id, 'edit': True},
|
||||||
|
subtype_xmlid='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):
|
||||||
|
ctx = dict(
|
||||||
|
self.env.context,
|
||||||
|
search_default_groupby_product=True,
|
||||||
|
purchase_order_id=self.id,
|
||||||
|
)
|
||||||
|
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), ('display_type', '=', False)],
|
||||||
|
'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'])
|
||||||
|
po_alternatives = self | self.alternative_po_ids
|
||||||
|
|
||||||
|
multiple_currencies = False
|
||||||
|
if len(po_alternatives.currency_id) > 1:
|
||||||
|
multiple_currencies = True
|
||||||
|
|
||||||
|
for line in po_alternatives.order_line:
|
||||||
|
if not line.product_qty or not line.price_subtotal or line.state in ['cancel', 'purchase', 'done']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# if no best price line => no best price unit line either
|
||||||
|
if not product_to_best_price_line[line.product_id]:
|
||||||
|
product_to_best_price_line[line.product_id] = line
|
||||||
|
product_to_best_price_unit[line.product_id] = line
|
||||||
|
else:
|
||||||
|
price_subtotal = line.price_subtotal
|
||||||
|
price_unit = line.price_unit
|
||||||
|
current_price_subtotal = product_to_best_price_line[line.product_id][0].price_subtotal
|
||||||
|
current_price_unit = product_to_best_price_unit[line.product_id][0].price_unit
|
||||||
|
if multiple_currencies:
|
||||||
|
price_subtotal /= line.order_id.currency_rate
|
||||||
|
price_unit /= line.order_id.currency_rate
|
||||||
|
current_price_subtotal /= product_to_best_price_line[line.product_id][0].order_id.currency_rate
|
||||||
|
current_price_unit /= product_to_best_price_unit[line.product_id][0].order_id.currency_rate
|
||||||
|
|
||||||
|
if current_price_subtotal > price_subtotal:
|
||||||
|
product_to_best_price_line[line.product_id] = line
|
||||||
|
elif current_price_subtotal == price_subtotal:
|
||||||
|
product_to_best_price_line[line.product_id] |= line
|
||||||
|
if current_price_unit > price_unit:
|
||||||
|
product_to_best_price_unit[line.product_id] = line
|
||||||
|
elif current_price_unit == price_unit:
|
||||||
|
product_to_best_price_unit[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
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
def _compute_price_unit_and_date_planned_and_name(self):
|
||||||
|
po_lines_without_requisition = self.env['purchase.order.line']
|
||||||
|
for pol in self:
|
||||||
|
if pol.product_id.id not in pol.order_id.requisition_id.line_ids.product_id.ids:
|
||||||
|
po_lines_without_requisition |= pol
|
||||||
|
continue
|
||||||
|
for line in pol.order_id.requisition_id.line_ids:
|
||||||
|
if line.product_id == pol.product_id:
|
||||||
|
pol.price_unit = line.product_uom_id._compute_price(line.price_unit, pol.product_uom)
|
||||||
|
partner = pol.order_id.partner_id or pol.order_id.requisition_id.vendor_id
|
||||||
|
params = {'order_id': pol.order_id}
|
||||||
|
seller = pol.product_id._select_seller(
|
||||||
|
partner_id=partner,
|
||||||
|
quantity=pol.product_qty,
|
||||||
|
date=pol.order_id.date_order and pol.order_id.date_order.date(),
|
||||||
|
uom_id=line.product_uom_id,
|
||||||
|
params=params)
|
||||||
|
|
||||||
|
if not pol.date_planned:
|
||||||
|
pol.date_planned = pol._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||||||
|
|
||||||
|
product_ctx = {'seller_id': seller.id, 'lang': get_lang(pol.env, partner.lang).code}
|
||||||
|
name = pol._get_product_purchase_description(pol.product_id.with_context(product_ctx))
|
||||||
|
if line.product_description_variants:
|
||||||
|
name += '\n' + line.product_description_variants
|
||||||
|
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 quantities to clear."),
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
}
|
277
models/purchase_requisition.py
Normal file
277
models/purchase_requisition.py
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
PURCHASE_REQUISITION_STATES = [
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('ongoing', 'Ongoing'),
|
||||||
|
('in_progress', 'Confirmed'),
|
||||||
|
('open', 'Bid Selection'),
|
||||||
|
('done', 'Closed'),
|
||||||
|
('cancel', 'Cancelled')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequisitionType(models.Model):
|
||||||
|
_name = "purchase.requisition.type"
|
||||||
|
_description = "Purchase Requisition Type"
|
||||||
|
_order = "sequence"
|
||||||
|
|
||||||
|
name = fields.Char(string='Agreement Type', required=True, translate=True)
|
||||||
|
sequence = fields.Integer(default=1)
|
||||||
|
exclusive = fields.Selection([
|
||||||
|
('exclusive', 'Select only one RFQ (exclusive)'), ('multiple', 'Select multiple RFQ (non-exclusive)')],
|
||||||
|
string='Agreement Selection Type', required=True, default='multiple',
|
||||||
|
help="""Select only one RFQ (exclusive): when a purchase order is confirmed, cancel the remaining purchase order.\n
|
||||||
|
Select multiple RFQ (non-exclusive): allows multiple purchase orders. On confirmation of a purchase order it does not cancel the remaining orders""")
|
||||||
|
quantity_copy = fields.Selection([
|
||||||
|
('copy', 'Use quantities of agreement'), ('none', 'Set quantities manually')],
|
||||||
|
string='Quantities', required=True, default='none')
|
||||||
|
line_copy = fields.Selection([
|
||||||
|
('copy', 'Use lines of agreement'), ('none', 'Do not create RfQ lines automatically')],
|
||||||
|
string='Lines', required=True, default='copy')
|
||||||
|
active = fields.Boolean(default=True, help="Set active to false to hide the Purchase Agreement Types without removing it.")
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequisition(models.Model):
|
||||||
|
_name = "purchase.requisition"
|
||||||
|
_description = "Purchase Requisition"
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = "id desc"
|
||||||
|
|
||||||
|
def _get_type_id(self):
|
||||||
|
return self.env['purchase.requisition.type'].search([], limit=1)
|
||||||
|
|
||||||
|
name = fields.Char(string='Reference', required=True, copy=False, default='New', readonly=True)
|
||||||
|
origin = fields.Char(string='Source Document')
|
||||||
|
order_count = fields.Integer(compute='_compute_orders_number', string='Number of Orders')
|
||||||
|
vendor_id = fields.Many2one('res.partner', string="Vendor", domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
|
||||||
|
type_id = fields.Many2one('purchase.requisition.type', string="Agreement Type", required=True, default=_get_type_id)
|
||||||
|
ordering_date = fields.Date(string="Ordering Date", tracking=True)
|
||||||
|
date_end = fields.Datetime(string='Agreement Deadline', tracking=True)
|
||||||
|
schedule_date = fields.Date(string='Delivery Date', index=True, help="The expected and scheduled delivery date where all the products are received", tracking=True)
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users', string='Purchase Representative',
|
||||||
|
default=lambda self: self.env.user, check_company=True)
|
||||||
|
description = fields.Html()
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
|
||||||
|
purchase_ids = fields.One2many('purchase.order', 'requisition_id', string='Purchase Orders')
|
||||||
|
line_ids = fields.One2many('purchase.requisition.line', 'requisition_id', string='Products to Purchase', copy=True)
|
||||||
|
product_id = fields.Many2one('product.product', related='line_ids.product_id', string='Product')
|
||||||
|
state = fields.Selection(PURCHASE_REQUISITION_STATES,
|
||||||
|
'Status', tracking=True, required=True,
|
||||||
|
copy=False, default='draft')
|
||||||
|
state_blanket_order = fields.Selection(PURCHASE_REQUISITION_STATES, compute='_set_state')
|
||||||
|
is_quantity_copy = fields.Selection(related='type_id.quantity_copy', readonly=True)
|
||||||
|
currency_id = fields.Many2one('res.currency', 'Currency', required=True,
|
||||||
|
default=lambda self: self.env.company.currency_id.id)
|
||||||
|
|
||||||
|
@api.depends('state')
|
||||||
|
def _set_state(self):
|
||||||
|
for requisition in self:
|
||||||
|
requisition.state_blanket_order = requisition.state
|
||||||
|
|
||||||
|
@api.onchange('vendor_id')
|
||||||
|
def _onchange_vendor(self):
|
||||||
|
self = self.with_company(self.company_id)
|
||||||
|
if not self.vendor_id:
|
||||||
|
self.currency_id = self.env.company.currency_id.id
|
||||||
|
else:
|
||||||
|
self.currency_id = self.vendor_id.property_purchase_currency_id.id or self.env.company.currency_id.id
|
||||||
|
|
||||||
|
requisitions = self.env['purchase.requisition'].search([
|
||||||
|
('vendor_id', '=', self.vendor_id.id),
|
||||||
|
('state', '=', 'ongoing'),
|
||||||
|
('type_id.quantity_copy', '=', 'none'),
|
||||||
|
('company_id', '=', self.company_id.id),
|
||||||
|
])
|
||||||
|
if any(requisitions):
|
||||||
|
title = _("Warning for %s", self.vendor_id.name)
|
||||||
|
message = _("There is already an open blanket order for this supplier. We suggest you complete this open blanket order, instead of creating a new one.")
|
||||||
|
warning = {
|
||||||
|
'title': title,
|
||||||
|
'message': message
|
||||||
|
}
|
||||||
|
return {'warning': warning}
|
||||||
|
|
||||||
|
@api.depends('purchase_ids')
|
||||||
|
def _compute_orders_number(self):
|
||||||
|
for requisition in self:
|
||||||
|
requisition.order_count = len(requisition.purchase_ids)
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
# try to set all associated quotations to cancel state
|
||||||
|
for requisition in self:
|
||||||
|
for requisition_line in requisition.line_ids:
|
||||||
|
requisition_line.supplier_info_ids.sudo().unlink()
|
||||||
|
requisition.purchase_ids.button_cancel()
|
||||||
|
for po in requisition.purchase_ids:
|
||||||
|
po.message_post(body=_('Cancelled by the agreement associated to this quotation.'))
|
||||||
|
self.write({'state': 'cancel'})
|
||||||
|
|
||||||
|
def action_in_progress(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.line_ids:
|
||||||
|
raise UserError(_("You cannot confirm agreement '%s' because there is no product line.", self.name))
|
||||||
|
if self.type_id.quantity_copy == 'none' and self.vendor_id:
|
||||||
|
for requisition_line in self.line_ids:
|
||||||
|
if requisition_line.price_unit <= 0.0:
|
||||||
|
raise UserError(_('You cannot confirm the blanket order without price.'))
|
||||||
|
if requisition_line.product_qty <= 0.0:
|
||||||
|
raise UserError(_('You cannot confirm the blanket order without quantity.'))
|
||||||
|
requisition_line.create_supplier_info()
|
||||||
|
self.write({'state': 'ongoing'})
|
||||||
|
else:
|
||||||
|
self.write({'state': 'in_progress'})
|
||||||
|
# Set the sequence number regarding the requisition type
|
||||||
|
if self.name == 'New':
|
||||||
|
self.name = self.env['ir.sequence'].with_company(self.company_id).next_by_code('purchase.requisition.blanket.order')
|
||||||
|
|
||||||
|
def action_open(self):
|
||||||
|
self.write({'state': 'open'})
|
||||||
|
|
||||||
|
def action_draft(self):
|
||||||
|
self.ensure_one()
|
||||||
|
self.write({'state': 'draft'})
|
||||||
|
|
||||||
|
def action_done(self):
|
||||||
|
"""
|
||||||
|
Generate all purchase order based on selected lines, should only be called on one agreement at a time
|
||||||
|
"""
|
||||||
|
if any(purchase_order.state in ['draft', 'sent', 'to approve'] for purchase_order in self.mapped('purchase_ids')):
|
||||||
|
raise UserError(_("To close this purchase requisition, cancel related Requests for Quotation.\n\n"
|
||||||
|
"Imagine the mess if someone confirms these duplicates: double the order, double the trouble :)"))
|
||||||
|
for requisition in self:
|
||||||
|
for requisition_line in requisition.line_ids:
|
||||||
|
requisition_line.supplier_info_ids.sudo().unlink()
|
||||||
|
self.write({'state': 'done'})
|
||||||
|
|
||||||
|
@api.ondelete(at_uninstall=False)
|
||||||
|
def _unlink_if_draft_or_cancel(self):
|
||||||
|
if any(requisition.state not in ('draft', 'cancel') for requisition in self):
|
||||||
|
raise UserError(_('You can only delete draft or cancelled requisitions.'))
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
# Draft requisitions could have some requisition lines.
|
||||||
|
self.mapped('line_ids').unlink()
|
||||||
|
return super(PurchaseRequisition, self).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequisitionLine(models.Model):
|
||||||
|
_name = "purchase.requisition.line"
|
||||||
|
_inherit = 'analytic.mixin'
|
||||||
|
_description = "Purchase Requisition Line"
|
||||||
|
_rec_name = 'product_id'
|
||||||
|
|
||||||
|
product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], required=True)
|
||||||
|
product_uom_id = fields.Many2one(
|
||||||
|
'uom.uom', 'Product Unit of Measure',
|
||||||
|
compute='_compute_product_uom_id', store=True, readonly=False, precompute=True,
|
||||||
|
domain="[('category_id', '=', product_uom_category_id)]")
|
||||||
|
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
|
||||||
|
product_qty = fields.Float(string='Quantity', digits='Product Unit of Measure')
|
||||||
|
product_description_variants = fields.Char('Custom Description')
|
||||||
|
price_unit = fields.Float(string='Unit Price', digits='Product Price')
|
||||||
|
qty_ordered = fields.Float(compute='_compute_ordered_qty', string='Ordered Quantities')
|
||||||
|
requisition_id = fields.Many2one('purchase.requisition', required=True, string='Purchase Agreement', ondelete='cascade')
|
||||||
|
company_id = fields.Many2one('res.company', related='requisition_id.company_id', string='Company', store=True, readonly=True)
|
||||||
|
schedule_date = fields.Date(string='Scheduled Date')
|
||||||
|
supplier_info_ids = fields.One2many('product.supplierinfo', 'purchase_requisition_line_id')
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
lines = super().create(vals_list)
|
||||||
|
for line, vals in zip(lines, vals_list):
|
||||||
|
if line.requisition_id.state not in ['draft', 'cancel', 'done'] and line.requisition_id.is_quantity_copy == 'none':
|
||||||
|
supplier_infos = self.env['product.supplierinfo'].search([
|
||||||
|
('product_id', '=', vals.get('product_id')),
|
||||||
|
('partner_id', '=', line.requisition_id.vendor_id.id),
|
||||||
|
])
|
||||||
|
if not any(s.purchase_requisition_id for s in supplier_infos):
|
||||||
|
line.create_supplier_info()
|
||||||
|
if vals['price_unit'] <= 0.0:
|
||||||
|
raise UserError(_('You cannot confirm the blanket order without price.'))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super(PurchaseRequisitionLine, self).write(vals)
|
||||||
|
if 'price_unit' in vals:
|
||||||
|
if vals['price_unit'] <= 0.0 and any(
|
||||||
|
requisition.state not in ['draft', 'cancel', 'done'] and
|
||||||
|
requisition.is_quantity_copy == 'none' for requisition in self.mapped('requisition_id')):
|
||||||
|
raise UserError(_('You cannot confirm the blanket order without price.'))
|
||||||
|
# If the price is updated, we have to update the related SupplierInfo
|
||||||
|
self.supplier_info_ids.write({'price': vals['price_unit']})
|
||||||
|
return res
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
to_unlink = self.filtered(lambda r: r.requisition_id.state not in ['draft', 'cancel', 'done'])
|
||||||
|
to_unlink.mapped('supplier_info_ids').unlink()
|
||||||
|
return super(PurchaseRequisitionLine, self).unlink()
|
||||||
|
|
||||||
|
def create_supplier_info(self):
|
||||||
|
purchase_requisition = self.requisition_id
|
||||||
|
if purchase_requisition.type_id.quantity_copy == 'none' and purchase_requisition.vendor_id:
|
||||||
|
# create a supplier_info only in case of blanket order
|
||||||
|
self.env['product.supplierinfo'].sudo().create({
|
||||||
|
'partner_id': purchase_requisition.vendor_id.id,
|
||||||
|
'product_id': self.product_id.id,
|
||||||
|
'product_tmpl_id': self.product_id.product_tmpl_id.id,
|
||||||
|
'price': self.price_unit,
|
||||||
|
'currency_id': self.requisition_id.currency_id.id,
|
||||||
|
'purchase_requisition_line_id': self.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.depends('requisition_id.purchase_ids.state')
|
||||||
|
def _compute_ordered_qty(self):
|
||||||
|
line_found = set()
|
||||||
|
for line in self:
|
||||||
|
total = 0.0
|
||||||
|
for po in line.requisition_id.purchase_ids.filtered(lambda purchase_order: purchase_order.state in ['purchase', 'done']):
|
||||||
|
for po_line in po.order_line.filtered(lambda order_line: order_line.product_id == line.product_id):
|
||||||
|
if po_line.product_uom != line.product_uom_id:
|
||||||
|
total += po_line.product_uom._compute_quantity(po_line.product_qty, line.product_uom_id)
|
||||||
|
else:
|
||||||
|
total += po_line.product_qty
|
||||||
|
if line.product_id not in line_found:
|
||||||
|
line.qty_ordered = total
|
||||||
|
line_found.add(line.product_id)
|
||||||
|
else:
|
||||||
|
line.qty_ordered = 0
|
||||||
|
|
||||||
|
@api.depends('product_id')
|
||||||
|
def _compute_product_uom_id(self):
|
||||||
|
for line in self:
|
||||||
|
line.product_uom_id = line.product_id.uom_id
|
||||||
|
|
||||||
|
@api.onchange('product_id')
|
||||||
|
def _onchange_product_id(self):
|
||||||
|
if self.product_id:
|
||||||
|
self.product_uom_id = self.product_id.uom_po_id
|
||||||
|
self.product_qty = 1.0
|
||||||
|
if not self.schedule_date:
|
||||||
|
self.schedule_date = self.requisition_id.schedule_date
|
||||||
|
|
||||||
|
def _prepare_purchase_order_line(self, name, product_qty=0.0, price_unit=0.0, taxes_ids=False):
|
||||||
|
self.ensure_one()
|
||||||
|
requisition = self.requisition_id
|
||||||
|
if self.product_description_variants:
|
||||||
|
name += '\n' + self.product_description_variants
|
||||||
|
if requisition.schedule_date:
|
||||||
|
date_planned = datetime.combine(requisition.schedule_date, time.min)
|
||||||
|
else:
|
||||||
|
date_planned = datetime.now()
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'product_id': self.product_id.id,
|
||||||
|
'product_uom': self.product_id.uom_po_id.id,
|
||||||
|
'product_qty': product_qty,
|
||||||
|
'price_unit': price_unit,
|
||||||
|
'taxes_id': [(6, 0, taxes_ids)],
|
||||||
|
'date_planned': date_planned,
|
||||||
|
'analytic_distribution': self.analytic_distribution,
|
||||||
|
}
|
13
report/purchase_requisition_report.xml
Normal file
13
report/purchase_requisition_report.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_report_purchase_requisitions" model="ir.actions.report">
|
||||||
|
<field name="name">Purchase Agreements</field>
|
||||||
|
<field name="model">purchase.requisition</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">purchase_requisition.report_purchaserequisitions</field>
|
||||||
|
<field name="report_file">purchase_requisition.report.report_purchaserequisitions</field>
|
||||||
|
<field name="print_report_name">'Purchase Agreement - %s' % (object.name)</field>
|
||||||
|
<field name="binding_model_id" ref="model_purchase_requisition"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
120
report/report_purchaserequisition.xml
Normal file
120
report/report_purchaserequisition.xml
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<template id="report_purchaserequisition_document">
|
||||||
|
<t t-set="o" t-value="o.with_context(lang=o.vendor_id.lang)"/>
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<t t-set="address">
|
||||||
|
<span t-field="o.vendor_id"
|
||||||
|
t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True, "phone_icons": True}'>VAT123</span>
|
||||||
|
<p t-if="o.vendor_id.vat"><t t-out="o.company_id.account_fiscal_country_id.vat_label or 'Tax ID'"/>: <span t-field="o.vendor_id.vat">VAT_DEMO</span></p>
|
||||||
|
</t>
|
||||||
|
<div class="page">
|
||||||
|
<div class="oe_structure"/>
|
||||||
|
|
||||||
|
<h2><span t-out="o.type_id.name">VAT</span> <span t-field="o.name">Type Demo</span></h2>
|
||||||
|
|
||||||
|
<div class="row my-2">
|
||||||
|
<div class="col-3">
|
||||||
|
<strong><span t-out="o.type_id.name">Reference:</span></strong><br/>
|
||||||
|
<span t-field="o.name">Reference Demo</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<strong>Scheduled Ordering Date:</strong><br/>
|
||||||
|
<span t-field="o.ordering_date">2023-08-20</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<strong>Agreement Deadline:</strong><br/>
|
||||||
|
<span t-field="o.date_end">2023-09-15</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<strong>Source:</strong><br/>
|
||||||
|
<span t-field="o.origin">Origin Demo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="o.line_ids">
|
||||||
|
<h3>Products</h3>
|
||||||
|
<div class="oe_structure"></div>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><strong>Product</strong></th>
|
||||||
|
<th><strong>Description</strong></th>
|
||||||
|
<th class="text-end"><strong>Qty</strong></th>
|
||||||
|
<th class="text-center" groups="uom.group_uom">
|
||||||
|
<strong>Product UoM</strong>
|
||||||
|
</th>
|
||||||
|
<th t-if="o.type_id == env.ref('purchase_requisition.type_single')">Price Unit</th>
|
||||||
|
<th class="text-end"><strong>Scheduled Date</strong></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="o.line_ids" t-as="line_ids">
|
||||||
|
<td>
|
||||||
|
<span t-if="line_ids.product_id.code"><!--internal reference exists-->
|
||||||
|
[ <span t-field="line_ids.product_id.code">Code</span> ]
|
||||||
|
</span>
|
||||||
|
<span t-field="line_ids.product_id.name">Product</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span t-field="line_ids.product_description_variants">Description Demo</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="line_ids.product_qty">5</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center" groups="uom.group_uom">
|
||||||
|
<span t-field="line_ids.product_uom_id.name">Unit</span>
|
||||||
|
</td>
|
||||||
|
<td t-if="o.type_id == env.ref('purchase_requisition.type_single')">
|
||||||
|
<span t-field="line_ids.price_unit" t-options='{"widget": "monetary", "display_currency": line_ids.requisition_id.currency_id}'>Price</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="line_ids.schedule_date">2023-08-11</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="o.purchase_ids">
|
||||||
|
<h3>Requests for Quotation Details</h3>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><strong>Vendor </strong></th>
|
||||||
|
<th class="text-end"><strong>Date</strong></th>
|
||||||
|
<th class="text-end"><strong>Reference </strong></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="o.purchase_ids" t-as="purchase_ids">
|
||||||
|
<td>
|
||||||
|
<span t-field="purchase_ids.partner_id.name">Vendor Name</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="purchase_ids.date_order">2023-08-15</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="purchase_ids.name">Reference Demo</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div t-if="o.description" t-out="o.description"/>
|
||||||
|
|
||||||
|
<div class="oe_structure"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
<template id="report_purchaserequisitions">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="o">
|
||||||
|
<t t-call="purchase_requisition.report_purchaserequisition_document" t-lang="o.vendor_id.lang"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
10
security/ir.model.access.csv
Normal file
10
security/ir.model.access.csv
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_purchase_requisition_type,purchase.requisition.type,model_purchase_requisition_type,purchase.group_purchase_user,1,0,0,0
|
||||||
|
access_purchase_requisition_type_manager,purchase.requisition.type,model_purchase_requisition_type,purchase.group_purchase_manager,1,1,1,1
|
||||||
|
access_purchase_requisition,purchase.requisition,model_purchase_requisition,purchase.group_purchase_user,1,1,1,1
|
||||||
|
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
|
|
16
security/purchase_requisition_security.xml
Normal file
16
security/purchase_requisition_security.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record model="ir.rule" id="purchase_requisition_comp_rule">
|
||||||
|
<field name="name">Purchase Requisition multi-company</field>
|
||||||
|
<field name="model_id" ref="model_purchase_requisition"/>
|
||||||
|
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="purchase_requisition_line_comp_rule">
|
||||||
|
<field name="name">Purchase requisition Line multi-company</field>
|
||||||
|
<field name="model_id" ref="model_purchase_requisition_line"/>
|
||||||
|
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
@ -0,0 +1,51 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||||
|
import { onWillStart, useState, useSubEnv } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class PurchaseOrderLineCompareListRenderer extends ListRenderer {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.bestFields = useState({
|
||||||
|
best_price_ids: [],
|
||||||
|
best_date_ids: [],
|
||||||
|
best_price_unit_ids: [],
|
||||||
|
});
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.updateBestFields();
|
||||||
|
});
|
||||||
|
const defaultOnClickViewButton = this.env.onClickViewButton;
|
||||||
|
useSubEnv({
|
||||||
|
onClickViewButton: async (params) => {
|
||||||
|
await defaultOnClickViewButton(params);
|
||||||
|
await this.updateBestFields();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBestFields() {
|
||||||
|
[this.bestFields.best_price_ids,
|
||||||
|
this.bestFields.best_date_ids,
|
||||||
|
this.bestFields.best_price_unit_ids] = await this.props.list.model.orm.call(
|
||||||
|
"purchase.order",
|
||||||
|
"get_tender_best_lines",
|
||||||
|
[this.props.list.context.purchase_order_id || this.props.list.context.active_id],
|
||||||
|
{ context: this.props.list.context }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCellClass(column, record) {
|
||||||
|
let classNames = super.getCellClass(...arguments);
|
||||||
|
const customClassNames = [];
|
||||||
|
if (column.name === "price_subtotal" && this.bestFields.best_price_ids.includes(record.resId)) {
|
||||||
|
customClassNames.push("text-success");
|
||||||
|
}
|
||||||
|
if (column.name === "date_planned" && this.bestFields.best_date_ids.includes(record.resId)) {
|
||||||
|
customClassNames.push("text-success");
|
||||||
|
}
|
||||||
|
if (column.name === "price_unit" && this.bestFields.best_price_unit_ids.includes(record.resId)) {
|
||||||
|
customClassNames.push("text-success");
|
||||||
|
}
|
||||||
|
return classNames.concat(" ", customClassNames.join(" "));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { listView } from '@web/views/list/list_view';
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { PurchaseOrderLineCompareListRenderer } from "./purchase_order_line_compare_list_renderer";
|
||||||
|
|
||||||
|
|
||||||
|
export const PurchaseOrderLineCompareListView = {
|
||||||
|
...listView,
|
||||||
|
Renderer: PurchaseOrderLineCompareListRenderer,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("views").add("purchase_order_line_compare", PurchaseOrderLineCompareListView);
|
53
static/src/widgets/purchase_order_alternatives_widget.js
Normal file
53
static/src/widgets/purchase_order_alternatives_widget.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||||
|
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||||
|
|
||||||
|
|
||||||
|
export class FieldMany2ManyAltPOsRenderer extends ListRenderer {
|
||||||
|
isCurrentRecord(record) {
|
||||||
|
return record.resId === this.props.list.model.root.resId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldMany2ManyAltPOsRenderer.recordRowTemplate = "purchase_requisition.AltPOsListRenderer.RecordRow";
|
||||||
|
|
||||||
|
export class FieldMany2ManyAltPOs extends X2ManyField {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMany2Many() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override to: avoid reopening currently open record
|
||||||
|
* open record in same window w/breadcrumb extended
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
async openRecord(record) {
|
||||||
|
if (record.resId !== this.props.record.resId) {
|
||||||
|
const action = await this.orm.call(record.resModel, "get_formview_action", [[record.resId]], {
|
||||||
|
context: this.props.context,
|
||||||
|
});
|
||||||
|
await this.action.doAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldMany2ManyAltPOs.components = {
|
||||||
|
...X2ManyField.components,
|
||||||
|
ListRenderer: FieldMany2ManyAltPOsRenderer,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fieldMany2ManyAltPOs = {
|
||||||
|
...x2ManyField,
|
||||||
|
component: FieldMany2ManyAltPOs,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("fields").add("many2many_alt_pos", fieldMany2ManyAltPOs);
|
@ -0,0 +1,3 @@
|
|||||||
|
.o_field_many2many_alt_pos {
|
||||||
|
width: 100%;
|
||||||
|
}
|
11
static/src/widgets/purchase_order_alternatives_widget.xml
Normal file
11
static/src/widgets/purchase_order_alternatives_widget.xml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- Ensure we can't unlink a PO from itself (i.e. confusing behavior) -->
|
||||||
|
<t t-name="purchase_requisition.AltPOsListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//t[@t-if='displayOptionalFields or hasX2ManyAction']" position="attributes">
|
||||||
|
<attribute name="t-if">(displayOptionalFields or hasX2ManyAction) and !isCurrentRecord(record)</attribute>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
5
tests/__init__.py
Normal file
5
tests/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
from . import test_purchase_requisition
|
68
tests/common.py
Normal file
68
tests/common.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo.tests import common
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurchaseRequisitionCommon(common.TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super(TestPurchaseRequisitionCommon, cls).setUpClass()
|
||||||
|
|
||||||
|
# Fetch purchase related user groups
|
||||||
|
user_group_purchase_manager = cls.env.ref('purchase.group_purchase_manager')
|
||||||
|
user_group_purchase_user = cls.env.ref('purchase.group_purchase_user')
|
||||||
|
|
||||||
|
# User Data: purchase requisition Manager and User
|
||||||
|
Users = cls.env['res.users'].with_context({'tracking_disable': True})
|
||||||
|
|
||||||
|
cls.user_purchase_requisition_manager = Users.create({
|
||||||
|
'name': 'Purchase requisition Manager',
|
||||||
|
'login': 'prm',
|
||||||
|
'email': 'requisition_manager@yourcompany.com',
|
||||||
|
'notification_type': 'inbox',
|
||||||
|
'groups_id': [(6, 0, [user_group_purchase_manager.id])]})
|
||||||
|
|
||||||
|
cls.user_purchase_requisition_user = Users.create({
|
||||||
|
'name': 'Purchase requisition User',
|
||||||
|
'login': 'pru',
|
||||||
|
'email': 'requisition_user@yourcompany.com',
|
||||||
|
'notification_type': 'inbox',
|
||||||
|
'groups_id': [(6, 0, [user_group_purchase_user.id])]})
|
||||||
|
|
||||||
|
# Create Product
|
||||||
|
cls.product_uom_id = cls.env.ref('uom.product_uom_unit')
|
||||||
|
|
||||||
|
cls.product_09 = cls.env['product.product'].create({
|
||||||
|
'name': 'Pedal Bin',
|
||||||
|
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||||
|
'standard_price': 10.0,
|
||||||
|
'list_price': 47.0,
|
||||||
|
'type': 'consu',
|
||||||
|
'uom_id': cls.product_uom_id.id,
|
||||||
|
'uom_po_id': cls.product_uom_id.id,
|
||||||
|
'default_code': 'E-COM10',
|
||||||
|
})
|
||||||
|
|
||||||
|
cls.product_13 = cls.env['product.product'].create({
|
||||||
|
'name': 'Corner Desk Black',
|
||||||
|
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||||
|
'standard_price': 78.0,
|
||||||
|
'list_price': 85.0,
|
||||||
|
'type': 'consu',
|
||||||
|
'uom_id': cls.product_uom_id.id,
|
||||||
|
'uom_po_id': cls.product_uom_id.id,
|
||||||
|
'default_code': 'FURN_1118',
|
||||||
|
})
|
||||||
|
|
||||||
|
# In order to test process of the purchase requisition ,create requisition
|
||||||
|
cls.bo_requisition = cls.env['purchase.requisition'].create({
|
||||||
|
'line_ids': [(0, 0, {
|
||||||
|
'product_id': cls.product_09.id,
|
||||||
|
'product_qty': 10.0,
|
||||||
|
'product_uom_id': cls.product_uom_id.id})]
|
||||||
|
})
|
||||||
|
|
||||||
|
cls.res_partner_1 = cls.env['res.partner'].create({
|
||||||
|
'name': 'Wood Corner',
|
||||||
|
})
|
489
tests/test_purchase_requisition.py
Normal file
489
tests/test_purchase_requisition.py
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# 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, fields
|
||||||
|
from odoo.tests import Form
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo.tests.common import tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestPurchaseRequisition(TestPurchaseRequisitionCommon):
|
||||||
|
|
||||||
|
def test_00_purchase_requisition_users(self):
|
||||||
|
self.assertTrue(self.user_purchase_requisition_manager, 'Manager Should be created')
|
||||||
|
self.assertTrue(self.user_purchase_requisition_user, 'User Should be created')
|
||||||
|
|
||||||
|
def test_01_cancel_purchase_requisition(self):
|
||||||
|
self.bo_requisition.with_user(self.user_purchase_requisition_user).action_cancel()
|
||||||
|
# Check requisition after cancelled.
|
||||||
|
self.assertEqual(self.bo_requisition.state, 'cancel', 'Requisition should be in cancelled state.')
|
||||||
|
# I reset requisition as "New".
|
||||||
|
self.bo_requisition.with_user(self.user_purchase_requisition_user).action_draft()
|
||||||
|
# I duplicate requisition.
|
||||||
|
self.bo_requisition.with_user(self.user_purchase_requisition_user).copy()
|
||||||
|
|
||||||
|
def test_02_purchase_requisition(self):
|
||||||
|
price_product09 = 34
|
||||||
|
price_product13 = 62
|
||||||
|
quantity = 26
|
||||||
|
|
||||||
|
# Create a pruchase requisition with type blanket order and two product
|
||||||
|
line1 = (0, 0, {'product_id': self.product_09.id, 'product_qty': quantity, 'product_uom_id': self.product_uom_id.id, 'price_unit': price_product09})
|
||||||
|
line2 = (0, 0, {'product_id': self.product_13.id, 'product_qty': quantity, 'product_uom_id': self.product_uom_id.id, 'price_unit': price_product13})
|
||||||
|
|
||||||
|
requisition_type = self.env['purchase.requisition.type'].create({
|
||||||
|
'name': 'Blanket test',
|
||||||
|
'quantity_copy': 'none'
|
||||||
|
})
|
||||||
|
requisition_blanket = self.env['purchase.requisition'].create({
|
||||||
|
'line_ids': [line1, line2],
|
||||||
|
'type_id': requisition_type.id,
|
||||||
|
'vendor_id': self.res_partner_1.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# confirm the requisition
|
||||||
|
requisition_blanket.action_in_progress()
|
||||||
|
|
||||||
|
# Check for both product that the new supplier info(purchase.requisition.vendor_id) is added to the purchase tab
|
||||||
|
# and check the quantity
|
||||||
|
seller_partner1 = self.res_partner_1
|
||||||
|
supplierinfo09 = self.env['product.supplierinfo'].search([
|
||||||
|
('partner_id', '=', seller_partner1.id),
|
||||||
|
('product_id', '=', self.product_09.id),
|
||||||
|
('purchase_requisition_id', '=', requisition_blanket.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(supplierinfo09.partner_id, seller_partner1, 'The supplierinfo is not correct')
|
||||||
|
self.assertEqual(supplierinfo09.price, price_product09, 'The supplierinfo is not correct')
|
||||||
|
|
||||||
|
supplierinfo13 = self.env['product.supplierinfo'].search([
|
||||||
|
('partner_id', '=', seller_partner1.id),
|
||||||
|
('product_id', '=', self.product_13.id),
|
||||||
|
('purchase_requisition_id', '=', requisition_blanket.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(supplierinfo13.partner_id, seller_partner1, 'The supplierinfo is not correct')
|
||||||
|
self.assertEqual(supplierinfo13.price, price_product13, 'The supplierinfo is not correct')
|
||||||
|
|
||||||
|
# Put the requisition in done Status
|
||||||
|
requisition_blanket.action_in_progress()
|
||||||
|
requisition_blanket.action_done()
|
||||||
|
|
||||||
|
self.assertFalse(self.env['product.supplierinfo'].search([('id', '=', supplierinfo09.id)]), 'The supplier info should be removed')
|
||||||
|
self.assertFalse(self.env['product.supplierinfo'].search([('id', '=', supplierinfo13.id)]), 'The supplier info should be removed')
|
||||||
|
|
||||||
|
def test_03_blanket_order_rfq(self):
|
||||||
|
""" Create a blanket order + an RFQ for it """
|
||||||
|
requisition_type = self.env['purchase.requisition.type'].create({
|
||||||
|
'name': 'Blanket test',
|
||||||
|
'quantity_copy': 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
bo_form = Form(self.env['purchase.requisition'])
|
||||||
|
bo_form.vendor_id = self.res_partner_1
|
||||||
|
bo_form.type_id = requisition_type
|
||||||
|
with bo_form.line_ids.new() as line:
|
||||||
|
line.product_id = self.product_09
|
||||||
|
line.product_qty = 5.0
|
||||||
|
line.price_unit = 21
|
||||||
|
bo = bo_form.save()
|
||||||
|
bo.action_in_progress()
|
||||||
|
|
||||||
|
# lazy reproduction of clicking on "New Quotation" act_window button
|
||||||
|
po_form = Form(self.env['purchase.order'].with_context({"default_requisition_id": bo.id, "default_user_id": False}))
|
||||||
|
po = po_form.save()
|
||||||
|
|
||||||
|
self.assertEqual(po.order_line.price_unit, bo.line_ids.price_unit, 'The blanket order unit price should have been copied to purchase order')
|
||||||
|
self.assertEqual(po.partner_id, bo.vendor_id, 'The blanket order vendor should have been copied to purchase order')
|
||||||
|
|
||||||
|
po_form = Form(po)
|
||||||
|
po_form.order_line.remove(0)
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.product_id = self.product_09
|
||||||
|
line.product_qty = 5.0
|
||||||
|
|
||||||
|
po = po_form.save()
|
||||||
|
po.button_confirm()
|
||||||
|
self.assertEqual(po.order_line.price_unit, bo.line_ids.price_unit, 'The blanket order unit price should still be copied to purchase order')
|
||||||
|
self.assertEqual(po.state, "purchase")
|
||||||
|
|
||||||
|
def test_06_purchase_requisition(self):
|
||||||
|
""" Create a blanket order for a product and a vendor already linked via
|
||||||
|
a supplier info"""
|
||||||
|
product = self.env['product.product'].create({
|
||||||
|
'name': 'test6',
|
||||||
|
})
|
||||||
|
product2 = self.env['product.product'].create({
|
||||||
|
'name': 'test6',
|
||||||
|
})
|
||||||
|
vendor = self.env['res.partner'].create({
|
||||||
|
'name': 'vendor6',
|
||||||
|
})
|
||||||
|
supplier_info = self.env['product.supplierinfo'].create({
|
||||||
|
'product_id': product.id,
|
||||||
|
'partner_id': vendor.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# create an empty blanket order
|
||||||
|
requisition_type = self.env['purchase.requisition.type'].create({
|
||||||
|
'name': 'Blanket test',
|
||||||
|
'quantity_copy': 'none'
|
||||||
|
})
|
||||||
|
line1 = (0, 0, {
|
||||||
|
'product_id': product2.id,
|
||||||
|
'product_uom_id': product2.uom_po_id.id,
|
||||||
|
'price_unit': 41,
|
||||||
|
'product_qty': 10,
|
||||||
|
})
|
||||||
|
requisition_blanket = self.env['purchase.requisition'].create({
|
||||||
|
'line_ids': [line1],
|
||||||
|
'type_id': requisition_type.id,
|
||||||
|
'vendor_id': vendor.id,
|
||||||
|
})
|
||||||
|
requisition_blanket.action_in_progress()
|
||||||
|
self.env['purchase.requisition.line'].create({
|
||||||
|
'product_id': product.id,
|
||||||
|
'product_qty': 14.0,
|
||||||
|
'requisition_id': requisition_blanket.id,
|
||||||
|
'price_unit': 10,
|
||||||
|
})
|
||||||
|
new_si = self.env['product.supplierinfo'].search([
|
||||||
|
('product_id', '=', product.id),
|
||||||
|
('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
|
||||||
|
line.product_uom = self.env.ref('uom.product_uom_dozen')
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.display_type = "line_section"
|
||||||
|
line.name = "Products"
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.display_type = 'line_note'
|
||||||
|
line.name = 'note1'
|
||||||
|
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(len(alt_po_1.order_line), 3)
|
||||||
|
self.assertEqual(orig_po.order_line[0].product_id, alt_po_1.order_line[0].product_id, "Alternative PO should have copied the product to purchase from original PO")
|
||||||
|
self.assertEqual(orig_po.order_line[0].product_qty, alt_po_1.order_line[0].product_qty, "Alternative PO should have copied the qty to purchase from original PO")
|
||||||
|
self.assertEqual(orig_po.order_line[0].product_uom, alt_po_1.order_line[0].product_uom, "Alternative PO should have copied the product unit of measure from original PO")
|
||||||
|
self.assertEqual((orig_po.order_line[1].display_type, orig_po.order_line[1].name), (alt_po_1.order_line[1].display_type, alt_po_1.order_line[1].name))
|
||||||
|
self.assertEqual((orig_po.order_line[2].display_type, orig_po.order_line[2].name), (alt_po_1.order_line[2].display_type, alt_po_1.order_line[2].name))
|
||||||
|
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[0].date_planned += timedelta(days=1)
|
||||||
|
alt_po_1.order_line[0].price_unit = unit_price - 10
|
||||||
|
action = orig_po.action_compare_alternative_lines()
|
||||||
|
best_price_ids, best_date_ids, best_price_unit_ids = orig_po.get_tender_best_lines()
|
||||||
|
best_price_pol = self.env['purchase.order.line'].browse(best_price_ids)
|
||||||
|
best_date_pol = self.env['purchase.order.line'].browse(best_date_ids)
|
||||||
|
best_unit_price_pol = self.env['purchase.order.line'].browse(best_price_unit_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")
|
||||||
|
self.assertEqual(best_unit_price_pol.order_id.id, alt_po_1.id, "Best unit price 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(len(warning_wiz.alternative_po_ids), 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")
|
||||||
|
|
||||||
|
def test_09_alternative_po_line_price_unit(self):
|
||||||
|
"""Checks PO line's `price_unit` is keep even if a line from an
|
||||||
|
alternative is chosen and thus the PO line's quantity was set to 0. """
|
||||||
|
# Creates a first Purchase Order.
|
||||||
|
po_form = Form(self.env['purchase.order'])
|
||||||
|
po_form.partner_id = self.res_partner_1
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.product_id = self.product_09
|
||||||
|
line.product_qty = 1
|
||||||
|
line.price_unit = 16
|
||||||
|
po_1 = po_form.save()
|
||||||
|
|
||||||
|
# Creates an alternative PO.
|
||||||
|
action = po_1.action_create_alternative()
|
||||||
|
alt_po_wizard_form = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context']))
|
||||||
|
alt_po_wizard_form.partner_id = self.res_partner_1
|
||||||
|
alt_po_wizard_form.copy_products = True
|
||||||
|
alt_po_wizard = alt_po_wizard_form.save()
|
||||||
|
alt_po_wizard.action_create_alternative()
|
||||||
|
|
||||||
|
# Set a lower price on the alternative and choses this PO line.
|
||||||
|
po_2 = po_1.alternative_po_ids - po_1
|
||||||
|
po_2.order_line.price_unit = 12
|
||||||
|
po_2.order_line.action_choose()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
po_1.order_line.product_uom_qty, 0,
|
||||||
|
"Line's quantity from the original PO should be reset to 0")
|
||||||
|
self.assertEqual(
|
||||||
|
po_1.order_line.price_unit, 16,
|
||||||
|
"Line's unit price from the original PO shouldn't be changed")
|
||||||
|
|
||||||
|
def test_10_alternative_po_line_price_unit_different_uom(self):
|
||||||
|
""" Check that the uom is copied in the alternative PO, and the "unit_price"
|
||||||
|
is calculated according to this uom and not that of the product """
|
||||||
|
# Creates a first Purchase Order.
|
||||||
|
po_form = Form(self.env['purchase.order'])
|
||||||
|
self.product_09.standard_price = 10
|
||||||
|
po_form.partner_id = self.res_partner_1
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.product_id = self.product_09
|
||||||
|
line.product_qty = 1
|
||||||
|
line.product_uom = self.env.ref('uom.product_uom_dozen')
|
||||||
|
po_1 = po_form.save()
|
||||||
|
self.assertEqual(po_1.order_line[0].price_unit, 120)
|
||||||
|
|
||||||
|
# Creates an alternative PO.
|
||||||
|
action = po_1.action_create_alternative()
|
||||||
|
alt_po_wizard_form = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context']))
|
||||||
|
alt_po_wizard_form.partner_id = self.res_partner_1
|
||||||
|
alt_po_wizard_form.copy_products = True
|
||||||
|
alt_po_wizard = alt_po_wizard_form.save()
|
||||||
|
alt_po_wizard.action_create_alternative()
|
||||||
|
|
||||||
|
po_2 = po_1.alternative_po_ids - po_1
|
||||||
|
self.assertEqual(po_2.order_line[0].product_uom, po_1.order_line[0].product_uom)
|
||||||
|
self.assertEqual(po_2.order_line[0].price_unit, 120)
|
||||||
|
|
||||||
|
def test_11_alternative_po_from_po_with_requisition_id(self):
|
||||||
|
"""Create a purchase order from a blanket order, then check that the alternative purchase order
|
||||||
|
can be created and that the requisition_id is not set on it.
|
||||||
|
"""
|
||||||
|
# create an empty blanket order
|
||||||
|
requisition_type = self.env['purchase.requisition.type'].create({
|
||||||
|
'name': 'Blanket test',
|
||||||
|
'quantity_copy': 'none'
|
||||||
|
})
|
||||||
|
line1 = (0, 0, {
|
||||||
|
'product_id': self.product_13.id,
|
||||||
|
'product_uom_id': self.product_13.uom_po_id.id,
|
||||||
|
'price_unit': 41,
|
||||||
|
'product_qty': 10,
|
||||||
|
})
|
||||||
|
requisition_blanket = self.env['purchase.requisition'].create({
|
||||||
|
'line_ids': [line1],
|
||||||
|
'type_id': requisition_type.id,
|
||||||
|
'vendor_id': self.res_partner_1.id,
|
||||||
|
})
|
||||||
|
requisition_blanket.action_in_progress()
|
||||||
|
# lazy reproduction of clicking on "New Quotation" act_window button
|
||||||
|
po_form = Form(self.env['purchase.order'].with_context({"default_requisition_id": requisition_blanket.id, "default_user_id": False}))
|
||||||
|
po_1 = po_form.save()
|
||||||
|
po_1.button_confirm()
|
||||||
|
self.assertTrue(po_1.requisition_id, "The requisition_id should be set in the purchase order")
|
||||||
|
|
||||||
|
# Creates an alternative PO.
|
||||||
|
action = po_1.action_create_alternative()
|
||||||
|
alt_po_wizard_form = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context']))
|
||||||
|
alt_po_wizard_form.partner_id = self.res_partner_1
|
||||||
|
alt_po_wizard_form.copy_products = True
|
||||||
|
alt_po_wizard = alt_po_wizard_form.save()
|
||||||
|
alt_po_wizard.action_create_alternative()
|
||||||
|
|
||||||
|
po_2 = po_1.alternative_po_ids - po_1
|
||||||
|
self.assertFalse(po_2.requisition_id, "The requisition_id should not be set in the alternative purchase order")
|
||||||
|
|
||||||
|
def test_12_alternative_po_line_different_currency(self):
|
||||||
|
""" Check alternative PO with different currency is compared correctly"""
|
||||||
|
currency_eur = self.env.ref("base.EUR")
|
||||||
|
currency_usd = self.env.ref("base.USD")
|
||||||
|
(currency_usd | currency_eur).active = True
|
||||||
|
|
||||||
|
self.env.ref('base.main_company').currency_id = currency_usd
|
||||||
|
|
||||||
|
# 1 USD = 0.5 EUR
|
||||||
|
self.env['res.currency.rate'].create([{
|
||||||
|
'name': fields.Datetime.today(),
|
||||||
|
'currency_id': self.env.ref('base.USD').id,
|
||||||
|
'rate': 1,
|
||||||
|
}, {
|
||||||
|
'name': fields.Datetime.today(),
|
||||||
|
'currency_id': self.env.ref('base.EUR').id,
|
||||||
|
'rate': 0.5,
|
||||||
|
}])
|
||||||
|
vendor_usd = self.env["res.partner"].create({
|
||||||
|
"name": "Supplier A",
|
||||||
|
})
|
||||||
|
vendor_eur = self.env["res.partner"].create({
|
||||||
|
"name": "Supplier B",
|
||||||
|
})
|
||||||
|
|
||||||
|
product = self.env['product.product'].create({
|
||||||
|
'name': 'Product',
|
||||||
|
'seller_ids': [(0, 0, {
|
||||||
|
'partner_id': vendor_usd.id,
|
||||||
|
'price': 100,
|
||||||
|
'currency_id': currency_usd.id,
|
||||||
|
}), (0, 0, {
|
||||||
|
'partner_id': vendor_eur.id,
|
||||||
|
'price': 80,
|
||||||
|
'currency_id': currency_eur.id,
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
po_form = Form(self.env['purchase.order'])
|
||||||
|
po_form.partner_id = vendor_eur
|
||||||
|
po_form.currency_id = currency_eur
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.product_id = product
|
||||||
|
line.product_qty = 1
|
||||||
|
po_orig = po_form.save()
|
||||||
|
self.assertEqual(po_orig.order_line.price_unit, 80)
|
||||||
|
self.assertEqual(po_orig.currency_id, currency_eur)
|
||||||
|
|
||||||
|
# Creates an alternative PO
|
||||||
|
action = po_orig.action_create_alternative()
|
||||||
|
alt_po_wizard_form = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context']))
|
||||||
|
alt_po_wizard_form.partner_id = vendor_usd
|
||||||
|
alt_po_wizard_form.copy_products = True
|
||||||
|
alt_po_wizard = alt_po_wizard_form.save()
|
||||||
|
alt_po_wizard.action_create_alternative()
|
||||||
|
|
||||||
|
po_alt = po_orig.alternative_po_ids - po_orig
|
||||||
|
# Ensure that the currency in the alternative purchase order is set to USD
|
||||||
|
# because, in some case, the company's default currency is EUR.
|
||||||
|
self.assertEqual(po_alt.currency_id, currency_usd)
|
||||||
|
self.assertEqual(po_alt.order_line.price_unit, 100)
|
||||||
|
|
||||||
|
# po_alt has cheaper price_unit/price_subtotal after conversion USD -> EUR
|
||||||
|
# 80 / 0.5 = 160 USD > 100 EUR
|
||||||
|
best_price_ids, best_date_ids, best_price_unit_ids = po_orig.get_tender_best_lines()
|
||||||
|
self.assertEqual(len(best_price_ids), 1)
|
||||||
|
# Equal dates
|
||||||
|
self.assertEqual(len(best_date_ids), 2)
|
||||||
|
self.assertEqual(len(best_price_unit_ids), 1)
|
||||||
|
# alt_po is cheaper than orig_po
|
||||||
|
self.assertEqual(best_price_ids[0], po_alt.order_line.id)
|
||||||
|
self.assertEqual(best_price_unit_ids[0], po_alt.order_line.id)
|
||||||
|
|
||||||
|
def test_alternative_po_with_multiple_price_list(self):
|
||||||
|
vendor_a = self.env["res.partner"].create({
|
||||||
|
"name": "Supplier A",
|
||||||
|
})
|
||||||
|
vendor_b = self.env["res.partner"].create({
|
||||||
|
"name": "Supplier B",
|
||||||
|
})
|
||||||
|
product = self.env['product.product'].create({
|
||||||
|
'name': 'Product',
|
||||||
|
'seller_ids': [(0, 0, {
|
||||||
|
'partner_id': vendor_a.id,
|
||||||
|
'price': 5,
|
||||||
|
}), (0, 0, {
|
||||||
|
'partner_id': vendor_b.id,
|
||||||
|
'price': 4,
|
||||||
|
'min_qty': 10,
|
||||||
|
}), (0, 0, {
|
||||||
|
'partner_id': vendor_b.id,
|
||||||
|
'price': 6,
|
||||||
|
'min_qty': 1,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
po_form = Form(self.env['purchase.order'])
|
||||||
|
po_form.partner_id = vendor_a
|
||||||
|
with po_form.order_line.new() as line:
|
||||||
|
line.product_id = product
|
||||||
|
line.product_qty = 100
|
||||||
|
po_orig = po_form.save()
|
||||||
|
self.assertEqual(po_orig.order_line.price_unit, 5)
|
||||||
|
# Creates an alternative PO
|
||||||
|
action = po_orig.action_create_alternative()
|
||||||
|
alt_po_wizard_form = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context']))
|
||||||
|
alt_po_wizard_form.partner_id = vendor_b
|
||||||
|
alt_po_wizard_form.copy_products = True
|
||||||
|
alt_po_wizard = alt_po_wizard_form.save()
|
||||||
|
alt_po_wizard.action_create_alternative()
|
||||||
|
po_alt = po_orig.alternative_po_ids - po_orig
|
||||||
|
self.assertEqual(po_alt.order_line.price_unit, 4)
|
||||||
|
|
||||||
|
def test_08_purchase_requisition_sequence(self):
|
||||||
|
new_company = self.env['res.company'].create({'name': 'Company 2'})
|
||||||
|
self.env['ir.sequence'].create({
|
||||||
|
'code': 'purchase.requisition.blanket.order',
|
||||||
|
'prefix': 'REQ_',
|
||||||
|
'name': 'Blanket Order sequence',
|
||||||
|
'company_id': new_company.id,
|
||||||
|
})
|
||||||
|
self.bo_requisition.company_id = new_company
|
||||||
|
self.bo_requisition.action_in_progress()
|
||||||
|
self.assertTrue(self.bo_requisition.name.startswith("REQ_"))
|
27
views/product_views.xml
Normal file
27
views/product_views.xml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="product_supplierinfo_tree_view_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">product.template.product.form.inherit</field>
|
||||||
|
<field name="model">product.supplierinfo</field>
|
||||||
|
<field name="inherit_id" ref="product.product_supplierinfo_tree_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='product_id']" position="after">
|
||||||
|
<field name="purchase_requisition_id" optional="hide"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="supplier_info_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">product.supplierinfo.requisition.view</field>
|
||||||
|
<field name="model">product.supplierinfo</field>
|
||||||
|
<field name="priority">20</field>
|
||||||
|
<field name="inherit_id" ref="product.product_supplierinfo_form_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="product_code" position="after">
|
||||||
|
<field name="purchase_requisition_id"
|
||||||
|
invisible="not purchase_requisition_id"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
312
views/purchase_requisition_views.xml
Normal file
312
views/purchase_requisition_views.xml
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
|
||||||
|
<!-- Purchase Requisition Type -->
|
||||||
|
|
||||||
|
<record model="ir.ui.view" id="view_purchase_requisition_type_tree">
|
||||||
|
<field name="name">purchase.requisition.type.tree</field>
|
||||||
|
<field name="model">purchase.requisition.type</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Purchase Agreement Types">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="exclusive"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_purchase_requisition_type_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.requisition.type.kanban</field>
|
||||||
|
<field name="model">purchase.requisition.type</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban class="o_kanban_mobile">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="exclusive"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="kanban-box">
|
||||||
|
<div t-attf-class="oe_kanban_global_click">
|
||||||
|
<div class="o_kanban_record_top ">
|
||||||
|
<div class="o_kanban_record_headings mt4">
|
||||||
|
<strong class="o_kanban_record_title"><field name="name"/></strong>
|
||||||
|
</div>
|
||||||
|
<field name="exclusive" widget="label_selection"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record model="ir.ui.view" id="view_purchase_requisition_type_form">
|
||||||
|
<field name="name">purchase.requisition.type.form</field>
|
||||||
|
<field name="model">purchase.requisition.type</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Purchase Agreement Types">
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||||
|
<group>
|
||||||
|
<group string="Agreement Type">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="exclusive" widget="radio"/>
|
||||||
|
<field name="active" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Data for new quotations">
|
||||||
|
<field name="line_copy" widget="radio"/>
|
||||||
|
<field name="quantity_copy" widget="radio"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record model="ir.ui.view" id="view_purchase_requisition_type_search">
|
||||||
|
<field name="name">purchase.requisition.type.search</field>
|
||||||
|
<field name="model">purchase.requisition.type</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Purchase Orders -->
|
||||||
|
|
||||||
|
<record model="ir.actions.act_window" id="action_purchase_requisition_to_so">
|
||||||
|
<field name="name">Request for Quotation</field>
|
||||||
|
<field name="res_model">purchase.order</field>
|
||||||
|
<field name="view_mode">form,tree</field>
|
||||||
|
<field name="domain">[('requisition_id','=',active_id)]</field>
|
||||||
|
<field name="context">{
|
||||||
|
"default_requisition_id":active_id,
|
||||||
|
}
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record model="ir.actions.act_window" id="action_purchase_requisition_list">
|
||||||
|
<field name="name">Request for Quotations</field>
|
||||||
|
<field name="res_model">purchase.order</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="domain">[('requisition_id','=',active_id)]</field>
|
||||||
|
<field name="context">{
|
||||||
|
"default_requisition_id":active_id,
|
||||||
|
}
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.ui.view" id="view_purchase_requisition_form">
|
||||||
|
<field name="name">purchase.requisition.form</field>
|
||||||
|
<field name="model">purchase.requisition</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Purchase Agreements">
|
||||||
|
<field name="company_id" invisible="1"/>
|
||||||
|
<field name="currency_id" invisible="1"/>
|
||||||
|
<header>
|
||||||
|
<button name="%(action_purchase_requisition_to_so)d" type="action"
|
||||||
|
string="New Quotation"
|
||||||
|
context="{'default_currency_id': currency_id, 'default_user_id': user_id}"
|
||||||
|
invisible="state != 'open'"/>
|
||||||
|
<button name="%(action_purchase_requisition_to_so)d" type="action"
|
||||||
|
string="New Quotation" class="btn-primary"
|
||||||
|
context="{'default_currency_id': currency_id, 'default_user_id': user_id}"
|
||||||
|
invisible="state not in ('in_progress', 'ongoing')"/>
|
||||||
|
<button name="action_in_progress" invisible="state != 'draft'" string="Confirm" type="object" class="btn-primary"/>
|
||||||
|
<button name="action_open" invisible="state != 'in_progress'" string="Validate" type="object" class="btn-primary"/>
|
||||||
|
<button name="action_done" invisible="state not in ('open', 'ongoing')" string="Close" type="object" class="btn-primary"/>
|
||||||
|
<button name="action_draft" invisible="state != 'cancel'" string="Reset to Draft" type="object"/>
|
||||||
|
<button name="action_cancel" invisible="state not in ('draft', 'in_progress', 'ongoing')" string="Cancel" type="object"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="draft,in_progress,open,done" invisible="is_quantity_copy == 'none'"/>
|
||||||
|
<field name="state_blanket_order" widget="statusbar" statusbar_visible="draft,ongoing,done" invisible="is_quantity_copy != 'none'"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="%(action_purchase_requisition_list)d" type="action" class="oe_stat_button" icon="fa-list-alt"
|
||||||
|
invisible="state == 'draft'" context="{'default_currency_id': currency_id}">
|
||||||
|
<field name="order_count" widget="statinfo" string="RFQs/Orders"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name" class="oe_inline"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="is_quantity_copy" invisible='1'/>
|
||||||
|
<field name="user_id" readonly="state not in ('draft', 'in_progress', 'open')" domain="[('share', '=', False)]"/>
|
||||||
|
<field name="type_id" readonly="state != 'draft'"/>
|
||||||
|
<field name="vendor_id" context="{'res_partner_search_mode': 'supplier'}" readonly="state in ['ongoing', 'done']" required="is_quantity_copy == 'none'"/>
|
||||||
|
<field name="currency_id" groups="base.group_multi_currency"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="date_end" readonly="state not in ('draft', 'in_progress', 'open', 'ongoing')"/>
|
||||||
|
<field name="ordering_date" readonly="state not in ('draft', 'in_progress', 'open', 'ongoing')"/>
|
||||||
|
<field name="schedule_date" readonly="state not in ('draft', 'in_progress', 'open', 'ongoing')"/>
|
||||||
|
<field name="origin" placeholder="e.g. PO0025" readonly="state != 'draft'"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}" readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Products" name="products">
|
||||||
|
<field name="line_ids" readonly="state == 'done'">
|
||||||
|
<tree string="Products" editable="bottom">
|
||||||
|
<field name="product_id"
|
||||||
|
domain="[('purchase_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
|
||||||
|
<field name="product_description_variants" invisible="product_description_variants == ''"/>
|
||||||
|
<field name="product_qty"/>
|
||||||
|
<field name="qty_ordered" optional="show"/>
|
||||||
|
<field name="product_uom_category_id" column_invisible="True"/>
|
||||||
|
<field name="product_uom_id" string="UoM" groups="uom.group_uom" optional="show" required="product_id"/>
|
||||||
|
<field name="schedule_date" optional="hide"/>
|
||||||
|
<field name="analytic_distribution" widget="analytic_distribution"
|
||||||
|
optional="hide"
|
||||||
|
groups="analytic.group_analytic_accounting"
|
||||||
|
options="{'product_field': 'product_id', 'business_domain': 'purchase_order'}"/>
|
||||||
|
<field name="price_unit"/>
|
||||||
|
</tree>
|
||||||
|
<form string="Products">
|
||||||
|
<group>
|
||||||
|
<field name="product_id"
|
||||||
|
domain="[('purchase_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]" />
|
||||||
|
<field name="product_qty"/>
|
||||||
|
<field name="qty_ordered"/>
|
||||||
|
<field name="product_uom_category_id" invisible="1"/>
|
||||||
|
<field name="product_uom_id" groups="uom.group_uom"/>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
<field name="analytic_distribution" widget="analytic_distribution"
|
||||||
|
groups="analytic.group_analytic_accounting"
|
||||||
|
options="{'product_field': 'product_id', 'business_domain': 'purchase_order'}"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
|
||||||
|
</group>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
<separator string="Terms and Conditions"/>
|
||||||
|
<field name="description" class="oe-bordered-editor" readonly="state not in ('draft', 'in_progress', 'open')"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<div class="oe_chatter">
|
||||||
|
<field name="message_follower_ids"/>
|
||||||
|
<field name="activity_ids"/>
|
||||||
|
<field name="message_ids"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record model="ir.ui.view" id="view_purchase_requisition_tree">
|
||||||
|
<field name="name">purchase.requisition.tree</field>
|
||||||
|
<field name="model">purchase.requisition</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Purchase Agreements" sample="1">
|
||||||
|
<field name="message_needaction" column_invisible="True"/>
|
||||||
|
<field name="name" decoration-bf="1"/>
|
||||||
|
<field name="vendor_id"/>
|
||||||
|
<field name="user_id" optional="show" widget='many2one_avatar_user'/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}" optional="show"/>
|
||||||
|
<field name="ordering_date" optional="show"/>
|
||||||
|
<field name="schedule_date" optional="hide"/>
|
||||||
|
<field name="date_end" optional="show" widget='remaining_days' decoration-danger="date_end and date_end<current_date" invisible="state in ('done', 'cancel')"/>
|
||||||
|
<field name="origin" optional="show"/>
|
||||||
|
<field name="state" optional="show" widget='badge' decoration-success="state == 'done'" decoration-info="state not in ('done', 'cancel')"/>
|
||||||
|
<field name="activity_exception_decoration" widget="activity_exception"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_purchase_requisition_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.requisition.kanban</field>
|
||||||
|
<field name="model">purchase.requisition</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban class="o_kanban_mobile" sample="1">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="type_id"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="kanban-box">
|
||||||
|
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="o_kanban_record_top">
|
||||||
|
<div class="o_kanban_record_headings mt4">
|
||||||
|
<strong class="o_kanban_record_title"><span><field name="name"/></span></strong>
|
||||||
|
</div>
|
||||||
|
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}" readonly="1"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_body">
|
||||||
|
<span class="text-muted"><field name="type_id"/></span>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom">
|
||||||
|
<div class="oe_kanban_bottom_left">
|
||||||
|
<field name="vendor_id"/>
|
||||||
|
</div>
|
||||||
|
<div class="oe_kanban_bottom_right">
|
||||||
|
<field name="user_id" widget="many2one_avatar_user"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_purchase_requisition_filter" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.requisition.list.select</field>
|
||||||
|
<field name="model">purchase.requisition</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Purchase Agreements">
|
||||||
|
<field name="vendor_id"/>
|
||||||
|
<field name="name" string="Reference" filter_domain="['|', ('name', 'ilike', self), ('origin', 'ilike', self)]"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<filter string="My Agreements" name="my_agreements" domain="[('user_id', '=', uid)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]" help="New Agreements"/>
|
||||||
|
<filter string="Confirmed" name="confirmed" domain="[('state', 'in', ('in_progress', 'open'))]" help="In negotiation"/>
|
||||||
|
<filter string="Done" name="done" domain="[('state', '=', 'done')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter invisible="1" string="Late Activities" name="activities_overdue"
|
||||||
|
domain="[('my_activity_date_deadline', '<', context_today().strftime('%Y-%m-%d'))]"
|
||||||
|
help="Show all records which has next action date is before today"/>
|
||||||
|
<filter invisible="1" string="Today Activities" name="activities_today"
|
||||||
|
domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
|
||||||
|
<filter invisible="1" string="Future Activities" name="activities_upcoming_all"
|
||||||
|
domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="Purchase Representative" name="representative" domain="[]" context="{'group_by': 'user_id'}"/>
|
||||||
|
<filter string="Status" name="status" domain="[]" context="{'group_by': 'state'}"/>
|
||||||
|
<filter string="Ordering Date" name="ordering_date" domain="[]" context="{'group_by': 'ordering_date'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
|
||||||
|
<record model="ir.actions.act_window" id="action_purchase_requisition">
|
||||||
|
<field name="name">Blanket Orders</field>
|
||||||
|
<field name="res_model">purchase.requisition</field>
|
||||||
|
<field name="view_mode">tree,kanban,form</field>
|
||||||
|
<field name="context">{}</field>
|
||||||
|
<field name="search_view_id" ref="view_purchase_requisition_filter"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Start a new purchase agreement
|
||||||
|
</p><p>
|
||||||
|
An example of a purchase agreement is a blanket order.
|
||||||
|
</p><p>
|
||||||
|
For a blanket order, you can record an agreement for a specific period
|
||||||
|
(e.g. a year) and you order products within this agreement to benefit
|
||||||
|
from the negotiated prices.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem
|
||||||
|
id="menu_purchase_requisition_pro_mgt"
|
||||||
|
sequence="10"
|
||||||
|
parent="purchase.menu_procurement_management"
|
||||||
|
action="action_purchase_requisition"/>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
122
views/purchase_views.xml
Normal file
122
views/purchase_views.xml
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="purchase_order_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.order.form.inherit</field>
|
||||||
|
<field name="model">purchase.order</field>
|
||||||
|
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="partner_id" position="replace">
|
||||||
|
<field name="is_quantity_copy" invisible="1"/>
|
||||||
|
<field name="partner_id" widget="res_partner_many2one" context="{'res_partner_search_mode': 'supplier', 'show_vat': True}" readonly="is_quantity_copy == 'none' or state in ['purchase', 'done', 'cancel']" placeholder="Name, TIN, Email, or Reference" force_save="1"/>
|
||||||
|
</field>
|
||||||
|
<field name="partner_ref" position="after">
|
||||||
|
<field name="requisition_id" domain="[('state', 'in', ('in_progress', 'open', 'ongoing')), ('vendor_id', 'in', (partner_id, False)), ('company_id', '=', company_id)]"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
</field>
|
||||||
|
<xpath expr="//page[@name='purchase_delivery_invoice']" position="after">
|
||||||
|
<page string="Alternatives" name="alternative_pos">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<p colspan="2">Create a call for tender by adding alternative requests for quotation 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.</p>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<p colspan="2">
|
||||||
|
<button name="action_create_alternative" type="object" class="btn-link d-block" string="Create Alternative" icon="fa-copy"/>
|
||||||
|
<button name="action_compare_alternative_lines" type="object" class="btn-link d-block" string="Compare Product Lines" icon="fa-bar-chart" invisible="not alternative_po_ids"/>
|
||||||
|
</p>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<field name="alternative_po_ids" readonly="not id" widget="many2many_alt_pos" context="{'quotation_only': True}">
|
||||||
|
<tree string="Alternative Purchase Order" decoration-muted="state in ['cancel', 'purchase', 'done']" decoration-bf="id == parent.id">
|
||||||
|
<control>
|
||||||
|
<create string="Link to Existing RfQ"/>
|
||||||
|
</control>
|
||||||
|
<field name="currency_id" column_invisible="1"/>
|
||||||
|
<field name="partner_id" readonly="state in ['cancel', 'done', 'purchase']"/>
|
||||||
|
<field name="name" string="Reference"/>
|
||||||
|
<field name="date_planned"/>
|
||||||
|
<field name="amount_total"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="purchase_order_search_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.order.list.select.inherit</field>
|
||||||
|
<field name="model">purchase.order</field>
|
||||||
|
<field name="inherit_id" ref="purchase.view_purchase_order_filter"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//filter[@name='approved']" position="after">
|
||||||
|
<filter string="Requisition" name="requisition" domain="[('requisition_id', '!=', False)]" help="Purchase Orders with requisition"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="purchase_order_kpis_tree_inherit_purchase_requisition" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.order.kpis.tree.inherit.purchase.requisition</field>
|
||||||
|
<field name="model">purchase.order</field>
|
||||||
|
<field name="inherit_id" ref="purchase.purchase_order_kpis_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="name" position="before">
|
||||||
|
<field name="has_alternatives" column_invisible="True"/>
|
||||||
|
<button class="fa fa-copy"
|
||||||
|
title="Has Alternatives"
|
||||||
|
disabled="1"
|
||||||
|
invisible="not has_alternatives"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="purchase_order_tree_inherit_purchase_requisition" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.order.tree.inherit.purchase.requisition</field>
|
||||||
|
<field name="model">purchase.order</field>
|
||||||
|
<field name="inherit_id" ref="purchase.purchase_order_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="name" position="before">
|
||||||
|
<field name="has_alternatives" column_invisible="True"/>
|
||||||
|
<button class="fa fa-copy"
|
||||||
|
title="Has Alternatives"
|
||||||
|
disabled="1"
|
||||||
|
invisible="not has_alternatives"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="purchase_order_line_compare_tree" model="ir.ui.view">
|
||||||
|
<field name="name">purchase.order.line.compare.tree</field>
|
||||||
|
<field name="model">purchase.order.line</field>
|
||||||
|
<field name="priority">1000</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Purchase Order Lines"
|
||||||
|
decoration-muted="state in ['cancel', 'purchase', 'done']"
|
||||||
|
create="0" delete="0" edit="0" expand="1"
|
||||||
|
js_class="purchase_order_line_compare">
|
||||||
|
<header>
|
||||||
|
<button name="action_clear_quantities" string="Clear Selected" type="object" class="o_clear_qty_buttons"/>
|
||||||
|
</header>
|
||||||
|
<field name="product_id" readonly="1"/>
|
||||||
|
<field name="partner_id" string="Vendor"/>
|
||||||
|
<field name="order_id" string="Reference" readonly="1"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="name" readonly="1"/>
|
||||||
|
<field name="date_planned" readonly="1"/>
|
||||||
|
<field name="product_qty"/>
|
||||||
|
<field name="product_uom" groups="uom.group_uom"/>
|
||||||
|
<field name="price_unit"/>
|
||||||
|
<field name="price_subtotal" string="Total"/>
|
||||||
|
<field name="currency_id"/>
|
||||||
|
<button name="action_choose" string="Choose" type="object" class="o_clear_qty_buttons" icon="fa-bullseye"
|
||||||
|
invisible="product_qty <= 0.0"/>
|
||||||
|
<button name="action_clear_quantities" string="Clear" type="object" class="o_clear_qty_buttons" icon="fa-times"
|
||||||
|
invisible="product_qty <= 0.0 or state in ['cancel', 'purchase', 'done']"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
2
wizard/__init__.py
Normal file
2
wizard/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import purchase_requisition_alternative_warning
|
||||||
|
from . import purchase_requisition_create_alternative
|
23
wizard/purchase_requisition_alternative_warning.py
Normal file
23
wizard/purchase_requisition_alternative_warning.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequisitionAlternativeWarning(models.TransientModel):
|
||||||
|
_name = 'purchase.requisition.alternative.warning'
|
||||||
|
_description = 'Wizard in case PO still has open alternative requests for quotation'
|
||||||
|
|
||||||
|
po_ids = fields.Many2many('purchase.order', 'warning_purchase_order_rel', string="POs to Confirm")
|
||||||
|
alternative_po_ids = fields.Many2many('purchase.order', 'warning_purchase_order_alternative_rel', string="Alternative POs")
|
||||||
|
|
||||||
|
def action_keep_alternatives(self):
|
||||||
|
return self._action_done()
|
||||||
|
|
||||||
|
def action_cancel_alternatives(self):
|
||||||
|
# in theory alternative_po_ids shouldn't have any po_ids in it, but it's possible by accident/forcing it, so avoid cancelling them to be safe
|
||||||
|
self.alternative_po_ids.filtered(lambda po: po.state in ['draft', 'sent', 'to approve'] and po.id not in self.po_ids.ids).button_cancel()
|
||||||
|
return self._action_done()
|
||||||
|
|
||||||
|
def _action_done(self):
|
||||||
|
return self.po_ids.with_context({'skip_alternative_check': True}).button_confirm()
|
28
wizard/purchase_requisition_alternative_warning.xml
Normal file
28
wizard/purchase_requisition_alternative_warning.xml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="purchase_requisition_alternative_warning_form" model="ir.ui.view">
|
||||||
|
<field name="name">Alternative Warning</field>
|
||||||
|
<field name="model">purchase.requisition.alternative.warning</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Alternative Warning">
|
||||||
|
<field name="alternative_po_ids" nolabel="1" readonly="1">
|
||||||
|
<tree create="0" delete="0" edit="0">
|
||||||
|
<field name="currency_id" column_invisible="1"/>
|
||||||
|
<field name="partner_id" readonly="state in ['cancel', 'done', 'purchase']"/>
|
||||||
|
<field name="name" string="Reference"/>
|
||||||
|
<field name="date_planned"/>
|
||||||
|
<field name="amount_total"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
<footer>
|
||||||
|
<button name="action_cancel_alternatives" string="Cancel Alternatives" data-hotkey="q" type="object" colspan="1" class="btn-primary"/>
|
||||||
|
<button name="action_keep_alternatives" string="Keep Alternatives" data-hotkey="w" type="object" colspan="1" class="btn-primary"/>
|
||||||
|
<button string="Discard" data-hotkey="x" special="cancel" colspan="1" class="btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
93
wizard/purchase_requisition_create_alternative.py
Normal file
93
wizard/purchase_requisition_create_alternative.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models, Command
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequisitionCreateAlternative(models.TransientModel):
|
||||||
|
_name = 'purchase.requisition.create.alternative'
|
||||||
|
_description = 'Wizard to preset values for alternative PO'
|
||||||
|
|
||||||
|
origin_po_id = fields.Many2one(
|
||||||
|
'purchase.order', help="The original PO that this alternative PO is being created for."
|
||||||
|
)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
'res.partner', string='Vendor', required=True,
|
||||||
|
help="Choose a vendor for alternative PO")
|
||||||
|
creation_blocked = fields.Boolean(
|
||||||
|
help="If the chosen vendor or if any of the products in the original PO have a blocking warning then we prevent creation of alternative PO. "
|
||||||
|
"This is because normally these fields are cleared w/warning message within form view, but we cannot recreate that in this case.",
|
||||||
|
compute="_compute_purchase_warn",
|
||||||
|
groups="purchase.group_warning_purchase")
|
||||||
|
purchase_warn_msg = fields.Text(
|
||||||
|
'Warning Messages',
|
||||||
|
compute="_compute_purchase_warn",
|
||||||
|
groups="purchase.group_warning_purchase")
|
||||||
|
copy_products = fields.Boolean(
|
||||||
|
"Copy Products", default=True,
|
||||||
|
help="If this is checked, the product quantities of the original PO will be copied")
|
||||||
|
|
||||||
|
@api.depends('partner_id', 'copy_products')
|
||||||
|
def _compute_purchase_warn(self):
|
||||||
|
self.creation_blocked = False
|
||||||
|
self.purchase_warn_msg = ''
|
||||||
|
# follows partner warning logic from PurchaseOrder
|
||||||
|
if not self.env.user.has_group('purchase.group_warning_purchase'):
|
||||||
|
return
|
||||||
|
partner = self.partner_id
|
||||||
|
# If partner has no warning, check its company
|
||||||
|
if partner and partner.purchase_warn == 'no-message':
|
||||||
|
partner = partner.parent_id
|
||||||
|
if partner and partner.purchase_warn != 'no-message':
|
||||||
|
self.purchase_warn_msg = _("Warning for %(partner)s:\n%(warning_message)s\n", partner=partner.name, warning_message=partner.purchase_warn_msg)
|
||||||
|
if partner.purchase_warn == 'block':
|
||||||
|
self.creation_blocked = True
|
||||||
|
self.purchase_warn_msg += _("This is a blocking warning!\n")
|
||||||
|
if self.copy_products and self.origin_po_id.order_line:
|
||||||
|
for line in self.origin_po_id.order_line:
|
||||||
|
if line.product_id.purchase_line_warn != 'no-message':
|
||||||
|
self.purchase_warn_msg += _("Warning for %(product)s:\n%(warning_message)s\n", product=line.product_id.name, warning_message=line.product_id.purchase_line_warn_msg)
|
||||||
|
if line.product_id.purchase_line_warn == 'block':
|
||||||
|
self.creation_blocked = True
|
||||||
|
self.purchase_warn_msg += _("This is a blocking warning!\n")
|
||||||
|
|
||||||
|
def action_create_alternative(self):
|
||||||
|
if self.env.user.has_group('purchase.group_warning_purchase') and self.creation_blocked:
|
||||||
|
raise UserError(
|
||||||
|
_('The vendor you have selected or at least one of the products you are copying from the original '
|
||||||
|
'order has a blocking warning on it and cannot be selected to create an alternative.')
|
||||||
|
)
|
||||||
|
vals = self._get_alternative_values()
|
||||||
|
alt_po = self.env['purchase.order'].with_context(origin_po_id=self.origin_po_id.id, default_requisition_id=False).create(vals)
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_model': 'purchase.order',
|
||||||
|
'res_id': alt_po.id,
|
||||||
|
'context': {
|
||||||
|
'active_id': alt_po.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_alternative_values(self):
|
||||||
|
vals = {
|
||||||
|
'date_order': self.origin_po_id.date_order,
|
||||||
|
'partner_id': self.partner_id.id,
|
||||||
|
'user_id': self.origin_po_id.user_id.id,
|
||||||
|
'dest_address_id': self.origin_po_id.dest_address_id.id,
|
||||||
|
'origin': self.origin_po_id.origin,
|
||||||
|
}
|
||||||
|
if self.copy_products and self.origin_po_id:
|
||||||
|
vals['order_line'] = [Command.create(self._get_alternative_line_value(line)) for line in self.origin_po_id.order_line]
|
||||||
|
return vals
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_alternative_line_value(self, order_line):
|
||||||
|
return {
|
||||||
|
'product_id': order_line.product_id.id,
|
||||||
|
'product_qty': order_line.product_qty,
|
||||||
|
'product_uom': order_line.product_uom.id,
|
||||||
|
'display_type': order_line.display_type,
|
||||||
|
'name': order_line.name,
|
||||||
|
}
|
29
wizard/purchase_requisition_create_alternative.xml
Normal file
29
wizard/purchase_requisition_create_alternative.xml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="purchase_requisition_create_alternative_form" model="ir.ui.view">
|
||||||
|
<field name="name">Create Alternative</field>
|
||||||
|
<field name="model">purchase.requisition.create.alternative</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Create Alternative">
|
||||||
|
<group>
|
||||||
|
<field name="origin_po_id" invisible="1"/>
|
||||||
|
<group>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="copy_products"/>
|
||||||
|
</group>
|
||||||
|
<group groups="purchase.group_warning_purchase" invisible="purchase_warn_msg == ''">
|
||||||
|
<field name="purchase_warn_msg"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button name="action_create_alternative" string="Create Alternative" data-hotkey="q" type="object" colspan="1" class="btn-primary"/>
|
||||||
|
<button string="Cancel" data-hotkey="x" special="cancel" colspan="1" class="btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
Loading…
x
Reference in New Issue
Block a user