diff --git a/product_cores/__init__.py b/product_cores/__init__.py new file mode 100644 index 00000000..09434554 --- /dev/null +++ b/product_cores/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models diff --git a/product_cores/__manifest__.py b/product_cores/__manifest__.py new file mode 100755 index 00000000..83abf041 --- /dev/null +++ b/product_cores/__manifest__.py @@ -0,0 +1,25 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Product Cores', + 'author': 'Hibou Corp. ', + 'version': '15.0.1.0.0', + 'category': 'Tools', + 'license': 'OPL-1', + 'summary': 'Charge customers core deposits.', + 'description': """ +Charge customers core deposits. + """, + 'website': 'https://hibou.io/', + 'depends': [ + 'sale_stock', + 'purchase_stock', + ], + 'data': [ + 'views/product_views.xml', + 'views/purchase_views.xml', + 'views/sale_views.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/product_cores/models/__init__.py b/product_cores/models/__init__.py new file mode 100644 index 00000000..db72a159 --- /dev/null +++ b/product_cores/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import account +from . import product +from . import purchase +from . import sale diff --git a/product_cores/models/account.py b/product_cores/models/account.py new file mode 100644 index 00000000..7ebf1401 --- /dev/null +++ b/product_cores/models/account.py @@ -0,0 +1,41 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from datetime import timedelta +from odoo import models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + def _post(self, soft=True): + if self._context.get('move_reverse_cancel'): + return super(AccountMove, self)._post(soft) + self._product_core_set_date_maturity() + return super(AccountMove, self)._post(soft) + + def _product_core_set_date_maturity(self): + for move in self: + for line in move.invoice_line_ids.filtered(lambda l: l.product_id.core_ok and l.product_id.type == 'service'): + regular_date_maturity = line.date + timedelta(days=(line.product_id.product_core_validity or 0)) + if move.move_type in ('in_invoice', 'in_refund', 'in_receipt'): + # derive from purchase + if move.move_type == 'in_refund' and line.purchase_line_id: + # try to date from original + po_move_lines = self.search([('purchase_line_id', '=', line.purchase_line_id.id)]) + po_move_lines = po_move_lines.filtered(lambda l: l.move_id.move_type == 'in_invoice') + if po_move_lines: + line.date_maturity = po_move_lines[0].date_maturity or regular_date_maturity + else: + line.date_maturity = regular_date_maturity + else: + line.date_maturity = regular_date_maturity + elif move.move_type in ('out_invoice', 'out_refund', 'out_receipt'): + # derive from sale + if move.move_type == 'out_refund' and line.sale_line_ids: + other_move_lines = line.sale_line_ids.mapped('invoice_lines').filtered(lambda l: l.move_id.move_type == 'out_invoice') + if other_move_lines: + line.date_maturity = other_move_lines[0].date_maturity or regular_date_maturity + else: + line.date_maturity = regular_date_maturity + else: + line.date_maturity = regular_date_maturity diff --git a/product_cores/models/product.py b/product_cores/models/product.py new file mode 100644 index 00000000..b88df8d9 --- /dev/null +++ b/product_cores/models/product.py @@ -0,0 +1,61 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + core_ok = fields.Boolean(string='Core') + product_core_service_id = fields.Many2one('product.product', string='Product Core Deposit', + compute='_compute_product_core_service', + inverse='_set_product_core_service') + product_core_id = fields.Many2one('product.product', string='Product Core', + compute='_compute_product_core', + inverse='_set_product_core') + product_core_validity = fields.Integer(string='Product Core Return Validity', + help='How long after a sale the core is eligible for return. (in days)') + + @api.depends('product_variant_ids', 'product_variant_ids.product_core_service_id') + def _compute_product_core_service(self): + unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) + for template in unique_variants: + template.product_core_service_id = template.product_variant_ids.product_core_service_id + for template in (self - unique_variants): + template.product_core_service_id = False + + @api.depends('product_variant_ids', 'product_variant_ids.product_core_id') + def _compute_product_core(self): + unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) + for template in unique_variants: + template.product_core_id = template.product_variant_ids.product_core_id + for template in (self - unique_variants): + template.product_core_id = False + + def _set_product_core_service(self): + if len(self.product_variant_ids) == 1: + self.product_variant_ids.product_core_service_id = self.product_core_service_id + + def _set_product_core(self): + if len(self.product_variant_ids) == 1: + self.product_variant_ids.product_core_id = self.product_core_id + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + product_core_service_id = fields.Many2one('product.product', string='Product Core Deposit') + product_core_id = fields.Many2one('product.product', string='Product Core') + + def get_purchase_core_service(self, vendor): + seller_line = self.seller_ids.filtered(lambda l: l.name == vendor and l.product_core_service_id) + # only want to return the first one + for l in seller_line: + return l.product_core_service_id + return seller_line + + +class ProductSupplierinfo(models.Model): + _inherit = 'product.supplierinfo' + + product_core_service_id = fields.Many2one('product.product', string='Product Core Deposit') diff --git a/product_cores/models/purchase.py b/product_cores/models/purchase.py new file mode 100644 index 00000000..95ba7087 --- /dev/null +++ b/product_cores/models/purchase.py @@ -0,0 +1,71 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + def copy(self, default=None): + new_po = super(PurchaseOrder, self).copy(default=default) + for line in new_po.order_line.filtered(lambda l: l.product_id.core_ok and not l.core_line_id): + line.unlink() + return new_po + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + qty_received = fields.Float(recursive=True) + core_line_id = fields.Many2one('purchase.order.line', string='Core Purchase Line', copy=False) + + @api.model + def create(self, values): + res = super(PurchaseOrderLine, self).create(values) + other_product = res.product_id.get_purchase_core_service(res.order_id.partner_id) + if other_product: + values['product_id'] = other_product.id + values['name'] = other_product.name + values['price_unit'] = other_product.list_price + values['core_line_id'] = res.id + other_line = self.create(values) + other_line._compute_tax_id() + return res + + def write(self, values): + res = super(PurchaseOrderLine, self).write(values) + if any(f in values for f in ('product_id', 'product_qty', 'product_uom')): + self.filtered(lambda l: not l.core_line_id)\ + .mapped('order_id.order_line')\ + .filtered('core_line_id')\ + ._update_core_line() + return res + + def unlink(self): + for line in self: + if line.core_line_id and line.core_line_id and not self.env.user.has_group('purchase.group_purchase_user'): + raise UserError(_('You cannot delete a core line while the original still exists.')) + # Unlink any linked core lines. + other_line = line.order_id.order_line.filtered(lambda l: l.core_line_id == line) + if other_line and other_line not in self: + other_line.write({'core_line_id': False}) + other_line.unlink() + return super(PurchaseOrderLine, self).unlink() + + def _update_core_line(self): + for line in self: + if line.core_line_id and line.core_line_id.product_id.get_purchase_core_service(line.order_id.partner_id): + line.update({ + 'product_id': line.core_line_id.product_id.get_purchase_core_service(line.order_id.partner_id).id, + 'product_qty': line.core_line_id.product_qty, + 'product_uom': line.core_line_id.product_uom.id, + }) + elif line.core_line_id: + line.unlink() + + @api.depends('qty_received_method', 'qty_received_manual', 'core_line_id.qty_received') + def _compute_qty_received(self): + super(PurchaseOrderLine, self)._compute_qty_received() + for line in self.filtered(lambda l: l.qty_received_method == 'manual' and l.core_line_id): + line.qty_received = line.core_line_id.qty_received diff --git a/product_cores/models/sale.py b/product_cores/models/sale.py new file mode 100644 index 00000000..c1cfb7d9 --- /dev/null +++ b/product_cores/models/sale.py @@ -0,0 +1,69 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def copy(self, default=None): + new_so = super(SaleOrder, self).copy(default=default) + for line in new_so.order_line.filtered(lambda l: l.product_id.core_ok and not l.core_line_id): + line.unlink() + return new_so + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + qty_delivered = fields.Float(recursive=True) + core_line_id = fields.Many2one('sale.order.line', string='Core Sale Line', copy=False) + + @api.model + def create(self, values): + res = super(SaleOrderLine, self).create(values) + if res.product_id.product_core_service_id: + other_product = res.product_id.product_core_service_id + values['product_id'] = other_product.id + values['name'] = other_product.name + values['price_unit'] = other_product.list_price + values['core_line_id'] = res.id + other_line = self.create(values) + other_line._compute_tax_id() + return res + + def write(self, values): + res = super(SaleOrderLine, self).write(values) + if 'product_id' in values or 'product_uom_qty' in values or 'product_uom' in values: + for line in self.filtered(lambda l: not l.core_line_id): + line.mapped('order_id.order_line').filtered(lambda l: l.core_line_id == line)._update_core_line() + return res + + def unlink(self): + for line in self: + if line.core_line_id and line.core_line_id and not self.env.user.has_group('sales_team.group_sale_manager'): + raise UserError(_('You cannot delete a core line while the original still exists.')) + # Unlink any linked core lines. + other_line = line.order_id.order_line.filtered(lambda l: l.core_line_id == line) + if other_line and other_line not in self: + other_line.write({'core_line_id': False}) + other_line.unlink() + return super(SaleOrderLine, self).unlink() + + def _update_core_line(self): + for line in self: + if line.core_line_id and line.core_line_id.product_id.product_core_service_id: + line.write({ + 'product_id': line.core_line_id.product_id.product_core_service_id.id, + 'product_uom_qty': line.core_line_id.product_uom_qty, + 'product_uom': line.core_line_id.product_uom.id, + }) + elif line.core_line_id: + line.unlink() + + @api.depends('core_line_id.qty_delivered') + def _compute_qty_delivered(self): + super(SaleOrderLine, self)._compute_qty_delivered() + for line in self.filtered(lambda l: l.core_line_id): + line.qty_delivered = line.core_line_id.qty_delivered diff --git a/product_cores/tests/__init__.py b/product_cores/tests/__init__.py new file mode 100644 index 00000000..c6970af4 --- /dev/null +++ b/product_cores/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import test_product_cores diff --git a/product_cores/tests/test_product_cores.py b/product_cores/tests/test_product_cores.py new file mode 100644 index 00000000..2bbe195c --- /dev/null +++ b/product_cores/tests/test_product_cores.py @@ -0,0 +1,242 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.tests import common, Form +from odoo import fields + + +class TestProductCores(common.TransactionCase): + def setUp(self): + super(TestProductCores, self).setUp() + self.customer = self.env.ref('base.res_partner_2') + self.vendor = self.env.ref('base.res_partner_12') + self.purchase_tax_physical = self.env['account.tax'].create({ + 'name': 'Purchase Tax Physical', + 'type_tax_use': 'purchase', + 'amount': 5.0, + }) + self.purchase_tax_service = self.env['account.tax'].create({ + 'name': 'Purchase Tax Service', + 'type_tax_use': 'purchase', + 'amount': 1.0, + }) + self.sale_tax_physical = self.env['account.tax'].create({ + 'name': 'Sale Tax Physical', + 'type_tax_use': 'sale', + 'amount': 5.0, + }) + self.sale_tax_service = self.env['account.tax'].create({ + 'name': 'Sale Tax Service', + 'type_tax_use': 'sale', + 'amount': 1.0, + }) + self.product = self.env['product.product'].create({ + 'name': 'Turbo', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'supplier_taxes_id': [(6, 0, [self.purchase_tax_physical.id])], + 'taxes_id': [(6, 0, [self.sale_tax_physical.id])] + }) + self.product_core_service = self.env['product.product'].create({ + 'name': 'Turbo Core Deposit', + 'type': 'service', + 'categ_id': self.env.ref('product.product_category_all').id, + 'core_ok': True, + 'service_type': 'manual', + 'supplier_taxes_id': [(6, 0, [self.purchase_tax_service.id])], + 'taxes_id': [(6, 0, [self.sale_tax_service.id])], + 'product_core_validity': 30, + }) + self.product_core = self.env['product.product'].create({ + 'name': 'Turbo Core', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'core_ok': True, + }) + self.product.product_core_id = self.product_core + self.product.product_core_service_id = self.product_core_service + + def test_01_purchase(self): + purchase = self.env['purchase.order'].create({ + 'partner_id': self.vendor.id, + }) + test_qty = 2.0 + po_line = self.env['purchase.order.line'].create({ + 'order_id': purchase.id, + 'product_id': self.product.id, + 'name': 'Test', + 'date_planned': purchase.date_order, + 'product_qty': test_qty, + 'product_uom': self.product.uom_id.id, + 'price_unit': 10.0, + }) + # Compute taxes since it didn't come from being created + po_line._compute_tax_id() + # No service line should have been created. + self.assertEqual(len(purchase.order_line), 1) + po_line.unlink() + + # Create a supplierinfo for this vendor with a core service product + self.env['product.supplierinfo'].create({ + 'name': self.vendor.id, + 'price': 10.0, + 'product_core_service_id': self.product_core_service.id, + 'product_tmpl_id': self.product.product_tmpl_id.id, + }) + po_line = self.env['purchase.order.line'].create({ + 'order_id': purchase.id, + 'product_id': self.product.id, + 'name': 'Test', + 'date_planned': purchase.date_order, + 'product_qty': test_qty, + 'product_uom': self.product.uom_id.id, + 'price_unit': 10.0, + }) + po_line._compute_tax_id() + # Ensure second line was created + self.assertEqual(len(purchase.order_line), 2) + # Ensure second line has the same quantity + self.assertTrue(all(l.product_qty == test_qty for l in purchase.order_line)) + po_line_service = purchase.order_line.filtered(lambda l: l.product_id == self.product_core_service) + self.assertTrue(po_line_service) + # Ensure correct taxes + self.assertEqual(po_line.taxes_id, self.purchase_tax_physical) + self.assertEqual(po_line_service.taxes_id, self.purchase_tax_service) + + test_qty = 10.0 + po_line.product_qty = test_qty + # Ensure second line has the same quantity + self.assertTrue(all(l.product_qty == test_qty for l in purchase.order_line)) + + purchase.button_confirm() + self.assertEqual(purchase.state, 'purchase') + self.assertEqual(len(purchase.picking_ids), 1) + self.assertEqual(len(purchase.picking_ids.move_line_ids), 1) # shouldn't have the service + purchase.picking_ids.move_line_ids.qty_done = purchase.picking_ids.move_line_ids.product_uom_qty + purchase.picking_ids.button_validate() + purchase.flush() + + # All lines should be received on the PO + for line in purchase.order_line: + self.assertEqual(line.product_qty, line.qty_received) + + # From purchase.tests.test_purchase_order_report in 13 + f = Form(self.env['account.move'].with_context(default_type='in_invoice')) + f.partner_id = purchase.partner_id + f.purchase_id = purchase + vendor_bill = f.save() + self.assertEqual(len(vendor_bill.invoice_line_ids), 2) + vendor_bill.action_post() + for line in vendor_bill.invoice_line_ids: + pol = purchase.order_line.filtered(lambda l: l.product_id == line.product_id) + self.assertTrue(pol) + self.assertEqual(line.quantity, pol.product_qty) + if line.product_id.type == 'service': + self.assertNotEqual(line.date, line.date_maturity) + purchase.flush() + + # Duplicate PO + # Should not 'duplicate' the original service line + purchase2 = purchase.copy() + self.assertEqual(len(purchase2.order_line), 2) + po_line2 = purchase2.order_line.filtered(lambda l: l.product_id == self.product) + po_line_service2 = purchase2.order_line.filtered(lambda l: l.product_id == self.product_core_service) + self.assertTrue(po_line2) + self.assertTrue(po_line_service2) + # Should not be allowed to remove the service line. + # Managers can remove the service line. + # with self.assertRaises(UserError): + # po_line_service2.unlink() + po_line2.unlink() + # Deleting the main product line should delete the service line + self.assertFalse(po_line2.exists()) + self.assertFalse(po_line_service2.exists()) + + def test_02_sale(self): + # Need Inventory. + adjust_quant = self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product.id, + 'inventory_quantity': 20.0, + 'location_id': self.env.ref('stock.warehouse0').lot_stock_id.id, + }) + adjust_quant.action_apply_inventory() + self.assertEqual(self.product.virtual_available, 20.0) + + sale = self.env['sale.order'].create({ + 'partner_id': self.customer.id, + 'date_order': fields.Datetime.now(), + 'picking_policy': 'direct', + }) + test_qty = 2.0 + so_line = self.env['sale.order.line'].create({ + 'order_id': sale.id, + 'product_id': self.product.id, + 'name': 'Test', + 'product_uom_qty': test_qty, + 'product_uom': self.product.uom_id.id, + 'price_unit': 10.0, + }) + # Compute taxes since it didn't come from being created + so_line._compute_tax_id() + # Ensure second line was created + self.assertEqual(len(sale.order_line), 2) + # Ensure second line has the same quantity + self.assertTrue(all(l.product_uom_qty == test_qty for l in sale.order_line)) + so_line_service = sale.order_line.filtered(lambda l: l.product_id == self.product_core_service) + self.assertTrue(so_line_service) + # Ensure correct taxes + self.assertEqual(so_line.tax_id, self.sale_tax_physical) + self.assertEqual(so_line_service.tax_id, self.sale_tax_service) + + test_qty = 1.0 + so_line.product_uom_qty = test_qty + # Ensure second line has the same quantity + self.assertTrue(all(l.product_qty == test_qty for l in sale.order_line)) + + sale.action_confirm() + self.assertTrue(sale.state in ('sale', 'done')) + self.assertEqual(len(sale.picking_ids), 1) + self.assertEqual(len(sale.picking_ids.move_lines), 1) + self.assertEqual(sale.picking_ids.move_lines.product_id, self.product) + sale.picking_ids.action_assign() + + self.assertEqual(so_line.product_uom_qty, sale.picking_ids.move_lines.reserved_availability) + for move_line in sale.picking_ids.mapped('move_lines.move_line_ids'): + move_line.qty_done = move_line.product_uom_qty + sale.picking_ids.button_validate() + self.assertEqual(sale.picking_ids.state, 'done') + self.assertEqual(so_line.qty_delivered, so_line.product_uom_qty) + + # Ensure all products are delivered. + self.assertTrue(all(l.product_qty == l.qty_delivered for l in sale.order_line)) + + # Duplicate SO + # Should not 'duplicate' the original service line + sale2 = sale.copy() + self.assertEqual(len(sale2.order_line), 2) + so_line2 = sale2.order_line.filtered(lambda l: l.product_id == self.product) + so_line_service2 = sale2.order_line.filtered(lambda l: l.product_id == self.product_core_service) + self.assertTrue(so_line2) + self.assertTrue(so_line_service2) + # Should not be allowed to remove the service line. + # Managers can remove the service line + # with self.assertRaises(UserError): + # so_line_service2.unlink() + so_line2.unlink() + # Deleting the main product line should delete the service line + self.assertFalse(so_line2.exists()) + self.assertFalse(so_line_service2.exists()) + + # Return the SO + self.assertEqual(len(sale.picking_ids), 1) + wiz = self.env['stock.return.picking'].with_context(active_model='stock.picking', active_id=sale.picking_ids.id).create({}) + wiz._onchange_picking_id() + wiz.create_returns() + self.assertEqual(len(sale.picking_ids), 2) + + return_picking = sale.picking_ids.filtered(lambda p: p.state != 'done') + self.assertTrue(return_picking) + for move_line in return_picking.mapped('move_lines.move_line_ids'): + move_line.qty_done = move_line.product_uom_qty + return_picking.button_validate() + + self.assertTrue(all(l.qty_delivered == 0.0 for l in sale.order_line)) diff --git a/product_cores/views/product_views.xml b/product_cores/views/product_views.xml new file mode 100644 index 00000000..da7157d9 --- /dev/null +++ b/product_cores/views/product_views.xml @@ -0,0 +1,69 @@ + + + + + product.template.product.form.inherit + product.template + + + +
+ +
+
+ + + + + + + +
+
+ + + product.product.form.inherit + product.product + + + +
+ +
+
+ + + + + + + +
+
+ + + + product.supplierinfo.tree.view.inherit + product.supplierinfo + + + + + + + + + + product.supplierinfo.form.view.inherit + product.supplierinfo + + + + + + + + +
diff --git a/product_cores/views/purchase_views.xml b/product_cores/views/purchase_views.xml new file mode 100644 index 00000000..63b8aec4 --- /dev/null +++ b/product_cores/views/purchase_views.xml @@ -0,0 +1,18 @@ + + + + + purchase.order.form.inherit + purchase.order + + + + + + + + + + + + diff --git a/product_cores/views/sale_views.xml b/product_cores/views/sale_views.xml new file mode 100644 index 00000000..6b2b88f7 --- /dev/null +++ b/product_cores/views/sale_views.xml @@ -0,0 +1,21 @@ + + + + + sale.order.form.inherit + sale.order + + + + + + + + + + + + + + +