1218 lines
52 KiB
Python
1218 lines
52 KiB
Python
# -*- 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])
|
|
client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '')
|
|
att = Attachment.create({
|
|
'name': f'Invoice - {client_name} - {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 <strong>{client_name}</strong>.',
|
|
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'<div class="alert alert-info">'
|
|
f'<strong>Invoice sent to case worker:</strong> {case_worker.name} ({case_worker.email})'
|
|
f'</div>'
|
|
),
|
|
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}")
|