Merge branch 'imp/14.0/delivery_hibou__per_package_carrier' into '14.0'

imp/14.0/delivery_hibou__per_package_carrier into 14.0

See merge request hibou-io/hibou-odoo/suite!1047
This commit is contained in:
Jared Kipe
2021-09-21 00:53:24 +00:00
27 changed files with 454 additions and 84 deletions

View File

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

View File

@@ -1,9 +1,9 @@
{ {
'name': 'Hibou Fedex Shipping', 'name': 'Hibou Fedex Shipping',
'version': '14.0.1.0.0', 'version': '14.0.1.1.0',
'category': 'Stock', 'category': 'Stock',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'license': 'AGPL-3', 'license': 'OPL-1',
'website': 'https://hibou.io/', 'website': 'https://hibou.io/',
'depends': [ 'depends': [
'delivery_fedex', 'delivery_fedex',

View File

@@ -1,2 +1,4 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import delivery_fedex from . import delivery_fedex
from . import stock from . import stock

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
import logging import logging
import pytz import pytz
from odoo import fields, models, tools, _ from odoo import fields, models, tools, _
@@ -223,6 +225,15 @@ class DeliveryFedex(models.Model):
srm = FedexRequest(self.log_xml, request_type="shipping", prod_environment=self.prod_environment) srm = FedexRequest(self.log_xml, request_type="shipping", prod_environment=self.prod_environment)
superself = self.sudo() superself = self.sudo()
picking_packages = picking.package_ids
package_carriers = picking_packages.mapped('carrier_id')
if package_carriers:
# only ship ours
picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref)
if package_carriers and not picking_packages:
continue
shipper_company = superself.get_shipper_company(picking=picking) shipper_company = superself.get_shipper_company(picking=picking)
shipper_warehouse = superself.get_shipper_warehouse(picking=picking) shipper_warehouse = superself.get_shipper_warehouse(picking=picking)
recipient = superself.get_recipient(picking=picking) recipient = superself.get_recipient(picking=picking)
@@ -239,7 +250,7 @@ class DeliveryFedex(models.Model):
srm.transaction_detail(picking.id) srm.transaction_detail(picking.id)
package_type = picking.package_ids and picking.package_ids[0].packaging_id.shipper_package_code or self.fedex_default_packaging_id.shipper_package_code package_type = picking_packages and picking_packages[0].packaging_id.shipper_package_code or self.fedex_default_packaging_id.shipper_package_code
srm.shipment_request(self.fedex_droppoff_type, self.fedex_service_type, package_type, self.fedex_weight_unit, self.fedex_saturday_delivery) srm.shipment_request(self.fedex_droppoff_type, self.fedex_service_type, package_type, self.fedex_weight_unit, self.fedex_saturday_delivery)
srm.set_currency(_convert_curr_iso_fdx(picking.company_id.currency_id.name)) srm.set_currency(_convert_curr_iso_fdx(picking.company_id.currency_id.name))
srm.set_shipper(shipper_company, shipper_warehouse) srm.set_shipper(shipper_company, shipper_warehouse)
@@ -278,7 +289,7 @@ class DeliveryFedex(models.Model):
send_etd = superself.env['ir.config_parameter'].get_param("delivery_fedex.send_etd") send_etd = superself.env['ir.config_parameter'].get_param("delivery_fedex.send_etd")
srm.commercial_invoice(self.fedex_document_stock_type, send_etd) srm.commercial_invoice(self.fedex_document_stock_type, send_etd)
package_count = len(picking.package_ids) or 1 package_count = len(picking_packages) or 1
# For india picking courier is not accepted without this details in label. # For india picking courier is not accepted without this details in label.
po_number = order.display_name or False po_number = order.display_name or False
@@ -307,16 +318,17 @@ class DeliveryFedex(models.Model):
package_labels = [] package_labels = []
carrier_tracking_ref = "" carrier_tracking_ref = ""
for sequence, package in enumerate(picking.package_ids, start=1): for sequence, package in enumerate(picking_packages, start=1):
package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit) package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit)
packaging = package.packaging_id packaging = package.packaging_id
packaging_code = packaging.shipper_package_code if (packaging.package_carrier_type == 'fedex' and packaging.shipper_package_code) else self.fedex_default_packaging_id.shipper_package_code
# Hibou Delivery # Hibou Delivery
# Add more details to package. # Add more details to package.
srm._add_package( srm._add_package(
package_weight, package_weight,
package_code=packaging.shipper_package_code, package_code=packaging_code,
package_height=packaging.height, package_height=packaging.height,
package_width=packaging.width, package_width=packaging.width,
package_length=packaging.packaging_length, package_length=packaging.packaging_length,
@@ -394,10 +406,11 @@ class DeliveryFedex(models.Model):
# One package # # One package #
############### ###############
elif package_count == 1: elif package_count == 1:
packaging = picking.package_ids[:1].packaging_id or picking.carrier_id.fedex_default_packaging_id packaging = picking_packages[:1].packaging_id or self.fedex_default_packaging_id
packaging_code = packaging.shipper_package_code if packaging.package_carrier_type == 'fedex' else self.fedex_default_packaging_id.shipper_package_code
srm._add_package( srm._add_package(
net_weight, net_weight,
package_code=packaging.shipper_package_code, package_code=packaging_code,
package_height=packaging.height, package_height=packaging.height,
package_width=packaging.width, package_width=packaging.width,
package_length=packaging.packaging_length, package_length=packaging.packaging_length,
@@ -457,21 +470,33 @@ class DeliveryFedex(models.Model):
picking.message_post(body='Fedex Documents', attachments=fedex_documents) picking.message_post(body='Fedex Documents', attachments=fedex_documents)
return res return res
def fedex_rate_shipment_multi(self, order=None, picking=None): def fedex_rate_shipment_multi(self, order=None, picking=None, packages=None):
if not packages:
return self._fedex_rate_shipment_multi_package(order=order, picking=picking)
else:
rates = []
for package in packages:
rates += self._fedex_rate_shipment_multi_package(order=order, picking=picking, package=package)
return rates
def _fedex_rate_shipment_multi_package(self, order=None, picking=None, package=None):
if order: if order:
max_weight = self._fedex_convert_weight(self.fedex_default_packaging_id.max_weight, self.fedex_weight_unit) max_weight = self._fedex_convert_weight(self.fedex_default_packaging_id.max_weight, self.fedex_weight_unit)
is_india = order.partner_shipping_id.country_id.code == 'IN' and order.company_id.partner_id.country_id.code == 'IN' is_india = order.partner_shipping_id.country_id.code == 'IN' and order.company_id.partner_id.country_id.code == 'IN'
est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0 est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0
weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit) weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit)
order_currency = order.currency_id order_currency = order.currency_id
else: elif not package:
# max_weight = self._fedex_convert_weight(self.fedex_default_packaging_id.max_weight, self.fedex_weight_unit)
is_india = picking.partner_id.country_id.code == 'IN' and picking.company_id.partner_id.country_id.code == 'IN' is_india = picking.partner_id.country_id.code == 'IN' and picking.company_id.partner_id.country_id.code == 'IN'
# TODO must be per-package eventually
# theoretically just sum of all packages weights, but the rating itself will also need to change...
est_weight_value = sum([(line.product_id.weight * (line.qty_done or line.product_uom_qty)) for line in picking.move_line_ids]) or 0.0 est_weight_value = sum([(line.product_id.weight * (line.qty_done or line.product_uom_qty)) for line in picking.move_line_ids]) or 0.0
weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit) weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit)
order_currency = picking.sale_id.currency_id if picking.sale_id else picking.company_id.currency_id order_currency = picking.sale_id.currency_id if picking.sale_id else picking.company_id.currency_id
else:
is_india = picking.partner_id.country_id.code == 'IN' and picking.company_id.partner_id.country_id.code == 'IN'
order_currency = picking.sale_id.currency_id if picking.sale_id else picking.company_id.currency_id
est_weight_value = package.shipping_weight or package.weight
weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit)
price = 0.0 price = 0.0
@@ -558,24 +583,24 @@ class DeliveryFedex(models.Model):
) )
srm.set_master_package(weight_value, 1) srm.set_master_package(weight_value, 1)
else: else:
if picking.package_ids: if package:
for sequence, package in enumerate(picking.package_ids, start=1): package_weight = self._fedex_convert_weight(package.shipping_weight or package.weight, self.fedex_weight_unit)
package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit) packaging = package.packaging_id
packaging = package.packaging_id package_code = package.packaging_id.shipper_package_code if packaging.package_carrier_type == 'fedex' else self.fedex_default_packaging_id.shipper_package_code
srm.add_package( srm.add_package(
package_weight, package_weight,
mode='rating', mode='rating',
package_code=packaging.shipper_package_code, package_code=package_code,
package_height=packaging.height, package_height=packaging.height,
package_width=packaging.width, package_width=packaging.width,
package_length=packaging.packaging_length, package_length=packaging.packaging_length,
sequence_number=sequence, sequence_number=1,
# po_number=po_number, # po_number=po_number,
# dept_number=dept_number, # dept_number=dept_number,
reference=('%s-%d' % (order_name, sequence)), reference=('%s-%d' % (order_name, 1)),
insurance=insurance_value insurance=insurance_value
) )
else: else:
# deliver all together... # deliver all together...
package_weight = self._fedex_convert_weight(picking.shipping_weight or picking.weight, self.fedex_weight_unit) package_weight = self._fedex_convert_weight(picking.shipping_weight or picking.weight, self.fedex_weight_unit)
@@ -662,6 +687,7 @@ class DeliveryFedex(models.Model):
tz = pytz.timezone(self.delivery_calendar_id.tz) tz = pytz.timezone(self.delivery_calendar_id.tz)
date_delivered = tz.localize(date_delivered).astimezone(pytz.utc).replace(tzinfo=None) date_delivered = tz.localize(date_delivered).astimezone(pytz.utc).replace(tzinfo=None)
result.append({'carrier': carrier, result.append({'carrier': carrier,
'package': package or self.env['stock.quant.package'].browse(),
'success': True, 'success': True,
'price': price, 'price': price,
'error_message': False, 'error_message': False,

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from zeep.exceptions import Fault from zeep.exceptions import Fault
from datetime import datetime from datetime import datetime
from copy import deepcopy from copy import deepcopy

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
{ {
'name': 'Golden State Overnight (gso.com) Shipping', 'name': 'Golden State Overnight (gso.com) Shipping',
'summary': 'Send your shippings through gso.com and track them online.', 'summary': 'Send your shippings through gso.com and track them online.',
'version': '14.0.1.0.0', 'version': '14.0.1.1.0',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'category': 'Warehouse', 'category': 'Warehouse',
'license': 'AGPL-3', 'license': 'OPL-1',
'images': [], 'images': [],
'website': "https://hibou.io", 'website': "https://hibou.io",
'description': """ 'description': """

View File

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

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
import pytz import pytz
from math import ceil from math import ceil
from base64 import b64decode from base64 import b64decode
@@ -198,9 +200,15 @@ class ProviderGSO(models.Model):
'thermal': [], 'thermal': [],
'paper': [], 'paper': [],
} }
if picking.package_ids: picking_packages = picking.package_ids
package_carriers = picking_packages.mapped('carrier_id')
if package_carriers:
# only ship ours
picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref)
if picking_packages:
# Every package will be a transaction # Every package will be a transaction
for package in picking.package_ids: for package in picking_packages:
request_body['Shipment']['Weight'] = self._gso_convert_weight(package.shipping_weight) request_body['Shipment']['Weight'] = self._gso_convert_weight(package.shipping_weight)
request_body['Shipment'].update(self._gso_get_package_dimensions(package)) request_body['Shipment'].update(self._gso_get_package_dimensions(package))
request_body['Shipment']['ShipmentReference'] = package.name request_body['Shipment']['ShipmentReference'] = package.name
@@ -217,7 +225,8 @@ class ProviderGSO(models.Model):
cost += response['ShipmentCharges']['TotalCharge'] cost += response['ShipmentCharges']['TotalCharge']
except HTTPError as e: except HTTPError as e:
raise ValidationError(e) raise ValidationError(e)
else: elif not package_carriers:
# ship the whole picking
request_body['Shipment']['Weight'] = self._gso_convert_weight(picking.shipping_weight) request_body['Shipment']['Weight'] = self._gso_convert_weight(picking.shipping_weight)
request_body['Shipment'].update(self._gso_get_package_dimensions()) request_body['Shipment'].update(self._gso_get_package_dimensions())
request_body['Shipment']['ShipmentReference'] = picking.name request_body['Shipment']['ShipmentReference'] = picking.name
@@ -234,6 +243,8 @@ class ProviderGSO(models.Model):
cost += response['ShipmentCharges']['TotalCharge'] cost += response['ShipmentCharges']['TotalCharge']
except HTTPError as e: except HTTPError as e:
raise ValidationError(e) raise ValidationError(e)
else:
continue
# Handle results # Handle results
trackings = [l[0] for l in labels['thermal']] + [l[0] for l in labels['paper']] trackings = [l[0] for l in labels['thermal']] + [l[0] for l in labels['paper']]
@@ -331,18 +342,32 @@ class ProviderGSO(models.Model):
res.append('https://www.gso.com/Tracking') res.append('https://www.gso.com/Tracking')
return res return res
def gso_rate_shipment_multi(self, order=None, picking=None): def gso_rate_shipment_multi(self, order=None, picking=None, packages=None):
if not packages:
return self._gso_rate_shipment_multi_package(order=order, picking=picking)
else:
rates = []
for package in packages:
rates += self._gso_rate_shipment_multi_package(order=order, picking=picking, package=package)
return rates
def _gso_rate_shipment_multi_package(self, order=None, picking=None, package=None):
sudoself = self.sudo() sudoself = self.sudo()
service = sudoself._get_gso_service() try:
service = sudoself._get_gso_service()
except HTTPError as e:
_logger.error(e)
return [{
'success': False,
'price': 0.0,
'error_message': _('GSO web service returned an error. ' + str(e)),
'warning_message': False,
}]
from_ = sudoself.get_shipper_warehouse(order=order, picking=picking) from_ = sudoself.get_shipper_warehouse(order=order, picking=picking)
to = sudoself.get_recipient(order=order, picking=picking) to = sudoself.get_recipient(order=order, picking=picking)
address_type = 'B' if bool(to.is_company or to.parent_id.is_company) else 'R' address_type = 'B' if bool(to.is_company or to.parent_id.is_company) else 'R'
package_dimensions = self._gso_get_package_dimensions(package=package)
if order:
est_weight_value = self._gso_convert_weight(
sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0)
else:
est_weight_value = self._gso_convert_weight(picking.shipping_weight)
date_planned = fields.Datetime.now() date_planned = fields.Datetime.now()
if self.env.context.get('date_planned'): if self.env.context.get('date_planned'):
@@ -353,10 +378,13 @@ class ProviderGSO(models.Model):
ship_date_gso = ship_date_utc.astimezone(pytz.timezone(GSO_TZ)) ship_date_gso = ship_date_utc.astimezone(pytz.timezone(GSO_TZ))
ship_date_gso = fields.Datetime.to_string(ship_date_gso) ship_date_gso = fields.Datetime.to_string(ship_date_gso)
if picking and picking.package_ids: if order:
package_dimensions = self._gso_get_package_dimensions(package=picking.package_ids[0]) est_weight_value = self._gso_convert_weight(
sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0)
elif not package:
est_weight_value = self._gso_convert_weight(picking.shipping_weight)
else: else:
package_dimensions = self._gso_get_package_dimensions() est_weight_value = package.shipping_weight or package.weight
request_body = { request_body = {
'AccountNumber': sudoself.gso_account_number, 'AccountNumber': sudoself.gso_account_number,
@@ -395,6 +423,7 @@ class ProviderGSO(models.Model):
if carrier: if carrier:
rates.append({ rates.append({
'carrier': carrier, 'carrier': carrier,
'package': package or self.env['stock.quant.package'].browse(),
'success': True, 'success': True,
'price': price, 'price': price,
'error_message': False, 'error_message': False,

View File

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

View File

@@ -1,7 +1,7 @@
{ {
'name': 'Delivery Hibou', 'name': 'Delivery Hibou',
'summary': 'Adds underlying pinnings for things like "RMA Return Labels"', 'summary': 'Adds underlying pinnings for things like "RMA Return Labels"',
'version': '14.0.1.0.0', 'version': '14.0.1.1.0',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'category': 'Stock', 'category': 'Stock',
'license': 'AGPL-3', 'license': 'AGPL-3',

View File

@@ -1,5 +1,6 @@
from odoo import fields, models from odoo import fields, models
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
from odoo.exceptions import UserError
class DeliveryCarrier(models.Model): class DeliveryCarrier(models.Model):
@@ -161,11 +162,12 @@ class DeliveryCarrier(models.Model):
# -------------------------- # # -------------------------- #
# API for external providers # # API for external providers #
# -------------------------- # # -------------------------- #
def rate_shipment_multi(self, order=None, picking=None): def rate_shipment_multi(self, order=None, picking=None, packages=None):
''' Compute the price of the order shipment ''' Compute the price of the order shipment
:param order: record of sale.order or None :param order: record of sale.order or None
:param picking: record of stock.picking or None :param picking: record of stock.picking or None
:param packages: recordset of stock.quant.package or None (requires picking also set)
:return list: dict: { :return list: dict: {
'carrier': delivery.carrier(), 'carrier': delivery.carrier(),
'success': boolean, 'success': boolean,
@@ -176,6 +178,7 @@ class DeliveryCarrier(models.Model):
'date_delivered': a datetime for when the shipment is supposed to arrive, 'date_delivered': a datetime for when the shipment is supposed to arrive,
'transit_days': a Float for how many days it takes in transit, 'transit_days': a Float for how many days it takes in transit,
'service_code': a string that represents the service level/agreement, 'service_code': a string that represents the service level/agreement,
'package': stock.quant.package(),
} }
e.g. self == delivery.carrier(5, 6) e.g. self == delivery.carrier(5, 6)
@@ -190,12 +193,53 @@ class DeliveryCarrier(models.Model):
if picking: if picking:
self = self.with_context(date_planned=fields.Datetime.now()) self = self.with_context(date_planned=fields.Datetime.now())
if not packages:
packages = picking.package_ids
else: else:
if packages:
raise UserError('Cannot rate package without picking.')
self = self.with_context(date_planned=(order.date_planned or fields.Datetime.now())) self = self.with_context(date_planned=(order.date_planned or fields.Datetime.now()))
res = [] res = []
for carrier in self: for carrier in self:
carrier_packages = packages.filtered(lambda p: not p.carrier_tracking_ref and
(not p.carrier_id or p.carrier_id == carrier) and
p.packaging_id.package_carrier_type in (False, '', 'none', carrier.delivery_type))
if packages and not carrier_packages:
continue
if hasattr(carrier, '%s_rate_shipment_multi' % self.delivery_type): if hasattr(carrier, '%s_rate_shipment_multi' % self.delivery_type):
carrier_rates = getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order, picking=picking) try:
res += carrier_rates res += getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order,
picking=picking,
packages=carrier_packages)
except TypeError:
# TODO remove catch if after Odoo 14
# This is intended to find ones that don't support packages= kwarg
res += getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order,
picking=picking)
return res return res
def cancel_shipment(self, pickings, packages=None):
''' Cancel a shipment
:param pickings: A recordset of pickings
:param packages: Optional recordset of packages (should be for this carrier)
'''
self.ensure_one()
if hasattr(self, '%s_cancel_shipment' % self.delivery_type):
# No good way to tell if this method takes the kwarg for packages
if packages:
try:
return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings, packages=packages)
except TypeError:
# we won't be able to cancel the packages properly
# here we will TRY to make a good call here where we put the package references into the picking
# and let the original mechanisms try to work here
tracking_ref = ','.join(packages.mapped('carrier_tracking_ref'))
pickings.write({
'carrier_id': self.id,
'carrier_tracking_ref': tracking_ref,
})
return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings)

View File

@@ -1,4 +1,27 @@
from odoo import api, fields, models from odoo import api, fields, models, _
from odoo.exceptions import UserError
class StockQuantPackage(models.Model):
_inherit = 'stock.quant.package'
carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
carrier_tracking_ref = fields.Char(string='Tracking Reference')
def _get_active_picking(self):
picking_id = self._context.get('active_id')
picking_model = self._context.get('active_model')
if not picking_id or picking_model != 'stock.picking':
raise UserError('Cannot cancel package other than through shipment/picking.')
return self.env['stock.picking'].browse(picking_id)
def send_to_shipper(self):
picking = self._get_active_picking()
picking.send_to_shipper(packages=self)
def cancel_shipment(self):
picking = self._get_active_picking()
picking.cancel_shipment(packages=self)
class StockPicking(models.Model): class StockPicking(models.Model):
@@ -11,6 +34,16 @@ class StockPicking(models.Model):
('no', 'No'), ('no', 'No'),
], string='Require Insurance', default='auto', ], string='Require Insurance', default='auto',
help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.') help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.')
package_carrier_tracking_ref = fields.Char(string='Package Tracking Numbers', compute='_compute_package_carrier_tracking_ref')
@api.depends('package_ids.carrier_tracking_ref')
def _compute_package_carrier_tracking_ref(self):
for picking in self:
package_refs = picking.package_ids.filtered('carrier_tracking_ref').mapped('carrier_tracking_ref')
if package_refs:
picking.package_carrier_tracking_ref = ','.join(package_refs)
else:
picking.package_carrier_tracking_ref = False
@api.onchange('carrier_id') @api.onchange('carrier_id')
def _onchange_carrier_id_for_priority(self): def _onchange_carrier_id_for_priority(self):
@@ -41,3 +74,86 @@ class StockPicking(models.Model):
# Assume Full Value # Assume Full Value
cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0]) cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0])
return cost return cost
def clear_carrier_tracking_ref(self):
self.write({'carrier_tracking_ref': False})
def reset_carrier_tracking_ref(self):
for picking in self:
picking.carrier_tracking_ref = picking.package_carrier_tracking_ref
# Override to send to specific packaging carriers
def send_to_shipper(self, packages=None):
self.ensure_one()
if not packages:
packages = self.package_ids
package_carriers = packages.mapped('carrier_id')
if not package_carriers:
# Original behavior
return super().send_to_shipper()
tracking_numbers = []
carrier_prices = []
order_currency = self.sale_id.currency_id or self.company_id.currency_id
for carrier in package_carriers:
self.carrier_id = carrier
carrier_packages = packages.filtered(lambda p: p.carrier_id == carrier)
res = carrier.send_shipping(self)
if res:
res = res[0]
if carrier.free_over and self.sale_id and self.sale_id._compute_amount_total_without_delivery() >= carrier.amount:
res['exact_price'] = 0.0
carrier_price = res['exact_price'] * (1.0 + (self.carrier_id.margin / 100.0))
carrier_prices.append(carrier_price)
tracking_number = ''
if res['tracking_number']:
tracking_number = res['tracking_number']
tracking_numbers.append(tracking_number)
# Try to add tracking to the individual packages.
potential_tracking_numbers = tracking_number.split(',')
if len(potential_tracking_numbers) >= len(carrier_packages):
for t, p in zip(potential_tracking_numbers, carrier_packages):
p.carrier_tracking_ref = t
else:
carrier_packages.write({'carrier_tracking_ref': tracking_number})
msg = _(
"Shipment sent to carrier %(carrier_name)s for shipping with tracking number %(ref)s<br/>Cost: %(price).2f %(currency)s",
carrier_name=carrier.name,
ref=tracking_number,
price=carrier_price,
currency=order_currency.name
)
self.message_post(body=msg)
self.carrier_price = sum(carrier_prices or [0.0])
self.carrier_tracking_ref = ','.join(tracking_numbers or [''])
self._add_delivery_cost_to_so()
# Override to provide per-package versions...
def cancel_shipment(self, packages=None):
pickings_with_package_tracking = self.filtered(lambda p: p.package_carrier_tracking_ref)
for picking in pickings_with_package_tracking:
if packages:
current_packages = packages
else:
current_packages = picking.package_ids
# Packages without a carrier can just be cleared
packages_without_carrier = current_packages.filtered(lambda p: not p.carrier_id and p.carrier_tracking_ref)
packages_without_carrier.write({
'carrier_tracking_ref': False,
})
# Packages with carrier can use the carrier method
packages_with_carrier = current_packages.filtered(lambda p: p.carrier_id and p.carrier_tracking_ref)
carriers = packages_with_carrier.mapped('carrier_id')
for carrier in carriers:
carrier_packages = packages_with_carrier.filtered(lambda p: p.carrier_id == carrier)
carrier.cancel_shipment(self, packages=carrier_packages)
package_refs = ','.join(carrier_packages.mapped('carrier_tracking_ref'))
msg = "Shipment %s cancelled" % package_refs
picking.message_post(body=msg)
carrier_packages.write({'carrier_tracking_ref': False})
pickings_without_package_tracking = self - pickings_with_package_tracking
if pickings_without_package_tracking:
# use original on these
super(StockPicking, pickings_without_package_tracking).cancel_shipment()

View File

@@ -11,4 +11,16 @@
</xpath> </xpath>
</field> </field>
</record> </record>
<record id="choose_delivery_package_view_form" model="ir.ui.view">
<field name="name">hibou.choose.delivery.package.form</field>
<field name="model">choose.delivery.package</field>
<field name="inherit_id" ref="delivery.choose_delivery_package_view_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='delivery_packaging_id']" position="attributes">
<attribute name="domain">[('package_carrier_type', '!=', False)]</attribute>
</xpath>
</field>
</record>
</odoo> </odoo>

View File

@@ -1,14 +1,46 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<record id="hibou_view_quant_package_form" model="ir.ui.view">
<field name="name">hibou.stock.quant.package.form</field>
<field name="model">stock.quant.package</field>
<field name="inherit_id" ref="stock.view_quant_package_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='location_id']" position="after">
<label for="carrier_id"/>
<div name="carrier">
<field name="carrier_id" class="oe_inline"/>
<button type="object" class="fa fa-arrow-right oe_link" name="send_to_shipper" string="Ship" attrs="{'invisible':['|',('carrier_tracking_ref','!=',False),('carrier_id','=', False)]}"/>
</div>
<label for="carrier_tracking_ref"/>
<div name="tracking">
<field name="carrier_tracking_ref" class="oe_inline" />
<button type="object" class="fa fa-arrow-right oe_link" name="cancel_shipment" string="Cancel" attrs="{'invisible':['|',('carrier_tracking_ref','=',False),('carrier_id','=', False)]}"/>
</div>
</xpath>
</field>
</record>
<record id="view_picking_withcarrier_out_form" model="ir.ui.view"> <record id="view_picking_withcarrier_out_form" model="ir.ui.view">
<field name="name">hibou.delivery.stock.picking_withcarrier.form.view</field> <field name="name">hibou.delivery.stock.picking_withcarrier.form.view</field>
<field name="model">stock.picking</field> <field name="model">stock.picking</field>
<field name="inherit_id" ref="delivery.view_picking_withcarrier_out_form" /> <field name="inherit_id" ref="delivery.view_picking_withcarrier_out_form" />
<field name="priority" eval="200" />
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='carrier_id']" position="before"> <xpath expr="//field[@name='carrier_id']" position="before">
<field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/> <field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/> <field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<field name="package_carrier_tracking_ref" attrs="{'invisible': [('package_carrier_tracking_ref', '=', False)]}" />
<button name="clear_carrier_tracking_ref" type="object" string="Clear Tracking" attrs="{'invisible': [('carrier_tracking_ref', '!=', False)]}" />
<button name="reset_carrier_tracking_ref" type="object" string="Reset Tracking" attrs="{'invisible': [('package_carrier_tracking_ref', '!=', False)]}" />
<field name="package_ids" attrs="{'invisible': [('package_carrier_tracking_ref', '=', False)]}" context="{'active_id': id, 'active_model': 'stock.picking'}" nolabel="1" colspan="2">
<tree>
<field name="name" />
<field name="carrier_id" />
<field name="carrier_tracking_ref" />
<button type="object" name="cancel_shipment" string="Cancel" />
</tree>
</field>
</xpath> </xpath>
</field> </field>
</record> </record>

View File

@@ -3,7 +3,7 @@
{ {
'name': 'Stamps.com (USPS) Shipping', 'name': 'Stamps.com (USPS) Shipping',
'summary': 'Send your shippings through Stamps.com and track them online.', 'summary': 'Send your shippings through Stamps.com and track them online.',
'version': '14.0.1.0.0', 'version': '14.0.1.1.0',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'category': 'Warehouse', 'category': 'Warehouse',
'license': 'OPL-1', 'license': 'OPL-1',

View File

@@ -147,11 +147,13 @@ class ProviderStamps(models.Model):
ret_val.ContentType = self._stamps_content_type() ret_val.ContentType = self._stamps_content_type()
return ret_val return ret_val
def _get_stamps_shipping_multi(self, service, date_planned, order=False, picking=False): def _get_stamps_shipping_multi(self, service, date_planned, order=False, picking=False, package=False):
if order: if order:
weight = sum([(line.product_id.weight * line.product_qty) for line in order.order_line]) or 0.0 weight = sum([(line.product_id.weight * line.product_qty) for line in order.order_line]) or 0.0
else: elif not package:
weight = picking.shipping_weight weight = picking.shipping_weight
else:
weight = package.shipping_weight or package.weight
weight = self._stamps_convert_weight(weight) weight = self._stamps_convert_weight(weight)
shipper = self.get_shipper_warehouse(order=order, picking=picking) shipper = self.get_shipper_warehouse(order=order, picking=picking)
@@ -164,7 +166,7 @@ class ProviderStamps(models.Model):
ret_val.ShipDate = date_planned.strftime('%Y-%m-%d') if date_planned else date.today().isoformat() ret_val.ShipDate = date_planned.strftime('%Y-%m-%d') if date_planned else date.today().isoformat()
ret_val.From = self._stamps_address(service, shipper) ret_val.From = self._stamps_address(service, shipper)
ret_val.To = self._stamps_address(service, recipient) ret_val.To = self._stamps_address(service, recipient)
ret_val.PackageType = self._stamps_package_type() ret_val.PackageType = self._stamps_package_type(package=package)
ret_val.WeightLb = weight ret_val.WeightLb = weight
ret_val.ContentType = 'Merchandise' ret_val.ContentType = 'Merchandise'
return ret_val return ret_val
@@ -206,7 +208,13 @@ class ProviderStamps(models.Model):
if not all((from_partner.zip, to_partner.zip)): if not all((from_partner.zip, to_partner.zip)):
raise ValidationError('Stamps needs ZIP/PostalCode. From: ' + str(from_partner.zip) + ' To: ' + str(to_partner.zip)) raise ValidationError('Stamps needs ZIP/PostalCode. From: ' + str(from_partner.zip) + ' To: ' + str(to_partner.zip))
for package in picking.package_ids: picking_packages = picking.package_ids
package_carriers = picking_packages.mapped('carrier_id')
if package_carriers:
# only ship ours
picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref)
for package in picking_packages:
weight = self._stamps_convert_weight(package.shipping_weight) weight = self._stamps_convert_weight(package.shipping_weight)
l, w, h = self._stamps_package_dimensions(package=package) l, w, h = self._stamps_package_dimensions(package=package)
@@ -223,7 +231,7 @@ class ProviderStamps(models.Model):
ret_val.WeightLb = weight ret_val.WeightLb = weight
ret_val.ContentType = self._stamps_content_type(package=package) ret_val.ContentType = self._stamps_content_type(package=package)
ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb) + self._stamps_hash_partner(to_partner), ret_val)) ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb) + self._stamps_hash_partner(to_partner), ret_val))
if not ret: if not ret and not package_carriers:
weight = self._stamps_convert_weight(picking.shipping_weight) weight = self._stamps_convert_weight(picking.shipping_weight)
l, w, h = self._stamps_package_dimensions() l, w, h = self._stamps_package_dimensions()
@@ -312,6 +320,8 @@ class ProviderStamps(models.Model):
package_labels = [] package_labels = []
shippings = self._stamps_get_shippings_for_picking(service, picking) shippings = self._stamps_get_shippings_for_picking(service, picking)
if not shippings:
continue
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking) company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
customs = None customs = None
@@ -453,14 +463,23 @@ class ProviderStamps(models.Model):
except WebFault as e: except WebFault as e:
raise ValidationError(e) raise ValidationError(e)
def stamps_rate_shipment_multi(self, order=None, picking=None): def stamps_rate_shipment_multi(self, order=None, picking=None, packages=None):
if not packages:
return self._stamps_rate_shipment_multi_package(order=order, picking=picking)
else:
rates = []
for package in packages:
rates += self._stamps_rate_shipment_multi_package(order=order, picking=picking, package=package)
return rates
def _stamps_rate_shipment_multi_package(self, order=None, picking=None, package=None):
self.ensure_one() self.ensure_one()
date_planned = fields.Datetime.now() date_planned = fields.Datetime.now()
if self.env.context.get('date_planned'): if self.env.context.get('date_planned'):
date_planned = self.env.context.get('date_planned') date_planned = self.env.context.get('date_planned')
res = [] res = []
service = self._get_stamps_service() service = self._get_stamps_service()
shipping = self._get_stamps_shipping_multi(service, date_planned, order=order, picking=picking) shipping = self._get_stamps_shipping_multi(service, date_planned, order=order, picking=picking, package=package)
rates = service.get_rates(shipping) rates = service.get_rates(shipping)
for rate in rates: for rate in rates:
price = float(rate.Amount) price = float(rate.Amount)
@@ -486,6 +505,7 @@ class ProviderStamps(models.Model):
if carrier: if carrier:
res.append({ res.append({
'carrier': carrier, 'carrier': carrier,
'package': package or self.env['stock.quant.package'].browse(),
'success': True, 'success': True,
'price': price, 'price': price,
'error_message': False, 'error_message': False,

View File

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

View File

@@ -1,9 +1,9 @@
{ {
'name': 'Hibou UPS Shipping', 'name': 'Hibou UPS Shipping',
'version': '14.0.1.0.0', 'version': '14.0.1.1.0',
'category': 'Stock', 'category': 'Stock',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'license': 'AGPL-3', 'license': 'OPL-1',
'website': 'https://hibou.io/', 'website': 'https://hibou.io/',
'depends': [ 'depends': [
'delivery_ups', 'delivery_ups',

View File

@@ -1,2 +1,4 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import delivery_ups from . import delivery_ups
from . import ups_request_patch from . import ups_request_patch

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
from odoo.addons.delivery_ups.models.ups_request import UPSRequest, Package from odoo.addons.delivery_ups.models.ups_request import UPSRequest, Package
@@ -223,7 +225,17 @@ class ProviderUPS(models.Model):
self.ups_get_return_label(picking) self.ups_get_return_label(picking)
return res return res
def ups_rate_shipment_multi(self, order=None, picking=None): def ups_rate_shipment_multi(self, order=None, picking=None, packages=None):
if not packages:
return self._ups_rate_shipment_multi_package(order=order, picking=picking)
else:
rates = []
for package in packages:
rates += self._ups_rate_shipment_multi_package(order=order, picking=picking, package=package)
return rates
def _ups_rate_shipment_multi_package(self, order=None, picking=None, package=None):
# TODO package here is ignored, it should not be (UPS is not multi-rating capable until we can get rates for a single package)
superself = self.sudo() superself = self.sudo()
srm = UPSRequest(self.log_xml, superself.ups_username, superself.ups_passwd, superself.ups_shipper_number, superself.ups_access_number, self.prod_environment) srm = UPSRequest(self.log_xml, superself.ups_username, superself.ups_passwd, superself.ups_shipper_number, superself.ups_access_number, self.prod_environment)
ResCurrency = self.env['res.currency'] ResCurrency = self.env['res.currency']

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from zeep.exceptions import Fault from zeep.exceptions import Fault
from odoo.addons.delivery_ups.models.ups_request import UPSRequest from odoo.addons.delivery_ups.models.ups_request import UPSRequest
import logging import logging

View File

@@ -1,7 +1,7 @@
{ {
'name': 'Stock Delivery Planner', 'name': 'Stock Delivery Planner',
'summary': 'Get rates and choose carrier for delivery.', 'summary': 'Get rates and choose carrier for delivery.',
'version': '14.0.1.0.0', 'version': '14.0.1.1.0',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'category': 'Warehouse', 'category': 'Warehouse',
'license': 'AGPL-3', 'license': 'AGPL-3',

View File

@@ -6,7 +6,7 @@
<field name="inherit_id" ref="stock.view_picking_form"/> <field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//header" position="inside"> <xpath expr="//header" position="inside">
<button name="action_plan_delivery" type="object" string="Plan Shipment" attrs="{'invisible': [('picking_type_code', '!=', 'outgoing')]}"/> <button name="action_plan_delivery" type="object" string="Plan Shipment" class="oe_highlight" attrs="{'invisible': [('picking_type_code', '!=', 'outgoing')]}"/>
</xpath> </xpath>
</field> </field>
</record> </record>

View File

@@ -1,4 +1,4 @@
from odoo import api, fields, models from odoo import api, fields, models, _
from odoo.tools import safe_eval from odoo.tools import safe_eval
import logging import logging
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -10,6 +10,16 @@ class StockDeliveryPlanner(models.TransientModel):
picking_id = fields.Many2one('stock.picking', 'Transfer') picking_id = fields.Many2one('stock.picking', 'Transfer')
plan_option_ids = fields.One2many('stock.delivery.planner.option', 'plan_id', 'Options') plan_option_ids = fields.One2many('stock.delivery.planner.option', 'plan_id', 'Options')
packages_planned = fields.Boolean(compute='_compute_packages_planned')
@api.depends('plan_option_ids.selection')
def _compute_packages_planned(self):
for wiz in self:
packages = wiz.picking_id.package_ids
if not packages:
wiz.packages_planned = False
selected_options = wiz.plan_option_ids.filtered(lambda p: p.selection == 'selected')
wiz.packages_planned = len(selected_options) == len(packages)
def create(self, values): def create(self, values):
planner = super(StockDeliveryPlanner, self).create(values) planner = super(StockDeliveryPlanner, self).create(values)
@@ -25,9 +35,12 @@ class StockDeliveryPlanner(models.TransientModel):
_logger.warning(rate.get('error_message')) _logger.warning(rate.get('error_message'))
for rate in filter(lambda r: r.get('success'), rates): for rate in filter(lambda r: r.get('success'), rates):
rate = self.calculate_delivery_window(rate) rate = self.calculate_delivery_window(rate)
# added late in API dev cycle
package = rate.get('package') or self.env['stock.quant.package'].browse()
planner.plan_option_ids |= planner.plan_option_ids.create({ planner.plan_option_ids |= planner.plan_option_ids.create({
'plan_id': self.id, 'plan_id': self.id,
'carrier_id': rate['carrier'].id, 'carrier_id': rate['carrier'].id,
'package_id': package.id,
'price': rate['price'], 'price': rate['price'],
'date_planned': rate['date_planned'], 'date_planned': rate['date_planned'],
'requested_date': rate['date_delivered'], 'requested_date': rate['date_delivered'],
@@ -48,6 +61,12 @@ class StockDeliveryPlanner(models.TransientModel):
rate['date_delivered'] = carrier.calculate_date_delivered(date_planned, rate.get('transit_days')) rate['date_delivered'] = carrier.calculate_date_delivered(date_planned, rate.get('transit_days'))
return rate return rate
def action_plan(self):
self.ensure_one()
selected_package_options = self.plan_option_ids.filtered(lambda o: o.package_id and o.selection == 'selected')
selected_package_options._plan()
return {"type": "ir.actions.act_window_close"}
class StockDeliveryOption(models.TransientModel): class StockDeliveryOption(models.TransientModel):
_name = 'stock.delivery.planner.option' _name = 'stock.delivery.planner.option'
@@ -55,17 +74,45 @@ class StockDeliveryOption(models.TransientModel):
plan_id = fields.Many2one('stock.delivery.planner', 'Plan', ondelete='cascade') plan_id = fields.Many2one('stock.delivery.planner', 'Plan', ondelete='cascade')
carrier_id = fields.Many2one('delivery.carrier', 'Delivery Method') carrier_id = fields.Many2one('delivery.carrier', 'Delivery Method')
package_id = fields.Many2one('stock.quant.package', 'Package')
price = fields.Float('Shipping Price') price = fields.Float('Shipping Price')
date_planned = fields.Datetime('Planned Date') date_planned = fields.Datetime('Planned Date')
requested_date = fields.Datetime('Expected Delivery Date') requested_date = fields.Datetime('Expected Delivery Date')
transit_days = fields.Integer('Transit Days') transit_days = fields.Integer('Transit Days')
sale_requested_date = fields.Datetime('Sale Order Delivery Date', related='plan_id.picking_id.sale_id.requested_date') sale_requested_date = fields.Datetime('Sale Order Delivery Date', related='plan_id.picking_id.sale_id.requested_date')
days_different = fields.Float('Days Different', compute='_compute_days_different') # use carrier calendar days_different = fields.Float('Days Different', compute='_compute_days_different') # use carrier calendar
selection = fields.Selection([
('', 'None'),
('selected', 'Selected'),
('deselected', 'De-selected')
])
def _plan(self):
# this is intended to be used during selecting a whole plan
for option in self:
option.package_id.write({
'carrier_id': option.carrier_id.id,
})
def select_plan(self): def select_plan(self):
for option in self.filtered('carrier_id'): self.ensure_one()
option.plan_id.picking_id.carrier_id = option.carrier_id self.selection = 'selected'
return if self.package_id:
# need to deselect other options for this package
deselected = self.plan_id.plan_option_ids.filtered(lambda o: o.package_id == self.package_id and o != self)
deselected.write({'selection': 'deselected'})
return {
'name': _('Delivery Rate Planner'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'stock.delivery.planner',
'res_id': self.plan_id.id,
'target': 'new',
}
else:
# Select plan for whole shipment
self.plan_id.picking_id.carrier_id = self.carrier_id
return {"type": "ir.actions.act_window_close"}
@api.depends('requested_date', 'sale_requested_date', 'carrier_id') @api.depends('requested_date', 'sale_requested_date', 'carrier_id')
def _compute_days_different(self): def _compute_days_different(self):

View File

@@ -6,22 +6,34 @@
<field name="type">form</field> <field name="type">form</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form> <form>
<field name="plan_option_ids"> <field name="packages_planned" invisible="1" />
<tree decoration-success="days_different &lt; 0.0" decoration-danger="days_different &gt; 0.0" <group>
create="false" edit="false" delete="false"> <field name="plan_option_ids" nolabel="1" >
<field name="carrier_id"/> <tree decoration-success="days_different &lt; 0.0 and not selection"
<field name="date_planned"/> decoration-danger="days_different &gt; 0.0 and not selection"
<field name="requested_date"/> decoration-info="selection == 'selected'"
<field name="transit_days"/> decoration-muted="selection == 'deselected'"
<field name="sale_requested_date"/> create="false" edit="false" delete="false">
<field name="days_different"/> <field name="package_id"/>
<field name="price"/> <field name="carrier_id"/>
<button class="eo_highlight" <field name="date_planned" invisible="1"/>
name="select_plan" <field name="requested_date"/>
string="Select" <field name="transit_days"/>
type="object" /> <field name="sale_requested_date"/>
</tree> <field name="days_different"/>
</field> <field name="price"/>
<field name="selection" invisible="1" />
<button class="eo_highlight"
name="select_plan"
string="Select"
type="object" />
</tree>
</field>
</group>
<footer>
<button type="object" name="action_plan" string="Plan" class="btn-primary" attrs="{'invisible': [('packages_planned', '=', False)]}"/>
<button string="Discard" special="cancel" class="btn-secondary"/>
</footer>
</form> </form>
</field> </field>
</record> </record>