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:
2026-03-11 16:19:52 +00:00
parent 1f79cdcaaf
commit 431052920e
274 changed files with 52782 additions and 7302 deletions

View File

@@ -0,0 +1,11 @@
from . import delivery_carrier
from . import product_packaging
from . import res_company
from . import res_partner
from . import payment_provider
from . import uom_uom
from . import fusion_shipment
from . import fusion_tracking_event
from . import fusion_order_package
from . import sale_order
from . import stock_picking

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
from odoo import models, fields
class FusionOrderPackage(models.Model):
"""Stores per-package dimensions and service info on a sale order.
Created when the user confirms the "Add Shipping" wizard after
defining one or more packages with dimensions.
Used by ``send_shipping`` to apply the correct dimensions
to each physical package on the delivery order.
"""
_name = 'fusion.order.package'
_description = 'Order Package'
_order = 'sequence, id'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='cascade',
required=True,
index=True,
)
sequence = fields.Integer(default=10)
package_type_id = fields.Many2one(
'stock.package.type',
string='Box Type',
)
package_length = fields.Float(string='Length')
package_width = fields.Float(string='Width')
package_height = fields.Float(string='Height')
weight = fields.Float(string='Weight')
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')
currency_id = fields.Many2one(
'res.currency',
related='sale_order_id.currency_id',
)

View File

@@ -0,0 +1,747 @@
import base64
import logging
from datetime import datetime as dt_mod
from lxml import etree
from requests import request
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from odoo.addons.fusion_shipping.api.canada_post.response import Response
_logger = logging.getLogger(__name__)
class FusionShipment(models.Model):
_name = 'fusion.shipment'
_description = 'Fusion Shipment'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'shipment_date desc, id desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
readonly=True,
default=lambda self: _('New'),
copy=False,
)
carrier_type = fields.Selection(
[
('canada_post', 'Canada Post'),
('ups', 'UPS'),
('ups_rest', 'UPS (REST)'),
('fedex', 'FedEx'),
('fedex_rest', 'FedEx (REST)'),
('dhl', 'DHL Express'),
('dhl_rest', 'DHL Express (REST)'),
],
string='Carrier Type',
readonly=True,
tracking=True,
help='The shipping carrier used for this shipment.',
)
tracking_number = fields.Char(
string='Tracking Number',
index=True,
readonly=True,
copy=False,
tracking=True,
)
shipment_id = fields.Char(
string='CP Shipment ID',
readonly=True,
copy=False,
help='Canada Post internal shipment identifier',
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
index=True,
ondelete='set null',
tracking=True,
)
picking_id = fields.Many2one(
'stock.picking',
string='Transfer',
index=True,
ondelete='set null',
tracking=True,
)
carrier_id = fields.Many2one(
'delivery.carrier',
string='Carrier',
ondelete='set null',
)
shipment_date = fields.Datetime(
string='Shipment Date',
default=fields.Datetime.now,
tracking=True,
)
status = fields.Selection(
[
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
('returned', 'Returned'),
('cancelled', 'Cancelled'),
],
string='Status',
default='confirmed',
tracking=True,
)
# Label attachments (Many2one for storage)
label_attachment_id = fields.Many2one(
'ir.attachment',
string='Printable Label (4x6)',
ondelete='set null',
help='The thermal printer label (4x6 format)',
)
full_label_attachment_id = fields.Many2one(
'ir.attachment',
string='Full Label (8.5x11)',
ondelete='set null',
help='Full page label with instructions and receipt',
)
receipt_attachment_id = fields.Many2one(
'ir.attachment',
string='Receipt',
ondelete='set null',
)
commercial_invoice_attachment_id = fields.Many2one(
'ir.attachment',
string='Commercial Invoice',
ondelete='set null',
)
# Return label
return_tracking_number = fields.Char(
string='Return Tracking Number',
readonly=True,
copy=False,
)
return_label_attachment_id = fields.Many2one(
'ir.attachment',
string='Return Label',
ondelete='set null',
)
# Shipment details
shipping_cost = fields.Monetary(
string='Shipping Cost',
currency_field='currency_id',
readonly=True,
)
service_type = fields.Char(
string='Service Type',
readonly=True,
)
weight = fields.Float(
string='Weight',
digits='Stock Weight',
readonly=True,
)
package_name = fields.Char(
string='Package',
readonly=True,
help='Name of the package this shipment covers',
)
# Address fields (computed)
sender_name = fields.Char(
string='Sender',
compute='_compute_sender_fields',
store=True,
)
sender_address = fields.Char(
string='Sender Address',
compute='_compute_sender_fields',
store=True,
)
recipient_name = fields.Char(
string='Recipient',
compute='_compute_recipient_fields',
store=True,
)
recipient_address = fields.Char(
string='Recipient Address',
compute='_compute_recipient_fields',
store=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
store=True,
)
# Tracking history
tracking_event_ids = fields.One2many(
'fusion.tracking.event',
'shipment_id',
string='Tracking Events',
)
tracking_event_count = fields.Integer(
string='Tracking Events',
compute='_compute_tracking_event_count',
)
last_tracking_update = fields.Datetime(
string='Last Tracking Update',
readonly=True,
)
delivery_date = fields.Datetime(
string='Delivery Date',
readonly=True,
)
def _compute_tracking_event_count(self):
for rec in self:
rec.tracking_event_count = len(rec.tracking_event_ids)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code(
'fusion.shipment'
) or _('New')
return super().create(vals_list)
@api.depends('picking_id', 'picking_id.picking_type_id.warehouse_id.partner_id')
def _compute_sender_fields(self):
for rec in self:
partner = (
rec.picking_id.picking_type_id.warehouse_id.partner_id
if rec.picking_id
else False
)
if partner:
rec.sender_name = partner.name or ''
parts = filter(None, [
partner.street,
partner.city,
partner.state_id.name if partner.state_id else '',
partner.zip,
])
rec.sender_address = ', '.join(parts)
else:
rec.sender_name = ''
rec.sender_address = ''
@api.depends('picking_id', 'picking_id.partner_id')
def _compute_recipient_fields(self):
for rec in self:
partner = rec.picking_id.partner_id if rec.picking_id else False
if partner:
rec.recipient_name = partner.name or ''
parts = filter(None, [
partner.street,
partner.city,
partner.state_id.name if partner.state_id else '',
partner.zip,
])
rec.recipient_address = ', '.join(parts)
else:
rec.recipient_name = ''
rec.recipient_address = ''
def action_open_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_open_picking(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Transfer',
'res_model': 'stock.picking',
'res_id': self.picking_id.id,
'view_mode': 'form',
'target': 'current',
}
def _action_open_attachment(self, attachment):
"""Open an attachment PDF in the browser viewer (new tab)."""
self.ensure_one()
if not attachment:
return False
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=false' % attachment.id,
'target': 'new',
}
def action_view_label(self):
return self._action_open_attachment(self.label_attachment_id)
def action_view_full_label(self):
return self._action_open_attachment(self.full_label_attachment_id)
def action_view_receipt(self):
return self._action_open_attachment(self.receipt_attachment_id)
def action_view_commercial_invoice(self):
return self._action_open_attachment(self.commercial_invoice_attachment_id)
# ── Tracking ──────────────────────────────────────────────
def action_refresh_tracking(self):
"""Fetch latest tracking events from Canada Post VIS API."""
self.ensure_one()
if not self.tracking_number:
raise ValidationError(
_("No tracking number available for this shipment."))
carrier = self.carrier_id
if not carrier:
raise ValidationError(
_("No carrier linked to this shipment."))
# VIS tracking uses /vis/ path, not /rs/
if carrier.prod_environment:
base = "https://soa-gw.canadapost.ca"
else:
base = "https://ct.soa-gw.canadapost.ca"
url = "%s/vis/track/pin/%s/detail" % (base, self.tracking_number)
headers = {
'Accept': 'application/vnd.cpc.track-v2+xml',
'Accept-language': 'en-CA',
}
try:
resp = request(
method='GET', url=url, headers=headers,
auth=(carrier.username, carrier.password))
_logger.info("Tracking API %s%s", url, resp.status_code)
if resp.status_code == 404:
# "No Pin History" — normal for new/sandbox shipments
self.last_tracking_update = fields.Datetime.now()
self.message_post(
body=_("No tracking history available yet for %s.") %
self.tracking_number)
return
if resp.status_code != 200:
raise ValidationError(
_("Canada Post tracking error: %s %s\n%s") % (
resp.status_code, resp.reason,
resp.text[:500] if resp.text else ''))
api_resp = Response(resp)
result = api_resp.dict()
except ValidationError:
raise
except Exception as e:
raise ValidationError(
_("Failed to fetch tracking: %s") % str(e))
self._process_tracking_events(result)
def _process_tracking_events(self, result):
"""Parse CP tracking detail response and store events."""
detail = result.get('tracking-detail', result)
sig_events = detail.get('significant-events', {})
occurrences = sig_events.get('occurrence', [])
# Single event returns dict, multiple returns list
if isinstance(occurrences, dict):
occurrences = [occurrences]
# Replace existing events
self.tracking_event_ids.unlink()
vals_list = []
for occ in occurrences:
event_date_str = occ.get('event-date', '')
event_time_str = occ.get('event-time', '')
# Build combined datetime for sorting
event_datetime = False
if event_date_str:
try:
if event_time_str:
event_datetime = dt_mod.strptime(
'%s %s' % (event_date_str, event_time_str),
'%Y-%m-%d %H:%M:%S')
else:
event_datetime = dt_mod.strptime(
event_date_str, '%Y-%m-%d')
except (ValueError, TypeError):
pass
vals_list.append({
'shipment_id': self.id,
'event_date': event_date_str or False,
'event_time': event_time_str or '',
'event_datetime': event_datetime,
'event_description': occ.get('event-description', ''),
'event_type': occ.get('event-type', ''),
'event_site': occ.get('event-site', ''),
'event_province': occ.get('event-province', ''),
'signatory_name': occ.get('signatory-name', ''),
})
if vals_list:
self.env['fusion.tracking.event'].create(vals_list)
self.last_tracking_update = fields.Datetime.now()
self._update_status_from_tracking(detail)
self.message_post(
body=_("Tracking refreshed: %d events loaded.") % len(vals_list))
def _update_status_from_tracking(self, detail):
"""Auto-update shipment status based on tracking data."""
if self.status == 'cancelled':
return
delivered_date = detail.get('actual-delivery-date', '')
if delivered_date:
if self._has_return_events():
self.status = 'returned'
else:
self.status = 'delivered'
try:
self.delivery_date = dt_mod.strptime(
delivered_date, '%Y-%m-%d')
except (ValueError, TypeError):
self.delivery_date = fields.Datetime.now()
elif self._has_return_events():
self.status = 'returned'
elif self.status == 'confirmed' and self.tracking_event_ids:
self.status = 'shipped'
def _has_return_events(self):
"""Check if any tracking events indicate a return/RTS."""
RETURN_TYPES = {'RTS', 'RETURN', 'RTS_LABEL_PROC'}
for event in self.tracking_event_ids:
if event.event_type in RETURN_TYPES:
return True
desc = (event.event_description or '').lower()
if 'return to sender' in desc or 'item returned' in desc:
return True
return False
# ── Void & Reissue ─────────────────────────────────────
def action_void_shipment(self):
"""Void this shipment via Canada Post API (DELETE endpoint)."""
self.ensure_one()
if self.status == 'cancelled':
raise ValidationError(
_("This shipment is already cancelled."))
if not self.shipment_id:
raise ValidationError(
_("No CP shipment ID — cannot void."))
carrier = self.carrier_id
if not carrier:
raise ValidationError(
_("No carrier linked to this shipment."))
if carrier.prod_environment:
base = "https://soa-gw.canadapost.ca"
else:
base = "https://ct.soa-gw.canadapost.ca"
customer = carrier.customer_number
if carrier.fusion_cp_type == 'commercial':
url = "%s/rs/%s/%s/shipment/%s" % (
base, customer, customer, self.shipment_id)
accept_header = 'application/vnd.cpc.shipment-v8+xml'
else:
url = "%s/rs/%s/ncshipment/%s" % (
base, customer, self.shipment_id)
accept_header = 'application/vnd.cpc.ncshipment-v4+xml'
try:
resp = request(
method='DELETE', url=url,
headers={
'Accept': accept_header,
'Accept-language': 'en-CA',
},
auth=(carrier.username, carrier.password))
_logger.info(
"Void Shipment %s%s", url, resp.status_code)
if resp.status_code == 204:
self.status = 'cancelled'
self.message_post(
body=_("Shipment voided successfully via "
"Canada Post API."))
else:
# Parse CP error XML for a clean message
error_msg = self._parse_cp_error_response(resp)
except ValidationError:
raise
except Exception as e:
raise ValidationError(
_("Failed to void shipment: %s") % str(e))
def _parse_cp_error_response(self, resp):
"""Parse a Canada Post error XML response and raise a clean
ValidationError with the human-readable description.
If the response cannot be parsed, falls back to the raw text.
"""
description = ''
try:
root = etree.fromstring(resp.content)
# Strip namespace for easy tag matching
ns = {'cp': root.nsmap.get(None, '')}
if ns['cp']:
msgs = root.findall('.//cp:message', ns)
else:
msgs = root.findall('.//message')
parts = []
for msg in msgs:
desc_el = msg.find(
'cp:description', ns) if ns['cp'] else msg.find(
'description')
if desc_el is not None and desc_el.text:
parts.append(desc_el.text.strip())
if parts:
description = '\n'.join(parts)
except Exception:
description = ''
if not description:
description = (resp.text[:500]
if resp.text else 'Unknown error')
raise ValidationError(
_("Canada Post: %s") % description)
def action_reissue_shipment(self):
"""Void current shipment and create a new one."""
self.ensure_one()
if self.status != 'cancelled':
self.action_void_shipment()
if not self.picking_id:
raise ValidationError(
_("No transfer linked — cannot reissue."))
picking = self.picking_id
carrier = picking.carrier_id or self.carrier_id
if not carrier:
raise ValidationError(
_("No carrier found for reissue."))
result = carrier.send_shipping(picking)
if result:
picking.carrier_tracking_ref = result[0].get(
'tracking_number', '')
self.message_post(
body=_("Shipment reissued. See new shipment record."))
def action_track_on_carrier(self):
"""Open the carrier's tracking website in a new tab."""
self.ensure_one()
if not self.tracking_number:
raise ValidationError(
_("No tracking number available for this shipment."))
# Default tracking URLs per carrier type
DEFAULT_TRACKING = {
'canada_post': 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor=',
'ups': 'https://www.ups.com/track?tracknum=',
'ups_rest': 'https://www.ups.com/track?tracknum=',
'fedex': 'https://www.fedex.com/wtrk/track/?trknbr=',
'fedex_rest': 'https://www.fedex.com/wtrk/track/?trknbr=',
'dhl': 'https://www.dhl.com/en/express/tracking.html?AWB=',
'dhl_rest': 'https://www.dhl.com/en/express/tracking.html?AWB=',
}
base_link = ''
if self.carrier_id and self.carrier_id.tracking_link:
base_link = self.carrier_id.tracking_link
elif self.carrier_type:
base_link = DEFAULT_TRACKING.get(self.carrier_type, '')
if not base_link:
base_link = 'https://www.google.com/search?q='
return {
'type': 'ir.actions.act_url',
'url': '%s%s' % (base_link, self.tracking_number),
'target': 'new',
}
# ── Cron ─────────────────────────────────────────────────
@api.model
def _cron_refresh_tracking(self):
"""Auto-refresh tracking for all active (non-terminal) shipments."""
shipments = self.search([
('status', 'in', ('confirmed', 'shipped')),
('tracking_number', '!=', False),
])
_logger.info(
"Cron: refreshing tracking for %d shipments", len(shipments))
for shipment in shipments:
try:
shipment.action_refresh_tracking()
self.env.cr.commit()
except Exception as e:
self.env.cr.rollback()
_logger.warning(
"Cron: tracking refresh failed for %s: %s",
shipment.name, str(e))
# ── Return Labels ────────────────────────────────────────
def action_view_return_label(self):
return self._action_open_attachment(self.return_label_attachment_id)
def action_create_return_label(self):
"""Create a prepaid return label via Canada Post Authorized
Returns API. Bill-on-scan: charged only when the customer
uses the label at a post office.
"""
self.ensure_one()
if self.return_tracking_number:
raise ValidationError(
_("A return label has already been created "
"for this shipment."))
if not self.carrier_id:
raise ValidationError(
_("No carrier linked to this shipment."))
if not self.picking_id:
raise ValidationError(
_("No transfer linked to this shipment."))
carrier = self.carrier_id
customer = carrier.customer_number
warehouse_partner = (
self.picking_id.picking_type_id.warehouse_id.partner_id)
if not warehouse_partner:
raise ValidationError(
_("No warehouse address found. Please configure "
"a contact on the warehouse."))
if carrier.prod_environment:
base = "https://soa-gw.canadapost.ca"
else:
base = "https://ct.soa-gw.canadapost.ca"
url = "%s/rs/%s/%s/authorizedreturn" % (base, customer, customer)
xml = self._build_return_label_xml(carrier, warehouse_partner)
headers = {
'Content-Type': 'application/vnd.cpc.authreturn-v2+xml',
'Accept': 'application/vnd.cpc.authreturn-v2+xml',
'Accept-language': 'en-CA',
}
try:
resp = request(
method='POST', url=url, data=xml,
headers=headers,
auth=(carrier.username, carrier.password))
_logger.info(
"Return Label API %s%s", url, resp.status_code)
if resp.status_code not in (200, 201):
self._parse_cp_error_response(resp)
self._process_return_label_response(resp, carrier)
except ValidationError:
raise
except Exception as e:
raise ValidationError(
_("Failed to create return label: %s") % str(e))
def _build_return_label_xml(self, carrier, return_to_partner):
"""Build Authorized Return XML.
return_to_partner = warehouse address (where the return goes).
"""
ns = "http://www.canadapost.ca/ws/authreturn-v2"
root = etree.Element("authorized-return", xmlns=ns)
etree.SubElement(root, "service-code").text = "DOM.EP"
# Receiver — warehouse where the item is returned to
receiver = etree.SubElement(root, "receiver")
rec_name = etree.SubElement(receiver, "name")
rec_name.text = (return_to_partner.name or '')[:44]
rec_company = etree.SubElement(receiver, "company")
rec_company.text = (
return_to_partner.commercial_company_name
or return_to_partner.name or '')[:44]
rec_addr = etree.SubElement(receiver, "domestic-address")
etree.SubElement(rec_addr, "address-line-1").text = (
return_to_partner.street or '')[:44]
if return_to_partner.street2:
etree.SubElement(rec_addr, "address-line-2").text = (
return_to_partner.street2)[:44]
etree.SubElement(rec_addr, "city").text = (
return_to_partner.city or '')[:40]
etree.SubElement(rec_addr, "province").text = (
return_to_partner.state_id.code or '')
etree.SubElement(rec_addr, "postal-code").text = (
(return_to_partner.zip or '').replace(' ', ''))
# Parcel characteristics
parcel = etree.SubElement(root, "parcel-characteristics")
etree.SubElement(parcel, "weight").text = str(
max(self.weight or 0.5, 0.1))
# Print preferences
prefs = etree.SubElement(root, "print-preferences")
etree.SubElement(prefs, "output-format").text = (
carrier.fusion_cp_output_format or '8.5x11')
return etree.tostring(
root, xml_declaration=True, encoding='UTF-8')
def _process_return_label_response(self, resp, carrier):
"""Parse return label response, download label PDF,
store on shipment.
"""
api_resp = Response(resp)
result = api_resp.dict()
auth_return = result.get('authorized-return', result)
tracking_pin = auth_return.get('tracking-pin', '')
self.return_tracking_number = tracking_pin
# Get label artifact URL from links
links = auth_return.get('links', {})
link_list = links.get('link', [])
if isinstance(link_list, dict):
link_list = [link_list]
label_url = ''
for link in link_list:
if link.get('@rel') == 'labelDetails':
label_url = link.get('@href', '')
break
if label_url:
label_resp = request(
method='GET', url=label_url,
headers={
'Accept': 'application/pdf',
'Accept-language': 'en-CA',
},
auth=(carrier.username, carrier.password))
if label_resp.status_code == 200:
attachment = self.env['ir.attachment'].create({
'name': 'Return-Label-%s.pdf' % tracking_pin,
'type': 'binary',
'datas': base64.b64encode(
label_resp.content).decode(),
'res_model': self._name,
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.return_label_attachment_id = attachment
self.message_post(
body=_("Return label created. Tracking: %s") % tracking_pin,
attachment_ids=(
self.return_label_attachment_id.ids
if self.return_label_attachment_id else []))

View File

@@ -0,0 +1,44 @@
from odoo import models, fields
class FusionTrackingEvent(models.Model):
_name = 'fusion.tracking.event'
_description = 'Shipping Tracking Event'
_order = 'event_datetime desc, id desc'
_rec_name = 'event_description'
shipment_id = fields.Many2one(
'fusion.shipment',
string='Shipment',
required=True,
ondelete='cascade',
index=True,
)
event_date = fields.Date(
string='Event Date',
)
event_time = fields.Char(
string='Event Time',
help='Time from Canada Post (HH:MM:SS)',
)
event_datetime = fields.Datetime(
string='Date/Time',
help='Combined date and time for sorting',
)
event_description = fields.Char(
string='Description',
)
event_type = fields.Char(
string='Event Type',
help='Canada Post event type code',
)
event_site = fields.Char(
string='Location',
)
event_province = fields.Char(
string='Province',
)
signatory_name = fields.Char(
string='Signatory',
help='Name of person who signed for delivery',
)

View File

@@ -0,0 +1,39 @@
from odoo import _, api, models
from odoo.addons.payment import utils as payment_utils
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
@api.model
def _get_compatible_providers(self, *args, sale_order_id=None, report=None, **kwargs):
""" Override of payment to allow only COD providers if allow_cash_on_delivery is enabled for
selected UPS delivery method.
:param int sale_order_id: The sales order to be paid, if any, as a `sale.order` id.
:param dict report: The availability report.
:return: The compatible providers.
:rtype: payment.provider
"""
compatible_providers = super()._get_compatible_providers(
*args, sale_order_id=sale_order_id, report=report, **kwargs
)
sale_order = self.env['sale.order'].browse(sale_order_id).exists()
if (
sale_order.carrier_id.delivery_type in ('fusion_ups', 'fusion_ups_rest')
and sale_order.carrier_id.allow_cash_on_delivery
):
unfiltered_providers = compatible_providers
compatible_providers = compatible_providers.filtered(
lambda p: p.custom_mode == 'cash_on_delivery'
)
payment_utils.add_to_report(
report,
unfiltered_providers - compatible_providers,
available=False,
reason=_("UPS provider is configured to use only Collect on Delivery."),
)
return compatible_providers

View File

@@ -0,0 +1,15 @@
from odoo import fields, models
class PackageType(models.Model):
_inherit = "stock.package.type"
package_carrier_type = fields.Selection(selection_add=[
('fusion_canada_post', 'Canada Post'),
('fusion_ups', 'UPS'),
('fusion_ups_rest', 'UPS (REST)'),
('fusion_fedex', 'FedEx'),
('fusion_fedex_rest', 'FedEx (REST)'),
('fusion_dhl', 'DHL Express'),
('fusion_dhl_rest', 'DHL Express (REST)'),
])

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class ResCompany(models.Model):
_inherit = "res.company"
def _default_uom_setting(self):
weight_uom_id = self.env.ref('uom.product_uom_kgm', raise_if_not_found=False)
if not weight_uom_id:
uom_categ_id = self.env.ref('uom.product_uom_categ_kgm').id
weight_uom_id = self.env['uom.uom'].search([('category_id', '=', uom_categ_id), ('factor', '=', 1)], limit=1)
return weight_uom_id
weight_unit_of_measurement_id = fields.Many2one('uom.uom',string="Weight Unit Of Measurement",help="Unit of measure for item weight",default=_default_uom_setting)

View File

@@ -0,0 +1,11 @@
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
property_ups_carrier_account = fields.Char(
string="UPS Account Number",
company_dependent=True,
help="UPS carrier account number for bill-my-account shipping.",
)

View File

@@ -0,0 +1,70 @@
from odoo import models, fields
class SaleOrder(models.Model):
_inherit = 'sale.order'
fusion_service_code = fields.Char(
string='Service Code',
copy=False,
help='Carrier service code selected during shipping method selection',
)
# Per-package dimensions & service info (replaces single-dim fields).
fusion_package_ids = fields.One2many(
'fusion.order.package',
'sale_order_id',
string='Packages',
copy=False,
)
# Legacy single-package dimension fields (kept for backward
# compatibility with existing orders -- new orders populate the
# One2many above and fill these from the first package).
fusion_package_length = fields.Float(
string='Package Length', copy=False)
fusion_package_width = fields.Float(
string='Package Width', copy=False)
fusion_package_height = fields.Float(
string='Package Height', copy=False)
fusion_shipment_ids = fields.One2many(
'fusion.shipment',
'sale_order_id',
string='Shipments',
)
fusion_shipment_count = fields.Integer(
string='Shipments',
compute='_compute_fusion_shipment_count',
)
partner_ups_carrier_account = fields.Char(
related='partner_id.property_ups_carrier_account',
string="UPS Carrier Account",
readonly=False,
)
ups_bill_my_account = fields.Boolean(
related='carrier_id.ups_bill_my_account',
string="UPS Bill My Account",
)
def _compute_fusion_shipment_count(self):
for order in self:
order.fusion_shipment_count = len(
order.fusion_shipment_ids)
def action_view_fusion_shipments(self):
self.ensure_one()
shipments = self.env['fusion.shipment'].search(
[('sale_order_id', '=', self.id)]
)
action = {
'type': 'ir.actions.act_window',
'name': 'Shipments',
'res_model': 'fusion.shipment',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
}
if len(shipments) == 1:
action['view_mode'] = 'form'
action['res_id'] = shipments.id
return action

View File

@@ -0,0 +1,34 @@
from odoo import models, fields
class StockPicking(models.Model):
_inherit = 'stock.picking'
fusion_shipment_count = fields.Integer(
string='Shipments',
compute='_compute_fusion_shipment_count',
)
def _compute_fusion_shipment_count(self):
Shipment = self.env['fusion.shipment']
for picking in self:
picking.fusion_shipment_count = Shipment.search_count(
[('picking_id', '=', picking.id)]
)
def action_view_fusion_shipments(self):
self.ensure_one()
shipments = self.env['fusion.shipment'].search(
[('picking_id', '=', self.id)]
)
action = {
'type': 'ir.actions.act_window',
'name': 'Shipments',
'res_model': 'fusion.shipment',
'view_mode': 'list,form',
'domain': [('picking_id', '=', self.id)],
}
if len(shipments) == 1:
action['view_mode'] = 'form'
action['res_id'] = shipments.id
return action

View File

@@ -0,0 +1,7 @@
from odoo import models, fields
class UoM(models.Model):
_inherit = 'uom.uom'
fedex_code = fields.Char(string='Fedex Code', help="UoM Code sent to FedEx")