# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_name = 'account.move'
_inherit = ['account.move', 'fusion_claims.adp.posting.schedule.mixin']
# ==========================================================================
# FIELD FLAGS
# ==========================================================================
x_fc_is_adp_invoice = fields.Boolean(
compute='_compute_is_adp_invoice_flag',
string='Is ADP Invoice',
)
def _compute_is_adp_invoice_flag(self):
"""Compute if this is an ADP invoice."""
for move in self:
move.x_fc_is_adp_invoice = move._is_adp_invoice()
def _compute_payment_state(self):
"""Extend to auto-advance linked ODSP orders when invoice is paid."""
old_states = {move.id: move.payment_state for move in self}
super()._compute_payment_state()
for move in self:
if move.payment_state in ('paid', 'in_payment') and old_states.get(move.id) != move.payment_state:
move._auto_advance_odsp_on_payment()
x_fc_is_mod_invoice = fields.Boolean(
compute='_compute_is_mod_invoice',
string='Is MOD Invoice',
)
@api.depends('x_fc_invoice_type')
def _compute_is_mod_invoice(self):
for move in self:
move.x_fc_is_mod_invoice = move.x_fc_invoice_type == 'march_of_dimes'
def _auto_advance_odsp_on_payment(self):
"""When invoice is paid, auto-advance linked ODSP order to payment_received."""
self.ensure_one()
so = self.x_fc_source_sale_order_id
if not so:
so = self.invoice_line_ids.mapped('sale_line_ids.order_id')[:1]
if not so or not so.x_fc_is_odsp_sale:
return
current_status = so._get_odsp_status()
eligible = {'pod_submitted', 'submitted_to_ow', 'payment_received'}
if current_status in eligible:
if current_status != 'payment_received':
so._odsp_advance_status(
'payment_received',
f"Payment received on invoice {self.name}. Status auto-advanced.",
)
_logger.info(f"Auto-advanced ODSP order {so.name} to payment_received")
if so.x_fc_odsp_division == 'ontario_works':
self._ow_schedule_delivery_activity(so)
def _ow_schedule_delivery_activity(self, so):
"""Schedule an activity on the OW sale order to arrange delivery."""
from datetime import timedelta
try:
activity_type = self.env.ref('mail.mail_activity_data_todo')
so.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=fields.Date.today() + timedelta(days=3),
summary="Schedule delivery for Ontario Works case",
note="Payment received on invoice %s. Please schedule delivery for this Ontario Works order." % self.name,
user_id=so.user_id.id or self.env.uid,
)
except Exception as e:
_logger.warning(f"Could not schedule delivery activity for {so.name}: {e}")
def action_mod_send_invoice(self):
"""Send MOD invoice to the case worker via email."""
self.ensure_one()
so = self.x_fc_source_sale_order_id
if not so:
from odoo.exceptions import UserError
raise UserError("No linked sale order found.")
# Get case worker email from the sale order's case worker contact
case_worker = so.x_fc_case_worker
if not case_worker or not case_worker.email:
from odoo.exceptions import UserError
raise UserError(
"No case worker with email found on the sale order.\n\n"
"Please add the case worker contact in the sale order first."
)
# Generate MOD Invoice PDF
import base64
from markupsafe import Markup
attachment_ids = []
attachment_names = []
Attachment = self.env['ir.attachment'].sudo()
try:
report = self.env.ref('fusion_claims.action_report_mod_invoice')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
name_parts = (so.partner_id.name or 'Client').strip().split()
first = name_parts[0] if name_parts else 'Client'
last = name_parts[-1] if len(name_parts) > 1 else ''
att = Attachment.create({
'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'account.move',
'res_id': self.id,
'mimetype': 'application/pdf',
})
attachment_ids.append(att.id)
attachment_names.append(att.name)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Failed to generate MOD invoice PDF: {e}")
client_name = so.partner_id.name or 'Client'
sender_name = (so.user_id or self.env.user).name
ref = so.x_fc_case_reference or ''
body_html = so._mod_email_build(
title='Invoice Submitted',
summary=f'Please find attached the invoice for the accessibility modification project '
f'for {client_name}.',
email_type='info',
sections=[('Invoice Details', [
('Invoice', self.name),
('Client', client_name),
('HVMP Reference', ref or 'N/A'),
('Amount', f'${self.amount_total:,.2f}'),
])],
note='Please process the payment as per the Payment Commitment Agreement terms.',
attachments_note=', '.join(attachment_names) if attachment_names else None,
sender_name=sender_name,
)
subject = f'Invoice - {ref} - {client_name}' if ref else f'Invoice - {client_name} - {self.name}'
cc_list = []
if so.user_id and so.user_id.email:
cc_list.append(so.user_id.email)
self.env['mail.mail'].sudo().create({
'subject': subject,
'body_html': body_html,
'email_to': case_worker.email,
'email_cc': ', '.join(cc_list) if cc_list else '',
'model': 'account.move',
'res_id': self.id,
'attachment_ids': [(6, 0, attachment_ids)] if attachment_ids else False,
}).send()
# Log to chatter on both invoice and sale order
so._email_chatter_log('MOD Invoice sent', case_worker.email,
', '.join(cc_list) if cc_list else None,
[f'Invoice: {self.name}'] + ([f'Attachments: {", ".join(attachment_names)}'] if attachment_names else []))
self.message_post(
body=Markup(
f'
'
f'Invoice sent to case worker: {case_worker.name} ({case_worker.email})'
f'
'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Invoice Sent',
'message': f'Invoice sent to {case_worker.name} ({case_worker.email})',
'type': 'success',
'sticky': False,
},
}
# ==========================================================================
# INVOICE TYPE AND CLIENT TYPE FIELDS
# ==========================================================================
x_fc_invoice_type = fields.Selection(
selection=[
('adp', 'ADP'),
('adp_client', 'ADP Client Portion'),
('adp_odsp', 'ADP/ODSP'),
('odsp', 'ODSP'),
('wsib', 'WSIB'),
('direct_private', 'Direct/Private'),
('insurance', 'Insurance'),
('march_of_dimes', 'March of Dimes'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('other', 'Others'),
('rental', 'Rentals'),
('hardship', 'Hardship Funding'),
('regular', 'Regular'),
],
string='Invoice Type',
tracking=True,
help='Type of invoice for billing purposes',
)
x_fc_client_type = fields.Selection(
selection=[
('REG', 'REG'),
('ODS', 'ODS'),
('OWP', 'OWP'),
('ACS', 'ACS'),
('LTC', 'LTC'),
('SEN', 'SEN'),
('CCA', 'CCA'),
],
string='Client Type',
tracking=True,
help='Client type for ADP portion calculations. REG = 75%/25%, others = 100%/0%',
)
# Authorizer Required field - only for certain invoice types
x_fc_authorizer_required = fields.Selection(
selection=[
('yes', 'Yes'),
('no', 'No'),
],
string='Authorizer Required?',
help='For ODSP, Direct/Private, Insurance, Others, and Rentals - specify if an authorizer is needed.',
)
# Computed field to determine if authorizer should be shown
x_fc_show_authorizer = fields.Boolean(
compute='_compute_show_authorizer',
string='Show Authorizer',
)
@api.depends('x_fc_invoice_type', 'x_fc_authorizer_required')
def _compute_show_authorizer(self):
"""Compute whether to show the authorizer field based on invoice type and authorizer_required."""
# Invoice types that require the "Authorizer Required?" question
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
# Invoice types where authorizer is always shown
always_auth_types = ('adp', 'adp_client', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
for move in self:
invoice_type = move.x_fc_invoice_type
if invoice_type in always_auth_types:
move.x_fc_show_authorizer = True
elif invoice_type in optional_auth_types:
move.x_fc_show_authorizer = move.x_fc_authorizer_required == 'yes'
else:
move.x_fc_show_authorizer = False
# Computed field to determine if "Authorizer Required?" question should be shown
x_fc_show_authorizer_question = fields.Boolean(
compute='_compute_show_authorizer_question',
string='Show Authorizer Question',
)
@api.depends('x_fc_invoice_type')
def _compute_show_authorizer_question(self):
"""Compute whether to show the 'Authorizer Required?' field."""
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
for move in self:
move.x_fc_show_authorizer_question = move.x_fc_invoice_type in optional_auth_types
# ==========================================================================
# ADP CLAIM FIELDS (Copied from Sale Order)
# ==========================================================================
x_fc_claim_number = fields.Char(
string='Claim Number',
tracking=True,
copy=False,
help='ADP Claim Number',
)
x_fc_client_ref_1 = fields.Char(
string='Client Reference 1',
help='Primary client reference (e.g., Health Card Number)',
)
x_fc_client_ref_2 = fields.Char(
string='Client Reference 2',
help='Secondary client reference',
)
x_fc_adp_delivery_date = fields.Date(
string='ADP Delivery Date',
help='Date the product was delivered to the client (for ADP billing)',
)
x_fc_service_start_date = fields.Date(
string='Service Start Date',
help='Service period start date (optional)',
)
x_fc_service_end_date = fields.Date(
string='Service End Date',
help='Service period end date (optional)',
)
x_fc_authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer',
help='Authorizer contact for this invoice',
domain="[('is_company', '=', False)]",
)
x_fc_primary_serial = fields.Char(
string='Primary Serial Number',
help='Primary serial number for the invoice (header level). '
'Line-level serials are tracked on individual invoice lines.',
copy=False,
)
# ==========================================================================
# SPLIT INVOICE TRACKING
# ==========================================================================
x_fc_source_sale_order_id = fields.Many2one(
'sale.order',
string='Source Sale Order',
help='The sale order this split invoice was created from',
index=True,
)
x_fc_adp_invoice_portion = fields.Selection(
selection=[
('client', 'Client Portion (25%)'),
('adp', 'ADP Portion (75%)'),
('full', 'Full Invoice'),
],
string='Invoice Portion',
default='full',
help='Tracks whether this is a split invoice for client or ADP portion',
)
is_manually_modified = fields.Boolean(
string='Manually Modified',
default=False,
copy=False,
help='Set to True if invoice fields have been manually edited (not synced from SO)',
)
# ==========================================================================
# COMPUTED TOTALS FOR ADP PORTIONS
# ==========================================================================
x_fc_adp_portion_total = fields.Monetary(
string='Total ADP Portion',
compute='_compute_adp_totals',
currency_field='currency_id',
help='Total ADP portion from the linked sale order',
)
x_fc_client_portion_total = fields.Monetary(
string='Total Client Portion',
compute='_compute_adp_totals',
currency_field='currency_id',
help='Total client portion from the linked sale order',
)
# Sibling invoice totals (shows the OTHER invoice's total)
x_fc_sibling_adp_total = fields.Monetary(
string='ADP Invoice Total',
compute='_compute_sibling_totals',
currency_field='currency_id',
help='Total from the sibling ADP portion invoice',
)
x_fc_sibling_client_total = fields.Monetary(
string='Client Invoice Total',
compute='_compute_sibling_totals',
currency_field='currency_id',
help='Total from the sibling Client portion invoice',
)
# ==========================================================================
# COMPUTED FIELD FOR PRODUCT-ONLY LINES (for ADP Summary)
# ==========================================================================
x_fc_product_lines = fields.One2many(
'account.move.line',
compute='_compute_product_lines',
string='Product Lines Only',
help='Only product lines (excludes sections, notes, and empty lines)',
)
# ==========================================================================
# DEDUCTION TRACKING
# ==========================================================================
x_fc_has_deductions = fields.Boolean(
string='Has Deductions',
compute='_compute_has_deductions',
help='True if any line has a deduction applied',
)
x_fc_total_deduction_amount = fields.Monetary(
string='Total Deduction Amount',
compute='_compute_has_deductions',
currency_field='currency_id',
help='Total amount of deductions applied to ADP portion',
)
# ==========================================================================
# DEVICE VERIFICATION STATUS (for linked Sale Order)
# ==========================================================================
x_fc_needs_device_verification = fields.Boolean(
string='Needs Device Verification',
compute='_compute_needs_device_verification',
help='True if this is a client invoice and the linked SO needs device verification',
)
@api.depends('x_fc_adp_invoice_portion', 'invoice_line_ids.sale_line_ids')
def _compute_needs_device_verification(self):
"""Check if this client invoice needs device verification.
Shows True if:
- This is a client portion invoice
- The linked sale order has ADP devices
- Device verification is NOT complete
"""
for move in self:
needs_verification = False
# Only applies to client invoices
if move.x_fc_adp_invoice_portion == 'client':
# Find linked sale order
sale_order = None
for line in move.invoice_line_ids:
if line.sale_line_ids:
sale_order = line.sale_line_ids[0].order_id
break
if sale_order:
# Check if SO has ADP devices and verification is not complete
if (sale_order.x_fc_total_device_count > 0 and
not sale_order.x_fc_device_verification_complete):
needs_verification = True
move.x_fc_needs_device_verification = needs_verification
# ==========================================================================
# ADP EXPORT TRACKING
# ==========================================================================
adp_exported = fields.Boolean(
string='ADP Exported',
default=False,
copy=False,
help='Has this invoice been exported to ADP format',
)
adp_export_date = fields.Datetime(
string='ADP Export Date',
copy=False,
)
adp_export_count = fields.Integer(
string='Export Count',
default=0,
copy=False,
help='Number of times this invoice has been exported',
)
# ==========================================================================
# ADP BILLING STATUS (Post-Export Lifecycle)
# ==========================================================================
x_fc_adp_billing_status = fields.Selection(
selection=[
('not_applicable', 'Not Applicable'),
('waiting', 'Waiting'),
('submitted', 'Submitted'),
('resubmitted', 'Resubmitted'),
('need_correction', 'Need Correction'),
('payment_issued', 'Payment Issued'),
('cancelled', 'Cancelled'),
],
string='ADP Billing Status',
default='not_applicable',
tracking=True,
copy=False,
help='Tracks the ADP billing lifecycle after invoice export',
)
# (Legacy studio fields removed - all data migrated to x_fc_* fields)
# ==========================================================================
# COMPUTED METHODS
# ==========================================================================
@api.depends('invoice_line_ids', 'invoice_line_ids.x_fc_adp_portion', 'invoice_line_ids.x_fc_client_portion')
def _compute_adp_totals(self):
"""Compute ADP and Client portion totals from invoice lines.
These totals are calculated from the stored portion values on each
invoice line, which were set during invoice creation using the
device codes database pricing and client type calculation.
"""
for move in self:
# Sum portions from invoice lines (values set during invoice creation)
adp_total = sum(move.invoice_line_ids.mapped('x_fc_adp_portion') or [0])
client_total = sum(move.invoice_line_ids.mapped('x_fc_client_portion') or [0])
move.x_fc_adp_portion_total = adp_total
move.x_fc_client_portion_total = client_total
@api.depends('invoice_line_ids', 'invoice_line_ids.product_id', 'invoice_line_ids.quantity', 'invoice_line_ids.display_type')
def _compute_product_lines(self):
"""Compute filtered list of only actual product lines (no sections, notes, or empty lines)."""
for move in self:
# Invoice lines have display_type='product' for actual products (unlike sale.order.line which uses False)
# Filter to only include product lines with quantity > 0
move.x_fc_product_lines = move.invoice_line_ids.filtered(
lambda l: l.display_type == 'product' and l.product_id and l.quantity > 0
)
@api.depends('invoice_line_ids.x_fc_deduction_type', 'invoice_line_ids.x_fc_deduction_value',
'invoice_line_ids.x_fc_adp_portion', 'invoice_line_ids.product_id')
def _compute_has_deductions(self):
"""Compute if invoice has any deductions and total deduction amount."""
for move in self:
product_lines = move.invoice_line_ids.filtered(
lambda l: l.display_type == 'product' and l.product_id and l.quantity > 0
)
# Check if any line has a deduction
has_deductions = any(
line.x_fc_deduction_type and line.x_fc_deduction_type != 'none'
for line in product_lines
)
# Calculate total deduction impact
total_deduction = 0.0
if has_deductions:
for line in product_lines:
if line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
total_deduction += line.x_fc_deduction_value
elif line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
# For percentage, calculate the reduction from normal
client_type = move._get_client_type()
base_pct = 0.75 if client_type == 'REG' else 1.0
adp_price = line.x_fc_adp_max_price or line.price_unit
normal_adp = adp_price * line.quantity * base_pct
actual_adp = line.x_fc_adp_portion
total_deduction += max(0, normal_adp - actual_adp)
move.x_fc_has_deductions = has_deductions
move.x_fc_total_deduction_amount = total_deduction
def _compute_sibling_totals(self):
"""Compute the OTHER portion totals from the linked sale order.
For Client Invoice: show the ADP portion from the sale order
For ADP Invoice: show the Client portion from the sale order
This way the report always shows both portions even if the other
invoice hasn't been created yet.
"""
SaleOrder = self.env['sale.order'].sudo()
for move in self:
adp_total = 0.0
client_total = 0.0
sale_order = False
# Method 1: Find the SO via invoice_ids reverse relation
sale_order = SaleOrder.search([('invoice_ids', 'in', move.id)], limit=1)
# Method 2: Find via invoice_line_ids.sale_line_ids
if not sale_order:
sale_line_ids = move.invoice_line_ids.mapped('sale_line_ids')
if sale_line_ids:
sale_order = sale_line_ids[0].order_id
# Method 3: Parse invoice_origin (e.g., "S29958 (Client 25%)")
if not sale_order and move.invoice_origin:
origin = move.invoice_origin.split(' ')[0] if move.invoice_origin else ''
if origin:
sale_order = SaleOrder.search([('name', '=', origin)], limit=1)
if sale_order:
# Get the portions from the sale order
adp_total = sale_order.x_fc_adp_portion_total or 0.0
client_total = sale_order.x_fc_client_portion_total or 0.0
move.x_fc_sibling_adp_total = adp_total
move.x_fc_sibling_client_total = client_total
@api.onchange('x_fc_invoice_type', 'x_fc_client_type')
def _onchange_invoice_type_client_type(self):
"""Trigger recalculation when invoice type or client type changes."""
for line in self.invoice_line_ids:
line._compute_adp_portions()
# ==========================================================================
# GETTER METHODS
# ==========================================================================
def _get_invoice_type(self):
"""Get invoice type from mapped field or built-in field."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_invoice_type', 'x_fc_invoice_type')
value = getattr(self, field_name, None) if hasattr(self, field_name) else None
if not value and field_name != 'x_fc_invoice_type':
value = self.x_fc_invoice_type
return value or ''
def _get_client_type(self):
"""Get client type from mapped field or built-in field."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type')
value = getattr(self, field_name, None) if hasattr(self, field_name) else None
if not value and field_name != 'x_fc_client_type':
value = self.x_fc_client_type
return value or ''
def _get_authorizer(self):
"""Get authorizer from mapped field or built-in field. Returns name as string."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_inv_authorizer', 'x_fc_authorizer_id')
value = getattr(self, field_name, None) if hasattr(self, field_name) else None
if not value and field_name != 'x_fc_authorizer_id':
value = self.x_fc_authorizer_id
# Return name if it's a record, otherwise return string value
if hasattr(value, 'name'):
return value.name or ''
return str(value) if value else ''
def _get_claim_number(self):
"""Get claim number."""
self.ensure_one()
return self.x_fc_claim_number or ''
def _get_client_ref_1(self):
"""Get client reference 1."""
self.ensure_one()
return self.x_fc_client_ref_1 or ''
def _get_client_ref_2(self):
"""Get client reference 2."""
self.ensure_one()
return self.x_fc_client_ref_2 or ''
def _get_adp_delivery_date(self):
"""Get ADP delivery date."""
self.ensure_one()
return self.x_fc_adp_delivery_date
def _is_adp_invoice(self):
"""Check if this is an ADP invoice type."""
self.ensure_one()
invoice_type = self._get_invoice_type()
if not invoice_type:
return False
invoice_type_lower = str(invoice_type).lower()
return 'adp' in invoice_type_lower
def _get_serial_numbers(self):
"""Get all serial numbers from invoice lines."""
self.ensure_one()
serial_lines = []
for line in self.invoice_line_ids:
serial = line._get_serial_number()
if serial:
serial_lines.append({
'product': line.product_id.name if line.product_id else line.name,
'serial': serial,
'adp_code': line._get_adp_device_code(),
})
return serial_lines
# ==========================================================================
# ACTION METHODS
# ==========================================================================
def action_export_adp_claim(self):
"""Open the ADP export wizard for this invoice."""
self.ensure_one()
return {
'name': 'Export ADP Claim',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.export.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_invoice_ids': [(6, 0, self.ids)],
'active_ids': self.ids,
'active_model': 'account.move',
},
}
def action_recalculate_adp_portions(self):
"""Manually recalculate ADP and Client portions for all lines."""
for move in self:
for line in move.invoice_line_ids:
line._compute_adp_portions()
move._compute_adp_totals()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'ADP Portions Recalculated',
'message': 'All line portions have been recalculated.',
'type': 'success',
'sticky': False,
}
}
def action_sync_to_sale_order(self):
"""Sync ADP fields from this Invoice TO the linked Sale Order and all other invoices.
This is a 2-way sync: Invoice becomes the source of truth, updates SO,
then SO syncs to all linked invoices.
"""
synced_orders = []
for move in self:
_logger.info(f"=== Invoice {move.name} Sync to SO started ===")
# Find linked sale orders via sale_line_ids -> order_id
sale_orders = self.env['sale.order']
for line in move.invoice_line_ids:
if line.sale_line_ids:
sale_orders |= line.sale_line_ids.mapped('order_id')
# Also try direct search
if not sale_orders:
sale_orders = self.env['sale.order'].search([
('invoice_ids', 'in', move.id)
])
if not sale_orders:
_logger.warning(f"No linked sale orders found for invoice {move.name}")
continue
_logger.info(f"Found {len(sale_orders)} linked sale orders: {sale_orders.mapped('name')}")
# Update all linked sale orders with invoice values
for order in sale_orders:
so_vals = {}
# Get values from invoice FC fields
so_vals['x_fc_claim_number'] = move.x_fc_claim_number or False
so_vals['x_fc_client_ref_1'] = move.x_fc_client_ref_1 or False
so_vals['x_fc_client_ref_2'] = move.x_fc_client_ref_2 or False
so_vals['x_fc_adp_delivery_date'] = move.x_fc_adp_delivery_date or False
authorizer_id = move.x_fc_authorizer_id.id if move.x_fc_authorizer_id else False
so_vals['x_fc_authorizer_id'] = authorizer_id or False
# Client Type
if move.x_fc_client_type:
so_vals['x_fc_client_type'] = move.x_fc_client_type
# Service Dates
so_vals['x_fc_service_start_date'] = move.x_fc_service_start_date or False
so_vals['x_fc_service_end_date'] = move.x_fc_service_end_date or False
# Primary Serial Number
if move.x_fc_primary_serial:
so_vals['x_fc_primary_serial'] = move.x_fc_primary_serial
_logger.debug(f" SO vals to write: {so_vals}")
try:
# Update the Sale Order (skip_sync to avoid infinite loop)
order.sudo().with_context(skip_sync=True).write(so_vals)
synced_orders.append(order.name)
_logger.info(f"SUCCESS: Synced fields from invoice {move.name} to SO {order.name}")
# Sync line-level fields (Serial Numbers) from Invoice Lines to SO Lines
self._sync_line_fields_to_sale_order(move, order)
# Now sync from SO to ALL linked invoices (including this one and others)
order.with_context(skip_sync=False)._sync_fields_to_invoices()
except Exception as e:
_logger.error(f"FAILED to sync from invoice {move.name} to SO {order.name}: {e}")
if synced_orders:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Complete',
'message': f'Synced ADP fields to Sale Order(s): {", ".join(synced_orders)} and all linked invoices.',
'type': 'success',
'sticky': False,
}
}
else:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Sync Performed',
'message': 'No linked Sale Orders found for this invoice.',
'type': 'warning',
'sticky': False,
}
}
def _sync_line_fields_to_sale_order(self, invoice, sale_order):
"""Sync Serial Numbers from Invoice lines to corresponding SO lines and sibling invoice lines.
Each invoice line syncs its serial to:
1. The linked SO line's x_fc_serial_number
2. All other invoice lines linked to that same SO line
Also syncs first serial to SO header's x_fc_primary_serial
"""
primary_serial = None # To collect primary serial for SO header
for inv_line in invoice.invoice_line_ids:
if inv_line.display_type in ('line_section', 'line_note'):
continue
# Get Serial Number from invoice line
serial_number = None
if 'x_fc_serial_number' in inv_line._fields:
serial_number = inv_line.x_fc_serial_number
# Track first non-empty serial as primary for SO header
if serial_number and not primary_serial:
primary_serial = serial_number
# Find linked SO lines
so_lines = inv_line.sale_line_ids
if not so_lines:
continue
for so_line in so_lines:
# Update SO line's serial
if serial_number is not None:
so_line_vals = {}
if 'x_fc_serial_number' in so_line._fields:
so_line_vals['x_fc_serial_number'] = serial_number or False
if so_line_vals:
try:
so_line.sudo().with_context(skip_sync=True).write(so_line_vals)
_logger.debug(f" Synced serial '{serial_number}' to SO line {so_line.id}")
except Exception as e:
_logger.error(f" Failed to sync serial to SO line {so_line.id}: {e}")
# Find sibling invoice lines (other invoices linked to same SO line)
sibling_inv_lines = self.env['account.move.line'].sudo().search([
('sale_line_ids', 'in', so_line.id),
('id', '!=', inv_line.id),
('move_id.state', '!=', 'cancel'),
])
for sibling_line in sibling_inv_lines:
sibling_vals = {}
# Sync serial number to sibling invoice line
if 'x_fc_serial_number' in sibling_line._fields:
sibling_vals['x_fc_serial_number'] = serial_number or False
if sibling_vals:
try:
sibling_line.sudo().with_context(skip_sync=True).write(sibling_vals)
_logger.debug(f" Synced serial '{serial_number}' to sibling inv line {sibling_line.id} (inv {sibling_line.move_id.name})")
except Exception as e:
_logger.error(f" Failed to sync serial to sibling inv line {sibling_line.id}: {e}")
# Sync primary serial to SO header (FC field only - Studio fields are read-only)
if primary_serial:
so_header_vals = {'x_fc_primary_serial': primary_serial}
try:
sale_order.sudo().with_context(skip_sync=True).write(so_header_vals)
_logger.debug(f" Synced primary serial to SO header {sale_order.name}: {so_header_vals}")
except Exception as e:
_logger.error(f" Failed to sync primary serial to SO {sale_order.name}: {e}")
# ==========================================================================
# OVERRIDE WRITE
# ==========================================================================
def write(self, vals):
"""Override write to trigger recalculation and handle billing status changes."""
# Track billing status changes for reminder scheduling
new_billing_status = vals.get('x_fc_adp_billing_status')
new_payment_state = vals.get('payment_state')
result = super().write(vals)
# Check if we need to recalculate
ICP = self.env['ir.config_parameter'].sudo()
invoice_type_field = ICP.get_param('fusion_claims.field_invoice_type', 'x_fc_invoice_type')
client_type_field = ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type')
trigger_fields = {'x_fc_invoice_type', 'x_fc_client_type', invoice_type_field, client_type_field}
if trigger_fields & set(vals.keys()):
for move in self:
for line in move.invoice_line_ids:
line._compute_adp_portions()
# Auto-update ADP billing status when payment is registered
# payment_state values: 'not_paid', 'in_payment', 'paid', 'partial', 'reversed', 'invoicing_legacy'
if new_payment_state in ('paid', 'in_payment'):
for move in self:
# Only for ADP invoices that are in 'submitted' or 'resubmitted' status
if (move.x_fc_adp_invoice_portion == 'adp' and
move.x_fc_adp_billing_status in ('submitted', 'resubmitted', 'waiting')):
move.with_context(skip_payment_status_update=True).write({
'x_fc_adp_billing_status': 'payment_issued'
})
_logger.info(f"Auto-updated ADP billing status to 'payment_issued' for {move.name}")
# Handle billing status changes for reminders
if new_billing_status:
for move in self:
if move.x_fc_adp_invoice_portion != 'adp':
# Only track billing status for ADP invoices
continue
if new_billing_status == 'waiting':
# Schedule billing deadline reminder
move._schedule_billing_reminder()
elif new_billing_status == 'need_correction':
# Schedule correction reminders for all configured users
move._schedule_correction_reminders()
elif new_billing_status == 'submitted':
# Complete billing deadline activity
move._complete_adp_activities('fusion_claims.mail_activity_type_adp_billing')
elif new_billing_status == 'resubmitted':
# Complete correction activities
move._complete_adp_activities('fusion_claims.mail_activity_type_adp_correction')
elif new_billing_status == 'payment_issued':
# Complete all remaining activities
move._complete_adp_activities('fusion_claims.mail_activity_type_adp_billing')
move._complete_adp_activities('fusion_claims.mail_activity_type_adp_correction')
return result
# ==========================================================================
# DEVICE APPROVAL WIZARD FROM INVOICE
# ==========================================================================
def action_open_device_approval_wizard(self):
"""Open the Device Approval Wizard for the linked Sale Order.
This allows users to complete device verification from the Client Invoice
when the invoice was created before ADP approval.
"""
self.ensure_one()
# Find linked sale order
sale_order = None
for line in self.invoice_line_ids:
if line.sale_line_ids:
sale_order = line.sale_line_ids[0].order_id
break
if not sale_order:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Linked Sale Order',
'message': 'Cannot find linked Sale Order for device verification.',
'type': 'warning',
}
}
# Check if verification is already complete
if sale_order.x_fc_device_verification_complete:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Already Verified',
'message': f'Device verification is already complete for {sale_order.name}.',
'type': 'info',
}
}
# Open the device approval wizard for the linked sale order
return {
'name': 'Verify Device Approval',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.device.approval.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': sale_order.id,
'active_model': 'sale.order',
},
}
# ==========================================================================
# EMAIL SEND OVERRIDE (Use ADP templates for ADP invoices)
# ==========================================================================
def action_invoice_sent(self):
"""Override to use ADP email template for ADP invoices.
When sending an invoice for an ADP sale, automatically selects the
ADP landscape template instead of the default template.
"""
self.ensure_one()
# Check if this is an ADP invoice
if self._is_adp_invoice():
# Get the ADP invoice template
template_xmlid = 'fusion_claims.email_template_adp_invoice'
try:
template = self.env.ref(template_xmlid, raise_if_not_found=False)
if template:
# Open the mail compose wizard with the ADP template pre-selected
ctx = {
'default_model': 'account.move',
'default_res_ids': self.ids,
'default_template_id': template.id,
'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature',
'default_composition_mode': 'comment',
'mark_invoice_as_sent': True,
'force_email': True,
'model_description': self.type_name,
}
return {
'type': 'ir.actions.act_window',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': ctx,
}
except Exception as e:
_logger.warning(f"Could not load ADP email template: {e}")
# Fall back to standard behavior for non-ADP invoices
return super().action_invoice_sent()
# ==========================================================================
# ADP ACTIVITY REMINDER METHODS
# ==========================================================================
def _schedule_or_renew_adp_activity(self, activity_type_xmlid, user_id, date_deadline, summary, note=False):
"""Schedule or renew an ADP-related activity.
If an activity of the same type for the same user already exists,
update its deadline instead of creating a duplicate.
"""
self.ensure_one()
try:
activity_type = self.env.ref(activity_type_xmlid)
except ValueError:
_logger.warning(f"Activity type not found: {activity_type_xmlid}")
return
# Search for existing activity of this type for this user
existing = self.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
and a.user_id.id == user_id
)
if existing:
# Update existing activity
existing[0].write({
'date_deadline': date_deadline,
'summary': summary,
'note': note or existing[0].note,
})
_logger.info(f"Renewed ADP activity for invoice {self.name}: {summary} -> {date_deadline}")
else:
# Create new activity
self.activity_schedule(
activity_type_xmlid,
date_deadline=date_deadline,
summary=summary,
note=note,
user_id=user_id
)
_logger.info(f"Scheduled new ADP activity for invoice {self.name}: {summary} -> {date_deadline}")
def _complete_adp_activities(self, activity_type_xmlid):
"""Complete all activities of a specific type for this record."""
self.ensure_one()
try:
activity_type = self.env.ref(activity_type_xmlid)
except ValueError:
return
activities = self.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
)
for activity in activities:
activity.action_feedback(feedback='Completed automatically')
_logger.info(f"Completed ADP activity for invoice {self.name}: {activity.summary}")
def _schedule_billing_reminder(self):
"""Schedule a billing deadline reminder for the configured billing person.
Reminds on Monday to complete billing by Wednesday 6 PM of the posting week.
"""
self.ensure_one()
if not self._is_adp_invoice():
return
# Get the configured billing reminder user
billing_user = self._get_adp_billing_reminder_user()
if not billing_user:
_logger.warning(f"No billing reminder user configured, cannot schedule reminder for {self.name}")
return
# Calculate the next posting date and Monday of that week
next_posting = self._get_next_posting_date()
reminder_date = self._get_posting_week_monday(next_posting)
deadline_wednesday = self._get_posting_week_wednesday(next_posting)
# Don't schedule if reminder date is in the past
from datetime import date
if reminder_date < date.today():
next_posting = self._get_next_posting_date(next_posting)
reminder_date = self._get_posting_week_monday(next_posting)
deadline_wednesday = self._get_posting_week_wednesday(next_posting)
summary = f"Complete ADP billing for {self.name} by Wednesday 6 PM"
note = f"Submit invoice {self.name} to ADP by {deadline_wednesday.strftime('%A, %B %d, %Y')} 6 PM for the {next_posting.strftime('%B %d')} posting."
self._schedule_or_renew_adp_activity(
'fusion_claims.mail_activity_type_adp_billing',
billing_user.id,
reminder_date,
summary,
note
)
def _schedule_correction_reminders(self):
"""Schedule correction reminders for all configured correction alert users.
Creates an activity for each user when an invoice needs correction.
"""
self.ensure_one()
if not self._is_adp_invoice():
return
# Get all configured correction reminder users
correction_users = self._get_adp_correction_reminder_users()
if not correction_users:
_logger.warning(f"No correction reminder users configured, cannot schedule reminder for {self.name}")
return
# Calculate the next submission deadline
next_posting = self._get_next_posting_date()
deadline_wednesday = self._get_posting_week_wednesday(next_posting)
from datetime import date
if deadline_wednesday < date.today():
next_posting = self._get_next_posting_date(next_posting)
deadline_wednesday = self._get_posting_week_wednesday(next_posting)
summary = f"Invoice {self.name} needs correction - resubmit to ADP"
note = f"This invoice was rejected by ADP and needs correction. Please fix and resubmit by {deadline_wednesday.strftime('%A, %B %d, %Y')} 6 PM."
for user in correction_users:
self._schedule_or_renew_adp_activity(
'fusion_claims.mail_activity_type_adp_correction',
user.id,
deadline_wednesday,
summary,
note
)
def _cron_renew_billing_reminders(self):
"""Cron job to renew overdue billing reminders.
For invoices with 'waiting' status that have overdue billing activities,
reschedule them to the next posting week's Monday.
"""
from datetime import date
today = date.today()
try:
activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_billing')
except ValueError:
_logger.warning("ADP Billing activity type not found")
return
# Find ADP invoices in 'waiting' status with overdue billing activities
waiting_invoices = self.search([
('x_fc_adp_invoice_portion', '=', 'adp'),
('x_fc_adp_billing_status', '=', 'waiting'),
])
for invoice in waiting_invoices:
overdue_activities = invoice.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
and a.date_deadline < today
)
if overdue_activities:
invoice._schedule_billing_reminder()
_logger.info(f"Renewed overdue billing reminder for invoice {invoice.name}")
def _cron_renew_correction_reminders(self):
"""Cron job to renew overdue correction reminders.
For invoices with 'need_correction' status that have overdue activities,
reschedule them to the next posting week's Wednesday.
"""
from datetime import date
today = date.today()
try:
activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_correction')
except ValueError:
_logger.warning("ADP Correction activity type not found")
return
# Find ADP invoices needing correction with overdue activities
correction_invoices = self.search([
('x_fc_adp_invoice_portion', '=', 'adp'),
('x_fc_adp_billing_status', '=', 'need_correction'),
])
for invoice in correction_invoices:
overdue_activities = invoice.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
and a.date_deadline < today
)
if overdue_activities:
invoice._schedule_correction_reminders()
_logger.info(f"Renewed overdue correction reminder for invoice {invoice.name}")