Merge branch 'mig/15.0/product_cores' into '15.0'

mig/15.0/product_cores into 15.0

See merge request hibou-io/hibou-odoo/suite!1208
This commit is contained in:
Jared Kipe
2021-12-03 19:56:41 +00:00
12 changed files with 629 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import models

25
product_cores/__manifest__.py Executable file
View File

@@ -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. <hello@hibou.io>',
'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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import test_product_cores

View File

@@ -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))

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="product_template_only_form_view_inherit" model="ir.ui.view">
<field name="name">product.template.product.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='options']" position="inside">
<div>
<field name="core_ok"/>
<label for="core_ok"/>
</div>
</xpath>
<xpath expr="//page[@name='inventory']" position="inside">
<group name="cores" string="Cores" attrs="{'invisible': [('product_variant_count', '&gt;', 1)]}">
<field name="product_core_service_id" domain="[('product_tmpl_id.core_ok', '=', True)]" context="{'default_core_ok': True}"/>
<field name="product_core_id" domain="[('product_tmpl_id.core_ok', '=', True)]" context="{'default_core_ok': True}"/>
<field name="product_core_validity"/>
</group>
</xpath>
</field>
</record>
<record id="product_normal_form_view_inherit" model="ir.ui.view">
<field name="name">product.product.form.inherit</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_normal_form_view"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='options']" position="inside">
<div>
<field name="core_ok"/>
<label for="core_ok"/>
</div>
</xpath>
<xpath expr="//page[@name='inventory']" position="inside">
<group name="cores" string="Cores">
<field name="product_core_service_id" domain="[('product_tmpl_id.core_ok', '=', True)]" context="{'default_core_ok': True}"/>
<field name="product_core_id" domain="[('product_tmpl_id.core_ok', '=', True)]" context="{'default_core_ok': True}"/>
<field name="product_core_validity"/>
</group>
</xpath>
</field>
</record>
<!-- product.supplierinfo -->
<record id="product_supplierinfo_tree_view_inherit" model="ir.ui.view">
<field name="name">product.supplierinfo.tree.view.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='name']" position="after">
<field name="product_core_service_id"/>
</xpath>
</field>
</record>
<record id="product_supplierinfo_form_view_inherit" model="ir.ui.view">
<field name="name">product.supplierinfo.form.view.inherit</field>
<field name="model">product.supplierinfo</field>
<field name="inherit_id" ref="product.product_supplierinfo_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="product_core_service_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<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">
<xpath expr="//field[@name='order_line']/tree" position="inside">
<field name="core_line_id" invisible="1"/>
</xpath>
<xpath expr="//field[@name='order_line']/form//field[@name='product_id']" position="after">
<field name="core_line_id" readonly="1" force_save="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_order_form_inherit" model="ir.ui.view">
<field name="name">sale.order.form.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='order_line']/tree" position="inside">
<field name="core_line_id" invisible="1"/>
</xpath>
<xpath expr="//field[@name='order_line']/form//field[@name='product_id']" position="after">
<field name="core_line_id" readonly="1" force_save="1"/>
</xpath>
<xpath expr="//field[@name='order_line']/kanban/field[@name='product_id']" position="after">
<field name="core_line_id"/>
</xpath>
</field>
</record>
</odoo>