feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
This commit is contained in:
1
fusion_shipping/wizard/__init__.py
Normal file
1
fusion_shipping/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import choose_delivery_fusion_rate
|
||||
446
fusion_shipping/wizard/choose_delivery_fusion_rate.py
Normal file
446
fusion_shipping/wizard/choose_delivery_fusion_rate.py
Normal file
@@ -0,0 +1,446 @@
|
||||
import json
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ChooseDeliveryFusionPackage(models.TransientModel):
|
||||
"""One package row in the Add Shipping wizard."""
|
||||
|
||||
_name = 'choose.delivery.fusion.package'
|
||||
_description = 'Shipping Package (Wizard)'
|
||||
_order = 'sequence, id'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'choose.delivery.carrier',
|
||||
string='Wizard',
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
package_type_id = fields.Many2one(
|
||||
'stock.package.type',
|
||||
string='Box Type',
|
||||
domain="[('package_carrier_type', '=', 'fusion_canada_post')]",
|
||||
)
|
||||
package_length = fields.Float(string='Length')
|
||||
package_width = fields.Float(string='Width')
|
||||
package_height = fields.Float(string='Height')
|
||||
weight = fields.Float(string='Weight')
|
||||
|
||||
# Per-package cost for the selected service (updated on service select)
|
||||
selected_price = fields.Float(
|
||||
string='Cost', digits='Product Price', readonly=True)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='wizard_id.currency_id',
|
||||
)
|
||||
|
||||
@api.onchange('package_type_id')
|
||||
def _onchange_package_type_id(self):
|
||||
"""Pre-fill dimensions from selected box type."""
|
||||
if self.package_type_id:
|
||||
self.package_length = self.package_type_id.packaging_length
|
||||
self.package_width = self.package_type_id.width
|
||||
self.package_height = self.package_type_id.height
|
||||
|
||||
|
||||
class ChooseDeliveryFusionRate(models.TransientModel):
|
||||
_name = 'choose.delivery.fusion.rate'
|
||||
_description = 'Shipping Rate Option'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'choose.delivery.carrier',
|
||||
string='Wizard',
|
||||
ondelete='cascade',
|
||||
)
|
||||
service_code = fields.Char(string='Service Code')
|
||||
service_name = fields.Char(string='Service')
|
||||
price = fields.Float(string='Shipping Cost', digits='Product Price')
|
||||
expected_delivery = fields.Char(string='Expected Delivery Date')
|
||||
is_selected = fields.Boolean(string='Selected', default=False)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='wizard_id.currency_id',
|
||||
)
|
||||
# JSON: [{"pkg_id": <int>, "price": <float>}, ...]
|
||||
per_package_prices = fields.Text(string='Per-Package Prices')
|
||||
|
||||
def action_select(self):
|
||||
"""Select this rate and deselect others. Update per-package costs."""
|
||||
self.ensure_one()
|
||||
# Deselect all, then select this one
|
||||
self.wizard_id.fusion_rate_ids.write({'is_selected': False})
|
||||
self.is_selected = True
|
||||
|
||||
# Update per-package costs from stored JSON
|
||||
if self.per_package_prices:
|
||||
try:
|
||||
pkg_prices = json.loads(self.per_package_prices)
|
||||
for pp in pkg_prices:
|
||||
pkg = self.env['choose.delivery.fusion.package'].browse(
|
||||
pp['pkg_id'])
|
||||
if pkg.exists():
|
||||
pkg.selected_price = pp['price']
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Apply margin from the carrier
|
||||
carrier = self.wizard_id.carrier_id
|
||||
price = self.price
|
||||
if carrier:
|
||||
price = carrier._apply_margins(price, self.wizard_id.order_id)
|
||||
# Check free_over
|
||||
if carrier.free_over:
|
||||
order = self.wizard_id.order_id
|
||||
amount = order.currency_id._convert(
|
||||
order.amount_untaxed,
|
||||
order.company_id.currency_id,
|
||||
order.company_id,
|
||||
fields.Date.today(),
|
||||
)
|
||||
if amount >= carrier.amount:
|
||||
price = 0.0
|
||||
|
||||
self.wizard_id.write({
|
||||
'delivery_price': price,
|
||||
'display_price': price,
|
||||
'fusion_selected_service': self.service_code,
|
||||
'fusion_selected_service_name': self.service_name,
|
||||
'fusion_selected_expected_delivery': self.expected_delivery,
|
||||
})
|
||||
# Re-open the wizard to show updated selection
|
||||
return {
|
||||
'name': _('Add a shipping method'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'choose.delivery.carrier',
|
||||
'res_id': self.wizard_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
|
||||
class ChooseDeliveryCarrier(models.TransientModel):
|
||||
_inherit = 'choose.delivery.carrier'
|
||||
|
||||
fusion_rate_ids = fields.One2many(
|
||||
'choose.delivery.fusion.rate',
|
||||
'wizard_id',
|
||||
string='Available Services',
|
||||
)
|
||||
fusion_selected_service = fields.Char(
|
||||
string='Selected Service Code',
|
||||
)
|
||||
fusion_selected_service_name = fields.Char(
|
||||
string='Selected Service Name',
|
||||
)
|
||||
fusion_selected_expected_delivery = fields.Char(
|
||||
string='Selected Expected Delivery',
|
||||
)
|
||||
|
||||
# -- Package list --
|
||||
fusion_package_ids = fields.One2many(
|
||||
'choose.delivery.fusion.package',
|
||||
'wizard_id',
|
||||
string='Packages',
|
||||
)
|
||||
|
||||
# -- Unit labels --
|
||||
fusion_dimension_unit_label = fields.Char(
|
||||
string='Dimension Unit',
|
||||
compute='_compute_fusion_dimension_unit_label',
|
||||
)
|
||||
fusion_weight_unit_label = fields.Char(
|
||||
string='Weight Unit',
|
||||
compute='_compute_fusion_weight_unit_label',
|
||||
)
|
||||
|
||||
@api.depends('carrier_id')
|
||||
def _compute_fusion_dimension_unit_label(self):
|
||||
for rec in self:
|
||||
if (rec.carrier_id
|
||||
and rec.carrier_id.delivery_type == 'fusion_canada_post'):
|
||||
rec.fusion_dimension_unit_label = (
|
||||
rec.carrier_id.fusion_cp_dimension_unit or 'cm')
|
||||
else:
|
||||
rec.fusion_dimension_unit_label = ''
|
||||
|
||||
@api.depends('carrier_id')
|
||||
def _compute_fusion_weight_unit_label(self):
|
||||
for rec in self:
|
||||
if (rec.carrier_id
|
||||
and rec.carrier_id.delivery_type == 'fusion_canada_post'):
|
||||
uom = (rec.order_id.company_id
|
||||
.weight_unit_of_measurement_id)
|
||||
rec.fusion_weight_unit_label = uom.name if uom else 'kg'
|
||||
else:
|
||||
rec.fusion_weight_unit_label = ''
|
||||
|
||||
@api.onchange('carrier_id')
|
||||
def _onchange_carrier_id_fusion_packages(self):
|
||||
"""When a CP carrier is selected, create one default package."""
|
||||
if (self.carrier_id
|
||||
and self.carrier_id.delivery_type == 'fusion_canada_post'
|
||||
and not self.fusion_package_ids):
|
||||
vals = {
|
||||
'sequence': 10,
|
||||
'weight': self.total_weight or 0.0,
|
||||
}
|
||||
# Pre-fill from default package type if set on carrier
|
||||
if self.carrier_id.product_packaging_id:
|
||||
pkg = self.carrier_id.product_packaging_id
|
||||
vals['package_type_id'] = pkg.id
|
||||
vals['package_length'] = pkg.packaging_length
|
||||
vals['package_width'] = pkg.width
|
||||
vals['package_height'] = pkg.height
|
||||
self.fusion_package_ids = [(5, 0, 0), (0, 0, vals)]
|
||||
|
||||
# -- Rate fetching --
|
||||
|
||||
def update_price(self):
|
||||
"""Override: for Canada Post, fetch all rates for all packages."""
|
||||
if self.carrier_id.delivery_type == 'fusion_canada_post':
|
||||
return self._update_fusion_rates()
|
||||
return super().update_price()
|
||||
|
||||
def _get_fusion_package_info_for_pkg(self, pkg):
|
||||
"""Build package_info dict for a single package, converted to cm."""
|
||||
carrier = self.carrier_id
|
||||
return {
|
||||
'length': round(carrier._fusion_cp_convert_dimension_to_cm(
|
||||
pkg.package_length), 1),
|
||||
'width': round(carrier._fusion_cp_convert_dimension_to_cm(
|
||||
pkg.package_width), 1),
|
||||
'height': round(carrier._fusion_cp_convert_dimension_to_cm(
|
||||
pkg.package_height), 1),
|
||||
}
|
||||
|
||||
def _update_fusion_rates(self):
|
||||
"""Fetch shipping service rates for every package and aggregate."""
|
||||
carrier = self.carrier_id
|
||||
packages = self.fusion_package_ids
|
||||
|
||||
if not packages:
|
||||
raise UserError(_(
|
||||
"Please add at least one package with dimensions."))
|
||||
|
||||
from_unit = (self.order_id.company_id
|
||||
.weight_unit_of_measurement_id)
|
||||
|
||||
# -- Validate every package --
|
||||
for pkg in packages:
|
||||
if not (pkg.package_length and pkg.package_width
|
||||
and pkg.package_height):
|
||||
raise UserError(_(
|
||||
"Please enter dimensions (L x W x H) "
|
||||
"for all packages."))
|
||||
if not pkg.weight:
|
||||
raise UserError(_(
|
||||
"Please enter weight for all packages."))
|
||||
package_info = self._get_fusion_package_info_for_pkg(pkg)
|
||||
weight_kg = pkg.weight
|
||||
if from_unit:
|
||||
weight_kg = round(carrier.convert_weight(
|
||||
from_unit, carrier.weight_uom_id, weight_kg), 2)
|
||||
carrier._fusion_cp_validate_package(weight_kg, package_info)
|
||||
|
||||
# -- Clear old rates --
|
||||
self.fusion_rate_ids.unlink()
|
||||
|
||||
# -- Fetch rates per package --
|
||||
# {service_code: {service_name, packages: [{pkg_id, price, exp}]}}
|
||||
all_service_rates = {}
|
||||
|
||||
for pkg in packages:
|
||||
package_info = self._get_fusion_package_info_for_pkg(pkg)
|
||||
carrier_ctx = carrier.with_context(
|
||||
order_weight=pkg.weight, # raw, in company UOM
|
||||
cp_package_info=package_info,
|
||||
)
|
||||
rates = carrier_ctx.fusion_canada_post_rate_shipment_all(
|
||||
self.order_id)
|
||||
|
||||
if isinstance(rates, dict) and rates.get('error_message'):
|
||||
raise UserError(rates['error_message'])
|
||||
|
||||
if not rates:
|
||||
raise UserError(_(
|
||||
"No shipping prices available for this order."))
|
||||
|
||||
for rate in rates:
|
||||
code = rate['service_code']
|
||||
if code not in all_service_rates:
|
||||
all_service_rates[code] = {
|
||||
'service_name': rate['service_name'],
|
||||
'packages': [],
|
||||
}
|
||||
all_service_rates[code]['packages'].append({
|
||||
'pkg_id': pkg.id,
|
||||
'price': rate['price'],
|
||||
'expected_delivery': rate.get(
|
||||
'expected_delivery', ''),
|
||||
})
|
||||
|
||||
# -- Keep only services available for ALL packages --
|
||||
num_packages = len(packages)
|
||||
available = {
|
||||
code: data
|
||||
for code, data in all_service_rates.items()
|
||||
if len(data['packages']) == num_packages
|
||||
}
|
||||
|
||||
if not available:
|
||||
raise UserError(_(
|
||||
"No single shipping service covers all packages. "
|
||||
"Try adjusting package dimensions or weight."))
|
||||
|
||||
# -- Find cheapest service --
|
||||
cheapest_code = min(
|
||||
available.keys(),
|
||||
key=lambda c: sum(
|
||||
p['price'] for p in available[c]['packages']))
|
||||
|
||||
# -- Create combined rate lines --
|
||||
vals_list = []
|
||||
for code, data in available.items():
|
||||
total_price = sum(p['price'] for p in data['packages'])
|
||||
expected_dates = [
|
||||
p['expected_delivery'] for p in data['packages']
|
||||
if p['expected_delivery']
|
||||
]
|
||||
expected = max(expected_dates) if expected_dates else ''
|
||||
is_sel = (code == cheapest_code)
|
||||
vals_list.append({
|
||||
'wizard_id': self.id,
|
||||
'service_code': code,
|
||||
'service_name': data['service_name'],
|
||||
'price': total_price,
|
||||
'expected_delivery': expected,
|
||||
'is_selected': is_sel,
|
||||
'per_package_prices': json.dumps(data['packages']),
|
||||
})
|
||||
|
||||
self.env['choose.delivery.fusion.rate'].create(vals_list)
|
||||
|
||||
# -- Auto-select cheapest: update packages and wizard --
|
||||
cheapest_data = available[cheapest_code]
|
||||
selected_price = sum(
|
||||
p['price'] for p in cheapest_data['packages'])
|
||||
|
||||
for pkg_data in cheapest_data['packages']:
|
||||
pkg_rec = packages.filtered(
|
||||
lambda p, pid=pkg_data['pkg_id']: p.id == pid)
|
||||
if pkg_rec:
|
||||
pkg_rec.selected_price = pkg_data['price']
|
||||
|
||||
# Apply carrier margins
|
||||
price = selected_price
|
||||
if self.carrier_id:
|
||||
price = self.carrier_id._apply_margins(
|
||||
price, self.order_id)
|
||||
if self.carrier_id.free_over:
|
||||
amount = self.order_id.currency_id._convert(
|
||||
self.order_id.amount_untaxed,
|
||||
self.order_id.company_id.currency_id,
|
||||
self.order_id.company_id,
|
||||
fields.Date.today(),
|
||||
)
|
||||
if amount >= self.carrier_id.amount:
|
||||
price = 0.0
|
||||
|
||||
expected_dates = [
|
||||
p['expected_delivery'] for p in cheapest_data['packages']
|
||||
if p['expected_delivery']
|
||||
]
|
||||
|
||||
self.write({
|
||||
'delivery_price': price,
|
||||
'display_price': price,
|
||||
'fusion_selected_service': cheapest_code,
|
||||
'fusion_selected_service_name': (
|
||||
cheapest_data['service_name']),
|
||||
'fusion_selected_expected_delivery': (
|
||||
max(expected_dates) if expected_dates else ''),
|
||||
})
|
||||
|
||||
return {
|
||||
'name': _('Add a shipping method'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'choose.delivery.carrier',
|
||||
'res_id': self.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# -- Confirm --
|
||||
|
||||
def button_confirm(self):
|
||||
"""Override: store per-package info on the sale order and enhance
|
||||
the delivery line description."""
|
||||
if self.carrier_id.delivery_type == 'fusion_canada_post':
|
||||
order = self.order_id
|
||||
|
||||
# Clear previous package records
|
||||
order.fusion_package_ids.unlink()
|
||||
|
||||
selected_code = self.fusion_selected_service or ''
|
||||
selected_name = self.fusion_selected_service_name or ''
|
||||
selected_delivery = (
|
||||
self.fusion_selected_expected_delivery or '')
|
||||
|
||||
pkg_vals = []
|
||||
for idx, pkg in enumerate(
|
||||
self.fusion_package_ids.sorted('sequence')):
|
||||
pkg_vals.append((0, 0, {
|
||||
'sequence': (idx + 1) * 10,
|
||||
'package_type_id': (
|
||||
pkg.package_type_id.id
|
||||
if pkg.package_type_id else False),
|
||||
'package_length': pkg.package_length,
|
||||
'package_width': pkg.package_width,
|
||||
'package_height': pkg.package_height,
|
||||
'weight': pkg.weight,
|
||||
'service_code': selected_code,
|
||||
'service_name': selected_name,
|
||||
'price': pkg.selected_price,
|
||||
'expected_delivery': selected_delivery,
|
||||
}))
|
||||
|
||||
write_vals = {
|
||||
'fusion_package_ids': pkg_vals,
|
||||
'fusion_cp_service_code': selected_code,
|
||||
}
|
||||
|
||||
# Backward compat: first-package dims in legacy fields
|
||||
first_pkg = self.fusion_package_ids.sorted('sequence')[:1]
|
||||
if first_pkg:
|
||||
write_vals['fusion_cp_package_length'] = (
|
||||
first_pkg.package_length)
|
||||
write_vals['fusion_cp_package_width'] = (
|
||||
first_pkg.package_width)
|
||||
write_vals['fusion_cp_package_height'] = (
|
||||
first_pkg.package_height)
|
||||
|
||||
order.write(write_vals)
|
||||
|
||||
res = super().button_confirm()
|
||||
|
||||
# Enhance delivery line description with service details
|
||||
if (self.carrier_id.delivery_type == 'fusion_canada_post'
|
||||
and self.fusion_selected_service_name):
|
||||
delivery_line = self.order_id.order_line.filtered(
|
||||
'is_delivery')
|
||||
if delivery_line:
|
||||
line = delivery_line[-1]
|
||||
parts = [line.name]
|
||||
parts.append(
|
||||
"Service: %s"
|
||||
% self.fusion_selected_service_name)
|
||||
num_pkgs = len(self.fusion_package_ids)
|
||||
if num_pkgs > 1:
|
||||
parts.append("Packages: %d" % num_pkgs)
|
||||
if self.fusion_selected_expected_delivery:
|
||||
parts.append(
|
||||
"Expected Delivery: %s"
|
||||
% self.fusion_selected_expected_delivery)
|
||||
line.name = '\n'.join(parts)
|
||||
return res
|
||||
Reference in New Issue
Block a user