diff --git a/delivery_hibou/__init__.py b/delivery_hibou/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/delivery_hibou/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/delivery_hibou/__manifest__.py b/delivery_hibou/__manifest__.py new file mode 100644 index 00000000..e53e01b9 --- /dev/null +++ b/delivery_hibou/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Delivery Hibou', + 'summary': 'Adds underlying pinnings for things like "RMA Return Labels"', + 'version': '11.0.1.0.0', + 'author': "Hibou Corp.", + 'category': 'Stock', + 'license': 'AGPL-3', + 'images': [], + 'website': "https://hibou.io", + 'description': """ +This is a collection of "typical" carrier needs, and a bridge into Hibou modules like `delivery_partner` and `sale_planner`. +""", + 'depends': [ + 'delivery', + 'delivery_partner', + ], + 'demo': [], + 'data': [ + 'views/delivery_views.xml', + 'views/stock_views.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/delivery_hibou/models/__init__.py b/delivery_hibou/models/__init__.py new file mode 100644 index 00000000..29ce1386 --- /dev/null +++ b/delivery_hibou/models/__init__.py @@ -0,0 +1,2 @@ +from . import delivery +from . import stock diff --git a/delivery_hibou/models/delivery.py b/delivery_hibou/models/delivery.py new file mode 100644 index 00000000..d8b6d41b --- /dev/null +++ b/delivery_hibou/models/delivery.py @@ -0,0 +1,152 @@ +from odoo import fields, models +from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES + + +class DeliveryCarrier(models.Model): + _inherit = 'delivery.carrier' + + automatic_insurance_value = fields.Float(string='Automatic Insurance Value', + help='Will be used during shipping to determine if the ' + 'picking\'s value warrants insurance being added.') + procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES, + string='Procurement Priority', + help='Priority for this carrier. Will affect pickings ' + 'and procurements related to this carrier.') + + def get_insurance_value(self, order=None, picking=None): + value = 0.0 + if order: + if order.order_line: + value = sum(order.order_line.filtered(lambda l: l.type != 'service').mapped('price_subtotal')) + else: + return value + if picking: + value = picking.declared_value() + if picking.require_insurance == 'no': + value = 0.0 + elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value: + value = 0.0 + return value + + def get_third_party_account(self, order=None, picking=None): + if order and order.shipping_account_id: + return order.shipping_account_id + if picking and picking.shipping_account_id: + return picking.shipping_account_id + return None + + def get_order_name(self, order=None, picking=None): + if order: + return order.name + if picking: + if picking.sale_id: + return picking.sale_id.name # + ' - ' + picking.name + return picking.name + return '' + + def get_attn(self, order=None, picking=None): + if order: + return order.client_order_ref + if picking and picking.sale_id: + return picking.sale_id.client_order_ref + # If Picking has a reference, decide what it is. + return False + + def _classify_picking(self, picking): + if picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'supplier' and picking.location_dest_id.usage == 'customer': + return 'dropship' + elif picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'customer' and picking.location_dest_id.usage == 'supplier': + return 'dropship_in' + elif picking.picking_type_id.code == 'incoming': + return 'in' + return 'out' + + # Shipper Company + + def get_shipper_company(self, order=None, picking=None): + """ + Shipper Company: The `res.partner` that provides the name of where the shipment is coming from. + """ + if order: + return order.company_id.partner_id + if picking: + return getattr(self, ('_get_shipper_company_%s' % (self._classify_picking(picking),)), + self._get_shipper_company_out)(picking) + return None + + def _get_shipper_company_dropship(self, picking): + return picking.company_id.partner_id + + def _get_shipper_company_dropship_in(self, picking): + return picking.company_id.partner_id + + def _get_shipper_company_in(self, picking): + return picking.company_id.partner_id + + def _get_shipper_company_out(self, picking): + return picking.company_id.partner_id + + # Shipper Warehouse + + def get_shipper_warehouse(self, order=None, picking=None): + """ + Shipper Warehouse: The `res.partner` that is basically the physical address a shipment is coming from. + """ + if order: + return order.warehouse_id.partner_id + if picking: + return getattr(self, ('_get_shipper_warehouse_%s' % (self._classify_picking(picking),)), + self._get_shipper_warehouse_out)(picking) + return None + + def _get_shipper_warehouse_dropship(self, picking): + return picking.partner_id + + def _get_shipper_warehouse_dropship_in(self, picking): + if picking.sale_id: + picking.sale_id.partner_shipping_id + return self._get_shipper_warehouse_dropship_in_no_sale(picking) + + def _get_shipper_warehouse_dropship_in_no_sale(self, picking): + return picking.company_id.partner_id + + def _get_shipper_warehouse_in(self, picking): + return picking.partner_id + + def _get_shipper_warehouse_out(self, picking): + return picking.picking_type_id.warehouse_id.partner_id + + # Recipient + + def get_recipient(self, order=None, picking=None): + """ + Recipient: The `res.partner` receiving the shipment. + """ + if order: + return order.partner_shipping_id + if picking: + return getattr(self, ('_get_recipient_%s' % (self._classify_picking(picking),)), + self._get_recipient_out)(picking) + return None + + def _get_recipient_dropship(self, picking): + if picking.sale_id: + return picking.sale_id.partner_shipping_id + return picking.sale_id.partner_shipping_id + + def _get_recipient_dropship_no_sale(self, picking): + return picking.company_id.partner_id + + def _get_recipient_dropship_in(self, picking): + return picking.picking_type_id.warehouse_id.partner_id + + def _get_recipient_in(self, picking): + return picking.picking_type_id.warehouse_id.partner_id + + def _get_recipient_out(self, picking): + return picking.partner_id + + + + + diff --git a/delivery_hibou/models/stock.py b/delivery_hibou/models/stock.py new file mode 100644 index 00000000..c1a6a792 --- /dev/null +++ b/delivery_hibou/models/stock.py @@ -0,0 +1,50 @@ +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account') + require_insurance = fields.Selection([ + ('auto', 'Automatic'), + ('yes', 'Yes'), + ('no', 'No'), + ], string='Require Insurance', default='auto', + help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.') + + @api.one + @api.depends('move_lines.priority', 'carrier_id') + def _compute_priority(self): + if self.carrier_id.procurement_priority: + self.priority = self.carrier_id.procurement_priority + else: + super(StockPicking, self)._compute_priority() + + @api.model + def create(self, values): + origin = values.get('origin') + if origin and not values.get('shipping_account_id'): + so = self.env['sale.order'].search([('name', '=', str(origin))], limit=1) + if so and so.shipping_account_id: + values['shipping_account_id'] = so.shipping_account_id.id + + res = super(StockPicking, self).create(values) + return res + + def declared_value(self): + self.ensure_one() + cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0]) + if not cost: + # Assume Full Value + cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0]) + return cost + + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def _prepare_procurement_values(self): + res = super(StockMove, self)._prepare_procurement_values() + res['priority'] = self.picking_id.priority or self.priority + return res diff --git a/delivery_hibou/tests/__init__.py b/delivery_hibou/tests/__init__.py new file mode 100644 index 00000000..d4e6373e --- /dev/null +++ b/delivery_hibou/tests/__init__.py @@ -0,0 +1 @@ +from . import test_delivery_hibou diff --git a/delivery_hibou/tests/test_delivery_hibou.py b/delivery_hibou/tests/test_delivery_hibou.py new file mode 100644 index 00000000..da8c4de4 --- /dev/null +++ b/delivery_hibou/tests/test_delivery_hibou.py @@ -0,0 +1,161 @@ +from odoo.tests import common + + +class TestDeliveryHibou(common.TransactionCase): + + def setUp(self): + super(TestDeliveryHibou, self).setUp() + self.partner = self.env.ref('base.res_partner_address_13') + self.product = self.env.ref('product.product_product_7') + # Create Shipping Account + self.shipping_account = self.env['partner.shipping.account'].create({ + 'name': '123123', + 'delivery_type': 'other', + }) + # Create Carrier + self.delivery_product = self.env['product.product'].create({ + 'name': 'Test Carrier1 Delivery', + 'type': 'service', + }) + self.carrier = self.env['delivery.carrier'].create({ + 'name': 'Test Carrier1', + 'product_id': self.delivery_product.id, + }) + + def test_delivery_hibou(self): + # Assign a new shipping account + self.partner.shipping_account_id = self.shipping_account + + # Assign values to new Carrier + test_insurance_value = 600 + test_procurement_priority = '2' + self.carrier.automatic_insurance_value = test_insurance_value + self.carrier.procurement_priority = test_procurement_priority + + + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'partner_shipping_id': self.partner.id, + 'partner_invoice_id': self.partner.id, + 'carrier_id': self.carrier.id, + 'shipping_account_id': self.shipping_account.id, + 'order_line': [(0, 0, { + 'product_id': self.product.id, + })] + }) + sale_order.get_delivery_price() + sale_order.set_delivery_line() + sale_order.action_confirm() + # Make sure 3rd party Shipping Account is set. + self.assertEqual(sale_order.shipping_account_id, self.shipping_account) + + self.assertTrue(sale_order.picking_ids) + # Priority coming from Carrier procurement_priority + self.assertEqual(sale_order.picking_ids.priority, test_procurement_priority) + # 3rd party Shipping Account copied from Sale Order + self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account) + self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account) + + # Test attn + test_ref = 'TEST100' + self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False) + sale_order.client_order_ref = test_ref + self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), test_ref) + # The picking should get this ref as well + self.assertEqual(sale_order.picking_ids.carrier_id.get_attn(picking=sale_order.picking_ids), test_ref) + + # Test order_name + self.assertEqual(sale_order.carrier_id.get_order_name(order=sale_order), sale_order.name) + # The picking should get the same 'order_name' + self.assertEqual(sale_order.picking_ids.carrier_id.get_order_name(picking=sale_order.picking_ids), sale_order.name) + + def test_carrier_hibou_out(self): + test_insurance_value = 4000 + self.carrier.automatic_insurance_value = test_insurance_value + + picking_out = self.env.ref('stock.outgoing_shipment_main_warehouse') + picking_out.action_assign() + self.assertEqual(picking_out.state, 'assigned') + + picking_out.carrier_id = self.carrier + + # This relies heavily on the 'stock' demo data. + # Should only have a single move_line_ids and it should not be done at all. + self.assertEqual(picking_out.move_line_ids.mapped('qty_done'), [0.0]) + self.assertEqual(picking_out.move_line_ids.mapped('product_uom_qty'), [15.0]) + self.assertEqual(picking_out.move_line_ids.mapped('product_id.standard_price'), [3300.0]) + + # The 'value' is assumed to be all of the product value from the initial demand. + self.assertEqual(picking_out.declared_value(), 15.0 * 3300.0) + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), picking_out.declared_value()) + + # Workflow where user explicitly opts out of insurance on the picking level. + picking_out.require_insurance = 'no' + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0) + picking_out.require_insurance = 'auto' + + # Lets choose to only delivery one piece at the moment. + # This does not meet the minimum on the carrier to have insurance value. + picking_out.move_line_ids.qty_done = 1.0 + self.assertEqual(picking_out.declared_value(), 3300.0) + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0) + # Workflow where user opts in to insurance. + picking_out.require_insurance = 'yes' + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 3300.0) + picking_out.require_insurance = 'auto' + + # Test with picking having 3rd party account. + self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), None) + picking_out.shipping_account_id = self.shipping_account + self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), self.shipping_account) + + # Shipment Time Methods! + self.assertEqual(picking_out.carrier_id._classify_picking(picking=picking_out), 'out') + self.assertEqual(picking_out.carrier_id.get_shipper_company(picking=picking_out), + picking_out.company_id.partner_id) + self.assertEqual(picking_out.carrier_id.get_shipper_warehouse(picking=picking_out), + picking_out.picking_type_id.warehouse_id.partner_id) + self.assertEqual(picking_out.carrier_id.get_recipient(picking=picking_out), + picking_out.partner_id) + # This picking has no `sale_id` + # Right now ATTN requires a sale_id, which this picking doesn't have (none of the stock ones do) + self.assertEqual(picking_out.carrier_id.get_attn(picking=picking_out), False) + self.assertEqual(picking_out.carrier_id.get_order_name(picking=picking_out), picking_out.name) + + def test_carrier_hibou_in(self): + picking_in = self.env.ref('stock.incomming_shipment1') + self.assertEqual(picking_in.state, 'assigned') + + picking_in.carrier_id = self.carrier + # This relies heavily on the 'stock' demo data. + # Should only have a single move_line_ids and it should not be done at all. + self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0]) + self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0]) + self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0]) + + self.assertEqual(picking_in.carrier_id._classify_picking(picking=picking_in), 'in') + self.assertEqual(picking_in.carrier_id.get_shipper_company(picking=picking_in), + picking_in.company_id.partner_id) + self.assertEqual(picking_in.carrier_id.get_shipper_warehouse(picking=picking_in), + picking_in.partner_id) + self.assertEqual(picking_in.carrier_id.get_recipient(picking=picking_in), + picking_in.picking_type_id.warehouse_id.partner_id) + + + + + + + + + + + + + + + + + + + diff --git a/delivery_hibou/views/delivery_views.xml b/delivery_hibou/views/delivery_views.xml new file mode 100644 index 00000000..01208bbd --- /dev/null +++ b/delivery_hibou/views/delivery_views.xml @@ -0,0 +1,14 @@ + + + + hibou.delivery.carrier.form + delivery.carrier + + + + + + + + + \ No newline at end of file diff --git a/delivery_hibou/views/stock_views.xml b/delivery_hibou/views/stock_views.xml new file mode 100644 index 00000000..78067b01 --- /dev/null +++ b/delivery_hibou/views/stock_views.xml @@ -0,0 +1,16 @@ + + + + + hibou.delivery.stock.picking_withcarrier.form.view + stock.picking + + + + + + + + + + diff --git a/delivery_partner/README.rst b/delivery_partner/README.rst new file mode 100644 index 00000000..e527d2bc --- /dev/null +++ b/delivery_partner/README.rst @@ -0,0 +1,29 @@ +********************************* +Hibou - Partner Shipping Accounts +********************************* + +Records shipping account numbers on partners. + +For more information and add-ons, visit `Hibou.io `_. + + +============= +Main Features +============= + +* New model: Customer Shipping Account +* Includes manager-level access permissions. + +.. image:: https://user-images.githubusercontent.com/15882954/41176601-e40f8558-6b15-11e8-998e-6a7ee5709c0f.png + :alt: 'Register Payment Detail' + :width: 988 + :align: left + + +======= +License +======= + +Please see `LICENSE `_. + +Copyright Hibou Corp. 2018 diff --git a/delivery_partner/__init__.py b/delivery_partner/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/delivery_partner/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/delivery_partner/__manifest__.py b/delivery_partner/__manifest__.py new file mode 100755 index 00000000..ca0582e6 --- /dev/null +++ b/delivery_partner/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Partner Shipping Accounts', + 'author': 'Hibou Corp. ', + 'version': '11.0.1.0.0', + 'category': 'Stock', + 'sequence': 95, + 'summary': 'Record shipping account numbers on partners.', + 'description': """ +Record shipping account numbers on partners. + +* Customer Shipping Account Model + """, + 'website': 'https://hibou.io/', + 'depends': [ + 'delivery', + 'contacts', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/delivery_views.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/delivery_partner/models/__init__.py b/delivery_partner/models/__init__.py new file mode 100644 index 00000000..be8cabd6 --- /dev/null +++ b/delivery_partner/models/__init__.py @@ -0,0 +1 @@ +from . import delivery diff --git a/delivery_partner/models/delivery.py b/delivery_partner/models/delivery.py new file mode 100644 index 00000000..6838d384 --- /dev/null +++ b/delivery_partner/models/delivery.py @@ -0,0 +1,48 @@ +from odoo import api, fields, models + + +class Partner(models.Model): + _inherit = 'res.partner' + + shipping_account_ids = fields.One2many('partner.shipping.account', 'partner_id', string='Shipping Accounts') + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account') + + +class PartnerShippingAccount(models.Model): + _name = 'partner.shipping.account' + + name = fields.Char(string='Account Num.', required=True) + description = fields.Char(string='Description') + partner_id = fields.Many2one('res.partner', string='Partner', help='Leave blank to allow as a generic 3rd party shipper.') + delivery_type = fields.Selection([ + ('other', 'Other'), + ], string='Carrier', required=True) + note = fields.Text(string='Internal Note') + + @api.multi + def name_get(self): + delivery_types = self._fields['delivery_type']._description_selection(self.env) + + def get_name(value): + name = [n for v, n in delivery_types if v == value] + return name[0] if name else 'Undefined' + + res = [] + for acc in self: + if acc.description: + res.append((acc.id, acc.description)) + else: + res.append((acc.id, '%s: %s' % (get_name(acc.delivery_type), acc.name))) + return res + + @api.constrains('name', 'delivery_type') + def _check_validity(self): + for acc in self: + check = getattr(acc, acc.delivery_type + '_check_validity', None) + if check: + return check() diff --git a/delivery_partner/security/ir.model.access.csv b/delivery_partner/security/ir.model.access.csv new file mode 100644 index 00000000..3db88333 --- /dev/null +++ b/delivery_partner/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_partner_shipping_account,partner.shipping.account,model_partner_shipping_account,base.group_partner_manager,1,1,1,1 diff --git a/delivery_partner/views/delivery_views.xml b/delivery_partner/views/delivery_views.xml new file mode 100644 index 00000000..7f888688 --- /dev/null +++ b/delivery_partner/views/delivery_views.xml @@ -0,0 +1,94 @@ + + + + partner.shipping.account.tree + partner.shipping.account + + + + + + + + + + + + partner.shipping.account.form + partner.shipping.account + +
+ + + + + + + + + + + + +
+
+
+ + + partner.shipping.account.search + partner.shipping.account + + + + + + + + + + + + Shipping Accounts + partner.shipping.account + form + tree,form + +

+ No accounts +

+
+
+ + + + + + res.partner.carrier.property.form.inherit + res.partner + + + + + + + + + + + + + + + + delivery.sale.order.form.view.with_carrier.inherit + sale.order + + + + + + + +
diff --git a/sale_line_reconfigure/__init__.py b/sale_line_reconfigure/__init__.py new file mode 100755 index 00000000..91c5580f --- /dev/null +++ b/sale_line_reconfigure/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/sale_line_reconfigure/__manifest__.py b/sale_line_reconfigure/__manifest__.py new file mode 100755 index 00000000..56f745df --- /dev/null +++ b/sale_line_reconfigure/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': 'Sale Line Reconfigure', + 'author': 'Hibou Corp. ', + 'category': 'Hidden', + 'version': '12.0.1.0.0', + 'description': + """ +Sale Line Reconfigure +===================== + +The product configurator allows for truely complex sale order lines, with potentially +no-variant and custom attribute values. + +This module allows you to create 'duplicate' sale order lines that start with all of +the attribute values from some other line. This lets you treat existing SO lines as +templates for the new additions. This lets you "re-configure" a line by a +workflow in which you add a new line, then remove old line (or reduce its ordered quantity). + """, + 'depends': [ + 'sale', + 'account', + ], + 'auto_install': False, + 'data': [ + 'views/assets.xml', + 'views/product_views.xml', + 'views/sale_product_configurator_templates.xml', + ], +} diff --git a/sale_line_reconfigure/controllers/__init__.py b/sale_line_reconfigure/controllers/__init__.py new file mode 100644 index 00000000..3c76586e --- /dev/null +++ b/sale_line_reconfigure/controllers/__init__.py @@ -0,0 +1 @@ +from . import product_configurator diff --git a/sale_line_reconfigure/controllers/product_configurator.py b/sale_line_reconfigure/controllers/product_configurator.py new file mode 100644 index 00000000..c7622976 --- /dev/null +++ b/sale_line_reconfigure/controllers/product_configurator.py @@ -0,0 +1,32 @@ +from odoo import http, fields +from odoo.addons.sale.controllers import product_configurator +from odoo.http import request + + + +class ProductConfiguratorController(product_configurator.ProductConfiguratorController): + @http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST']) + def configure(self, product_id, pricelist_id, sale_line_id=None, **kw): + product_template = request.env['product.template'].browse(int(product_id)) + to_currency = product_template.currency_id + pricelist = self._get_pricelist(pricelist_id) + if pricelist: + product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) + to_currency = pricelist.currency_id + + sale_line = None + if sale_line_id: + sale_line = request.env['sale.order.line'].browse(int(sale_line_id)) + + return request.env['ir.ui.view'].render_template("sale.product_configurator_configure", { + 'product': product_template, + 'to_currency': to_currency, + 'pricelist': pricelist, + 'sale_line': sale_line, + 'get_attribute_exclusions': self._get_attribute_exclusions, + 'get_attribute_value_defaults': self._get_attribute_value_defaults, + 'sale_line': sale_line, + }) + + def _get_attribute_value_defaults(self, product, sale_line, **kw): + return product.get_default_attribute_values(sale_line) diff --git a/sale_line_reconfigure/models/__init__.py b/sale_line_reconfigure/models/__init__.py new file mode 100644 index 00000000..23275437 --- /dev/null +++ b/sale_line_reconfigure/models/__init__.py @@ -0,0 +1 @@ +from . import product \ No newline at end of file diff --git a/sale_line_reconfigure/models/product.py b/sale_line_reconfigure/models/product.py new file mode 100644 index 00000000..9b1a5c88 --- /dev/null +++ b/sale_line_reconfigure/models/product.py @@ -0,0 +1,47 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + def get_default_attribute_values(self, so_line): + product = None + if so_line: + product = so_line.product_id + attribute_values = self.env['product.attribute.value'].browse() + for attribute_line in self.attribute_line_ids: + attribute = attribute_line.attribute_id + attribute_line_values = attribute_line.product_template_value_ids + + # Product Values + if product: + product_values = product.attribute_value_ids.filtered(lambda v: v.attribute_id == attribute) + if product_values: + attribute_values += product_values + continue + + so_line_values = so_line.product_no_variant_attribute_value_ids.filtered( + lambda v: v.attribute_id == attribute) + if so_line_values: + attribute_values += so_line_values.mapped('product_attribute_value_id') + continue + + default_value = self.env['product.template.attribute.value'].search([ + ('product_tmpl_id', '=', self.id), + ('attribute_id', '=', attribute.id), + ('is_default', '=', True), + ], limit=1) + if default_value: + attribute_values += default_value.mapped('product_attribute_value_id') + continue + + # First value + attribute_values += attribute_line_values[0].product_attribute_value_id + + return attribute_values + + +class ProductTemplateAttributeValue(models.Model): + _inherit = 'product.template.attribute.value' + + is_default = fields.Boolean(string='Use as Default', copy=False) diff --git a/sale_line_reconfigure/static/src/js/product_configurator_controller.js b/sale_line_reconfigure/static/src/js/product_configurator_controller.js new file mode 100644 index 00000000..32f2f427 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/product_configurator_controller.js @@ -0,0 +1,198 @@ +odoo.define('sale_line_reconfigure.ProductConfiguratorFormController', function (require) { +"use strict"; + +/* +Product Configurator is very powerful, but not so easy to extend. + */ + +var core = require('web.core'); +var _t = core._t; +var FormController = require('web.FormController'); +var OptionalProductsModal = require('sale.OptionalProductsModal'); + +var ProductConfiguratorFormController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + field_changed: '_onFieldChanged' + }), + className: 'o_product_configurator', + /** + * @override + */ + init: function (){ + this._super.apply(this, arguments); + }, + /** + * We need to override the default click behavior for our "Add" button + * because there is a possibility that this product has optional products. + * If so, we need to display an extra modal to choose the options. + * + * @override + */ + _onButtonClicked: function (event) { + if (event.stopPropagation){ + event.stopPropagation(); + } + var attrs = event.data.attrs; + if (attrs.special === 'cancel') { + this._super.apply(this, arguments); + } else { + if (!this.$el + .parents('.modal') + .find('.o_sale_product_configurator_add') + .hasClass('disabled')){ + this._handleAdd(this.$el); + } + } + }, + /** + * This is overridden to allow catching the "select" event on our product template select field. + * This will not work anymore if more fields are added to the form. + * TODO awa: Find a better way to catch that event. + * + * @override + */ + _onFieldChanged: function (event) { + var self = this; + + this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled'); + + this._rpc({ + route: '/product_configurator/configure', + params: { + product_id: event.data.changes.product_template_id.id, + pricelist_id: this.renderer.pricelistId, + sale_line_id: this.renderer.saleLineId, + } + }).then(function (configurator) { + self.renderer.renderConfigurator(configurator); + }); + + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * When the user adds a product that has optional products, we need to display + * a window to allow the user to choose these extra options. + * + * This will also create the product if it's in "dynamic" mode + * (see product_attribute.create_variant) + * + * @private + * @param {$.Element} $modal + */ + _handleAdd: function ($modal) { + var self = this; + var productSelector = [ + 'input[type="hidden"][name="product_id"]', + 'input[type="radio"][name="product_id"]:checked' + ]; + + var productId = parseInt($modal.find(productSelector.join(', ')).first().val(), 10); + var productReady = this.renderer.selectOrCreateProduct( + $modal, + productId, + $modal.find('.product_template_id').val(), + false + ); + + productReady.done(function (productId){ + $modal.find(productSelector.join(', ')).val(productId); + + var variantValues = self + .renderer + .getSelectedVariantValues($modal.find('.js_product')); + + var productCustomVariantValues = self + .renderer + .getCustomVariantValues($modal.find('.js_product')); + + var noVariantAttributeValues = self + .renderer + .getNoVariantAttributeValues($modal.find('.js_product')); + + self.rootProduct = { + product_id: productId, + quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1), + variant_values: variantValues, + product_custom_attribute_values: productCustomVariantValues, + no_variant_attribute_values: noVariantAttributeValues + }; + + self.optionalProductsModal = new OptionalProductsModal($('body'), { + rootProduct: self.rootProduct, + pricelistId: self.renderer.pricelistId, + okButtonText: _t('Confirm'), + cancelButtonText: _t('Back'), + title: _t('Configure') + }).open(); + + self.optionalProductsModal.on('options_empty', null, + self._onModalOptionsEmpty.bind(self)); + + self.optionalProductsModal.on('update_quantity', null, + self._onOptionsUpdateQuantity.bind(self)); + + self.optionalProductsModal.on('confirm', null, + self._onModalConfirm.bind(self)); + }); + }, + + /** + * No optional products found for this product, only add the root product + * + * @private + */ + _onModalOptionsEmpty: function () { + this._addProducts([this.rootProduct]); + }, + + /** + * Add all selected products + * + * @private + */ + _onModalConfirm: function () { + this._addProducts(this.optionalProductsModal.getSelectedProducts()); + }, + + /** + * Update product configurator form + * when quantity is updated in the optional products window + * + * @private + * @param {integer} quantity + */ + _onOptionsUpdateQuantity: function (quantity) { + this.$el + .find('input[name="add_qty"]') + .val(quantity) + .trigger('change'); + }, + + /** + * This triggers the close action for the window and + * adds the product as the "infos" parameter. + * It will allow the caller (typically the SO line form) of this window + * to handle the added products. + * + * @private + * @param {Array} products the list of added products + * {integer} products.product_id: the id of the product + * {integer} products.quantity: the added quantity for this product + * {Array} products.product_custom_attribute_values: + * see product_configurator_mixin.getCustomVariantValues + * {Array} products.no_variant_attribute_values: + * see product_configurator_mixin.getNoVariantAttributeValues + */ + _addProducts: function (products) { + this.do_action({type: 'ir.actions.act_window_close', infos: products}); + } +}); + +return ProductConfiguratorFormController; + +}); \ No newline at end of file diff --git a/sale_line_reconfigure/static/src/js/product_configurator_renderer.js b/sale_line_reconfigure/static/src/js/product_configurator_renderer.js new file mode 100644 index 00000000..da1135a9 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/product_configurator_renderer.js @@ -0,0 +1,55 @@ +odoo.define('sale_line_reconfigure.ProductConfiguratorFormRenderer', function (require) { +"use strict"; + +var FormRenderer = require('web.FormRenderer'); +var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); + +var ProductConfiguratorFormRenderer = FormRenderer.extend(ProductConfiguratorMixin ,{ + /** + * @override + */ + init: function (){ + this._super.apply(this, arguments); + this.pricelistId = this.state.context.default_pricelist_id || 0; + + // Override + this.saleLineId = this.state.context.default_sale_line_id || 0; + // End Override + }, + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + this.$el.append($('
', {class: 'configurator_container'})); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Renders the product configurator within the form + * + * Will also: + * - add events handling for variant changes + * - trigger variant change to compute the price and other + * variant specific changes + * + * @param {string} configuratorHtml the evaluated template of + * the product configurator + */ + renderConfigurator: function (configuratorHtml) { + var $configuratorContainer = this.$('.configurator_container'); + $configuratorContainer.empty(); + + var $configuratorHtml = $(configuratorHtml); + $configuratorHtml.appendTo($configuratorContainer); + + this.triggerVariantChange($configuratorContainer); + } +}); + +return ProductConfiguratorFormRenderer; + +}); diff --git a/sale_line_reconfigure/static/src/js/product_configurator_view.js b/sale_line_reconfigure/static/src/js/product_configurator_view.js new file mode 100644 index 00000000..b3a34ea7 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/product_configurator_view.js @@ -0,0 +1,20 @@ +odoo.define('sale_line_reconfigure.ProductConfiguratorFormView', function (require) { +"use strict"; + +var ProductConfiguratorFormController = require('sale_line_reconfigure.ProductConfiguratorFormController'); +var ProductConfiguratorFormRenderer = require('sale_line_reconfigure.ProductConfiguratorFormRenderer'); +var FormView = require('web.FormView'); +var viewRegistry = require('web.view_registry'); + +var ProductConfiguratorFormView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: ProductConfiguratorFormController, + Renderer: ProductConfiguratorFormRenderer, + }), +}); + +viewRegistry.add('product_configurator_form', ProductConfiguratorFormView); + +return ProductConfiguratorFormView; + +}); \ No newline at end of file diff --git a/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js b/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js new file mode 100644 index 00000000..539c6368 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js @@ -0,0 +1,227 @@ +odoo.define('sale_line_reconfigure.section_and_note_backend', function (require) { +// The goal of this file is to contain JS hacks related to allowing +// section and note on sale order and invoice. + +// [UPDATED] now also allows configuring products on sale order. +// This is a copy job from `account` to support re-configuring a selected sale order line. + +"use strict"; +var pyUtils = require('web.py_utils'); +var core = require('web.core'); +var _t = core._t; +var FieldChar = require('web.basic_fields').FieldChar; +var FieldOne2Many = require('web.relational_fields').FieldOne2Many; +var fieldRegistry = require('web.field_registry'); +var FieldText = require('web.basic_fields').FieldText; +var ListRenderer = require('web.ListRenderer'); + +var SectionAndNoteListRenderer = ListRenderer.extend({ + /** + * We want section and note to take the whole line (except handle and trash) + * to look better and to hide the unnecessary fields. + * + * @override + */ + _renderBodyCell: function (record, node, index, options) { + var $cell = this._super.apply(this, arguments); + + var isSection = record.data.display_type === 'line_section'; + var isNote = record.data.display_type === 'line_note'; + + if (isSection || isNote) { + if (node.attrs.widget === "handle") { + return $cell; + } else if (node.attrs.name === "name") { + var nbrColumns = this._getNumberOfCols(); + if (this.handleField) { + nbrColumns--; + } + if (this.addTrashIcon) { + nbrColumns--; + } + $cell.attr('colspan', nbrColumns); + } else { + return $cell.addClass('o_hidden'); + } + } + + return $cell; + }, + /** + * We add the o_is_{display_type} class to allow custom behaviour both in JS and CSS. + * + * @override + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + + if (record.data.display_type) { + $row.addClass('o_is_' + record.data.display_type); + } + + return $row; + }, + /** + * We want to add .o_section_and_note_list_view on the table to have stronger CSS. + * + * @override + * @private + */ + _renderView: function () { + var def = this._super(); + this.$el.find('> table').addClass('o_section_and_note_list_view'); + return def; + }, + /** + * Add support for product configurator + * + * @override + * @private + */ + _onAddRecord: function (ev) { + // we don't want the browser to navigate to a the # url + ev.preventDefault(); + + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + var lineId = null; + if (this.currentRow !== null && this.state.data[this.currentRow].res_id) { + lineId = this.state.data[this.currentRow].res_id; + } + + // but we do want to unselect current row + var self = this; + this.unselectRow().then(function () { + var context = ev.currentTarget.dataset.context; + + var pricelistId = self._getPricelistId(); + if (context && pyUtils.py_eval(context).open_product_configurator){ + self._rpc({ + model: 'ir.model.data', + method: 'xmlid_to_res_id', + kwargs: {xmlid: 'sale.sale_product_configurator_view_form'}, + }).then(function (res_id) { + self.do_action({ + name: _t('Configure a product'), + type: 'ir.actions.act_window', + res_model: 'sale.product.configurator', + views: [[res_id, 'form']], + target: 'new', + context: { + 'default_pricelist_id': pricelistId, + 'default_sale_line_id': lineId, + } + }, { + on_close: function (products) { + if (products && products !== 'special'){ + self.trigger_up('add_record', { + context: self._productsToRecords(products), + forceEditable: "bottom" , + allowWarning: true, + onSuccess: function (){ + self.unselectRow(); + } + }); + } + } + }); + }); + } else { + self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered + } + }); + }, + + /** + * Will try to get the pricelist_id value from the parent sale_order form + * + * @private + * @returns {integer} pricelist_id's id + */ + _getPricelistId: function () { + var saleOrderForm = this.getParent() && this.getParent().getParent(); + var stateData = saleOrderForm && saleOrderForm.state && saleOrderForm.state.data; + var pricelist_id = stateData.pricelist_id && stateData.pricelist_id.data && stateData.pricelist_id.data.id; + + return pricelist_id; + }, + + /** + * Will map the products to appropriate record objects that are + * ready for the default_get + * + * @private + * @param {Array} products The products to transform into records + */ + _productsToRecords: function (products) { + var records = []; + _.each(products, function (product){ + var record = { + default_product_id: product.product_id, + default_product_uom_qty: product.quantity + }; + + if (product.no_variant_attribute_values) { + var default_product_no_variant_attribute_values = []; + _.each(product.no_variant_attribute_values, function (attribute_value) { + default_product_no_variant_attribute_values.push( + [4, parseInt(attribute_value.value)] + ); + }); + record['default_product_no_variant_attribute_value_ids'] + = default_product_no_variant_attribute_values; + } + + if (product.product_custom_attribute_values) { + var default_custom_attribute_values = []; + _.each(product.product_custom_attribute_values, function (attribute_value) { + default_custom_attribute_values.push( + [0, 0, { + attribute_value_id: attribute_value.attribute_value_id, + custom_value: attribute_value.custom_value + }] + ); + }); + record['default_product_custom_attribute_value_ids'] + = default_custom_attribute_values; + } + + records.push(record); + }); + + return records; + } +}); + +// We create a custom widget because this is the cleanest way to do it: +// to be sure this custom code will only impact selected fields having the widget +// and not applied to any other existing ListRenderer. +var SectionAndNoteFieldOne2Many = FieldOne2Many.extend({ + /** + * We want to use our custom renderer for the list. + * + * @override + */ + _getRenderer: function () { + if (this.view.arch.tag === 'tree') { + return SectionAndNoteListRenderer; + } + return this._super.apply(this, arguments); + }, +}); + +// This is a merge between a FieldText and a FieldChar. +// We want a FieldChar for section, +// and a FieldText for the rest (product and note). +var SectionAndNoteFieldText = function (parent, name, record, options) { + var isSection = record.data.display_type === 'line_section'; + var Constructor = isSection ? FieldChar : FieldText; + return new Constructor(parent, name, record, options); +}; + +fieldRegistry.add('section_and_note_one2many', SectionAndNoteFieldOne2Many); +fieldRegistry.add('section_and_note_text', SectionAndNoteFieldText); + +}); diff --git a/sale_line_reconfigure/views/assets.xml b/sale_line_reconfigure/views/assets.xml new file mode 100644 index 00000000..dc682f44 --- /dev/null +++ b/sale_line_reconfigure/views/assets.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/sale_line_reconfigure/views/product_views.xml b/sale_line_reconfigure/views/product_views.xml new file mode 100644 index 00000000..ccf2c11d --- /dev/null +++ b/sale_line_reconfigure/views/product_views.xml @@ -0,0 +1,24 @@ + + + + product.template.attribute.value.view.tree.inherit + product.template.attribute.value + + + + + + + + + + product.template.attribute.value.view.form.inherit + product.template.attribute.value + + + + + + + + \ No newline at end of file diff --git a/sale_line_reconfigure/views/sale_product_configurator_templates.xml b/sale_line_reconfigure/views/sale_product_configurator_templates.xml new file mode 100644 index 00000000..184610aa --- /dev/null +++ b/sale_line_reconfigure/views/sale_product_configurator_templates.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file