diff --git a/compute_purchase_order/__init__.py b/compute_purchase_order/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/compute_purchase_order/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/compute_purchase_order/__openerp__.py b/compute_purchase_order/__openerp__.py new file mode 100644 index 0000000..1018692 --- /dev/null +++ b/compute_purchase_order/__openerp__.py @@ -0,0 +1,21 @@ +# -*- encoding: utf-8 -*- +{ + 'name': 'Computed Purchase Order', + 'version': '9.0.1', + 'category': 'Purchase Order', + 'description': """ todo """, + 'author': 'Coop IT Easy', + 'website': 'https://github.com/coopiteasy/procurement-addons', + 'license': 'AGPL-3', + 'depends': [ + 'product', + 'purchase', + 'stock', + 'stock_coverage', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/computed_purchase_order.xml', + 'views/purchase_order.xml', + ], +} diff --git a/compute_purchase_order/models/__init__.py b/compute_purchase_order/models/__init__.py new file mode 100644 index 0000000..6fdf87d --- /dev/null +++ b/compute_purchase_order/models/__init__.py @@ -0,0 +1,4 @@ +from . import purchase_order +from . import computed_purchase_order +from . import computed_purchase_order_line +from . import product_template diff --git a/compute_purchase_order/models/computed_purchase_order.py b/compute_purchase_order/models/computed_purchase_order.py new file mode 100644 index 0000000..2c4bb62 --- /dev/null +++ b/compute_purchase_order/models/computed_purchase_order.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +from openerp import models, fields, api +from openerp.exceptions import ValidationError + + +class ComputedPurchaseOrder(models.Model): + _description = 'Computed Purchase Order' + _name = 'computed.purchase.order' + _order = 'id desc' + + name = fields.Char( + string='CPO Reference', + size=64, + default='New') + + order_date = fields.Datetime( + string='Purchase Order Date', + default=fields.Datetime.now, + help="Depicts the date where the Quotation should be validated and converted into a purchase order.") # noqa + + date_planned = fields.Datetime( + string='Date Planned' + ) + + supplier_id = fields.Many2one( + 'res.partner', + string='Supplier', + readonly=True, + help="Supplier of the purchase order.") + + order_line_ids = fields.One2many( + 'computed.purchase.order.line', + 'computed_purchase_order_id', + string='Order Lines', + ) + + total_amount = fields.Float( + string='Total Amount (w/o VAT)', + compute='_compute_cpo_total' + ) + + generated_purchase_order_ids = fields.One2many( + 'purchase.order', + 'original_cpo_id', + string='Generated Purchase Orders', + ) + + generated_po_count = fields.Integer( + string='Generated Purchase Order count', + compute='_compute_generated_po_count', + ) + + @api.model + def default_get(self, fields_list): + record = super(ComputedPurchaseOrder, self).default_get(fields_list) + + record['date_planned'] = self._get_default_date_planned() + record['supplier_id'] = self._get_selected_supplier_id() + record['order_line_ids'] = self._create_order_lines() + record['name'] = self._compute_default_name() + + return record + + def _get_default_date_planned(self): + return fields.Datetime.now() + + def _get_selected_supplier_id(self): + """ + Calcule le vendeur associé qui a la date de début la plus récente et + plus petite qu’aujourd’hui pour chaque article sélectionné. + Will raise an error if more than two sellers are set + """ + if 'active_ids' not in self.env.context: + return False + + product_ids = self.env.context['active_ids'] + products = self.env['product.template'].browse(product_ids) + + suppliers = set() + for product in products: + main_supplier_id = product.main_supplier_id.id + suppliers.add(main_supplier_id) + + if len(suppliers) == 0: + raise ValidationError(u'No supplier is set for selected articles.') + elif len(suppliers) == 1: + return suppliers.pop() + else: + raise ValidationError( + u'You must select article from a single supplier.') + + def _create_order_lines(self): + product_tmpl_ids = self._get_selected_products() + cpol_ids = [] + OrderLine = self.env['computed.purchase.order.line'] + for product_id in product_tmpl_ids: + cpol = OrderLine.create( + {'computed_purchase_order_id': self.id, + 'product_template_id': product_id, + } + ) + # should ideally be set in cpol defaults + cpol.purchase_quantity = cpol.minimum_purchase_qty + cpol_ids.append(cpol.id) + return cpol_ids + + def _compute_default_name(self): + supplier_id = self._get_selected_supplier_id() + if supplier_id: + supplier_name = ( + self.env['res.partner'] + .browse(supplier_id) + .name) + + name = u'CPO {} {}'.format( + supplier_name, + fields.Date.today()) + else: + name = 'New' + return name + + def _get_selected_products(self): + if 'active_ids' in self.env.context: + return self.env.context['active_ids'] + else: + return [] + + @api.multi + def _compute_generated_po_count(self): + for cpo in self: + cpo.generated_po_count = len(cpo.generated_purchase_order_ids) + + @api.multi + def get_generated_po_action(self): + self.ensure_one() + action = { + 'type': 'ir.actions.act_window', + 'res_model': 'purchase.order', + 'view_mode': 'tree,form,kanban', + 'target': 'current', + 'domain': [('id', 'in', self.generated_purchase_order_ids.ids)], + } + return action + + # @api.onchange(order_line_ids) # fixme + @api.multi + def _compute_cpo_total(self): + for cpo in self: + total_amount = sum(cpol.subtotal for cpol in cpo.order_line_ids) + cpo.total_amount = total_amount + + @api.multi + def create_purchase_order(self): + self.ensure_one() + + if sum(self.order_line_ids.mapped('purchase_quantity')) == 0: + raise ValidationError(u'You need at least a product to generate ' + u'a Purchase Order') + + PurchaseOrder = self.env['purchase.order'] + PurchaseOrderLine = self.env['purchase.order.line'] + + po_values = { + 'name': 'New', + 'date_order': self.order_date, + 'partner_id': self.supplier_id.id, + 'date_planned': self.date_planned, + } + purchase_order = PurchaseOrder.create(po_values) + + for cpo_line in self.order_line_ids: + if cpo_line.purchase_quantity > 0: + pol_values = { + 'name': cpo_line.name, + 'product_id': cpo_line.get_default_product_product().id, + 'product_qty': cpo_line.purchase_quantity, + 'price_unit': cpo_line.product_price, + 'product_uom': cpo_line.uom_po_id.id, + 'order_id': purchase_order.id, + 'date_planned': self.date_planned, + } + PurchaseOrderLine.create(pol_values) + + self.generated_purchase_order_ids += purchase_order + + action = { + 'type': 'ir.actions.act_window', + 'res_model': 'purchase.order', + 'res_id': purchase_order.id, + 'view_type': 'form', + 'view_mode': 'form', + 'target': 'current', + } + return action diff --git a/compute_purchase_order/models/computed_purchase_order_line.py b/compute_purchase_order/models/computed_purchase_order_line.py new file mode 100644 index 0000000..92ca505 --- /dev/null +++ b/compute_purchase_order/models/computed_purchase_order_line.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +import logging + +from openerp import models, fields, api +from openerp.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ComputedPurchaseOrderLine(models.Model): + _description = 'Computed Purchase Order Line' + _name = 'computed.purchase.order.line' + + computed_purchase_order_id = fields.Many2one( + 'computed.purchase.order', + string='Computed Purchase Order', + ) + + product_template_id = fields.Many2one( + 'product.template', + string='Linked Product Template', + required=True, + help='Product') + + name = fields.Char( + string='Product Name', + related='product_template_id.name', + read_only=True) + + supplierinfo_id = fields.Many2one( + 'product.supplierinfo', + string='Supplier information', + compute='_compute_supplierinfo', + store=True, + readonly=True, + ) + + category_id = fields.Many2one( + 'product.category', + string='Internal Category', + related='product_template_id.categ_id', + read_only=True) + + uom_id = fields.Many2one( + 'product.uom', + string='Unit of Measure', + read_only=True, + related='product_template_id.uom_id', + help="Default Unit of Measure used for all stock operation.") + + qty_available = fields.Float( + string='Stock Quantity', + related='product_template_id.qty_available', + read_only=True, + help='Quantity currently in stock. Does not take ' + 'into account incoming orders.') + + virtual_available = fields.Float( + string='Stock Quantity', + related='product_template_id.virtual_available', + read_only=True, + help='Virtual quantity taking into account current stock, incoming ' + 'orders and outgoing sales.') + + average_consumption = fields.Float( + string='Average Consumption', + related='product_template_id.average_consumption', + read_only=True) + + stock_coverage = fields.Float( + string='Stock Coverage', + related='product_template_id.estimated_stock_coverage', + read_only=True, + ) + + minimum_purchase_qty = fields.Float( + string='Minimum Purchase Quantity', + compute='_depends_on_product_template', + ) + + purchase_quantity = fields.Float( + string='Purchase Quantity', + required=True, + default=0.) + + uom_po_id = fields.Many2one( + 'product.uom', + string='Purchase Unit of Measure', + read_only=True, + related='product_template_id.uom_po_id', + help="Default Unit of Measure used for all stock operation.") + + product_price = fields.Float( + string='Product Price (w/o VAT)', + compute='_depends_on_product_template', + read_only=True, + help='Supplier Product Price by buying unit. Price is without VAT') + + virtual_coverage = fields.Float( + string='Expected Stock Coverage', + compute='_depends_on_purchase_quantity', + help='Expected stock coverage (in days) based on current stocks and average daily consumption') # noqa + + subtotal = fields.Float( + string='Subtotal (w/o VAT)', + compute='_depends_on_purchase_quantity') + + @api.multi + @api.depends('product_template_id') + def _depends_on_product_template(self): + for cpol in self: + # get supplier info + cpol.minimum_purchase_qty = cpol.supplierinfo_id.min_qty + cpol.product_price = cpol.supplierinfo_id.price + + @api.multi + @api.onchange('product_template_id') + def _onchange_purchase_quantity(self): + for cpol in self: + cpol.purchase_quantity = cpol.supplierinfo_id.min_qty + + @api.depends('purchase_quantity') + @api.multi + def _depends_on_purchase_quantity(self): + for cpol in self: + cpol.subtotal = cpol.product_price * cpol.purchase_quantity + avg = cpol.average_consumption + if avg > 0: + qty = cpol.virtual_available + cpol.purchase_quantity + cpol.virtual_coverage = qty / avg + else: + # todo what would be a good default value? (not float(inf)) + cpol.virtual_coverage = 9999 + + return True + + @api.multi + @api.depends('product_template_id') + @api.onchange('product_template_id') + def _compute_supplierinfo(self): + for cpol in self: + if not cpol.product_template_id: + cpol.supplierinfo_id = False + else: + SupplierInfo = self.env['product.supplierinfo'] + si = SupplierInfo.search([ + ('product_tmpl_id', '=', cpol.product_template_id.id), + ('name', '=', cpol.product_template_id.main_supplier_id.id) # noqa + ]) + + if len(si) == 0: + raise ValidationError( + u'No supplier information set for {name}' + .format(name=cpol.product_template_id.name)) + elif len(si) == 1: + cpol.supplierinfo_id = si + else: + _logger.warning( + u'product {name} has several suppliers, chose last' + .format(name=cpol.product_template_id.name) + ) + si = si.sorted(key=lambda r: r.create_date, reverse=True) + cpol.supplierinfo_id = si[0] + + @api.constrains('purchase_quantity') + def _check_minimum_purchase_quantity(self): + for cpol in self: + if cpol.purchase_quantity < 0: + raise ValidationError(u'Purchase quantity for {product_name} must be greater than 0' # noqa + .format(product_name=cpol.product_template_id.name)) + elif 0 < cpol.purchase_quantity < cpol.minimum_purchase_qty: + raise ValidationError(u'Purchase quantity for {product_name} must be greater than {min_qty}' # noqa + .format(product_name=cpol.product_template_id.name, + min_qty=cpol.minimum_purchase_qty)) + + @api.multi + def get_default_product_product(self): + self.ensure_one() + ProductProduct = self.env['product.product'] + products = ProductProduct.search([ + ('product_tmpl_id', '=', self.product_template_id.id) + ]) + + products = products.sorted( + key=lambda product: product.create_date, + reverse=True + ) + + if products: + return products[0] + else: + raise ValidationError( + u'%s:%s template has no variant set' + % (self.product_template_id.id, self.product_template_id.name) + ) + diff --git a/compute_purchase_order/models/product_template.py b/compute_purchase_order/models/product_template.py new file mode 100644 index 0000000..030719b --- /dev/null +++ b/compute_purchase_order/models/product_template.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from openerp import models, fields, api + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + main_supplier_id = fields.Many2one( + 'res.partner', + compute='_compute_main_supplier_id', + store=True + ) + + def _get_sorted_supplierinfo(self): + return self.seller_ids.sorted( + key=lambda seller: seller.date_start, + reverse=True) + + @api.multi + @api.depends('seller_ids', 'seller_ids.date_start') + def _compute_main_supplier_id(self): + # Calcule le vendeur associé qui a la date de début la plus récente + # et plus petite qu’aujourd’hui + for pt in self: + sellers_ids = pt._get_sorted_supplierinfo() + pt.main_supplier_id = sellers_ids and sellers_ids[0].name or False diff --git a/compute_purchase_order/models/purchase_order.py b/compute_purchase_order/models/purchase_order.py new file mode 100644 index 0000000..24da3c6 --- /dev/null +++ b/compute_purchase_order/models/purchase_order.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +from openerp import models, fields + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + original_cpo_id = fields.Many2one( + 'computed.purchase.order', + string='Original CPO', + help='CPO used to generate this Purchase Order' + ) diff --git a/compute_purchase_order/security/ir.model.access.csv b/compute_purchase_order/security/ir.model.access.csv new file mode 100644 index 0000000..faf22c8 --- /dev/null +++ b/compute_purchase_order/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_purchase_order,purchase.order,model_computed_purchase_order,purchase.group_purchase_user,1,1,1,1 +access_purchase_order_manager,purchase.order,model_computed_purchase_order,purchase.group_purchase_manager,1,1,1,1 +access_purchase_order_stock_worker,purchase.order,model_computed_purchase_order,stock.group_stock_user,1,0,0,0 +access_purchase_order_invoicing_payments,purchase.order,model_computed_purchase_order,account.group_account_invoice,1,1,0,0 + +access_purchase_order_line,purchase.order.line user,model_computed_purchase_order_line,purchase.group_purchase_user,1,1,1,1 +access_purchase_order_line_manager,purchase.order.line manager,model_computed_purchase_order_line,purchase.group_purchase_manager,1,0,0,0 +access_purchase_order_line_stock_worker,purchase.order.line,model_computed_purchase_order_line,stock.group_stock_user,1,0,0,0 +access_purchase_order_line_manager,purchase.order.line,model_computed_purchase_order_line,purchase.group_purchase_manager,1,1,1,1 +access_purchase_order_line_invoicing_payments,purchase.order.line,model_computed_purchase_order_line,account.group_account_invoice,1,1,0,0 diff --git a/compute_purchase_order/views/computed_purchase_order.xml b/compute_purchase_order/views/computed_purchase_order.xml new file mode 100644 index 0000000..10038e1 --- /dev/null +++ b/compute_purchase_order/views/computed_purchase_order.xml @@ -0,0 +1,115 @@ + + + + + + + computed.purchase.order.tree + computed.purchase.order + + + + + + + + + + + + + + computed.purchase.order.form + computed.purchase.order + +
+
+
+ + + + + + + + + + + +
+ +
+ +
+ + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + computed.purchase.order + + + + + + + + + + + Computed Purchase Orders + ir.actions.act_window + computed.purchase.order + tree,form + + + + + + + + + +
diff --git a/compute_purchase_order/views/product_template.xml b/compute_purchase_order/views/product_template.xml new file mode 100644 index 0000000..c5607c8 --- /dev/null +++ b/compute_purchase_order/views/product_template.xml @@ -0,0 +1,24 @@ + + + + + product.template.tree + product.template + + + + + + + + + + + + + + + + + + diff --git a/compute_purchase_order/views/purchase_order.xml b/compute_purchase_order/views/purchase_order.xml new file mode 100644 index 0000000..75dc354 --- /dev/null +++ b/compute_purchase_order/views/purchase_order.xml @@ -0,0 +1,13 @@ + + + + purchase.order.form.inherit + purchase.order + + + + + + + +