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": , "price": }, ...] 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