This commit is contained in:
gsinghpal
2026-03-09 15:21:22 -04:00
parent a3e85a23ef
commit acd3fc455e
243 changed files with 20459 additions and 4197 deletions

View File

@@ -1862,6 +1862,10 @@ class SaleOrder(models.Model):
string='Previous Status Before Hold',
help='Status before the application was put on hold (for resuming)',
)
x_fc_previous_status_before_withdrawal = fields.Char(
string='Status Before Withdrawal',
help='Records the status before withdrawal for audit trail.',
)
x_fc_status_before_delivery = fields.Char(
string='Status Before Delivery',
@@ -2327,6 +2331,20 @@ class SaleOrder(models.Model):
help='Date when Page 11 was signed',
)
page11_sign_request_ids = fields.One2many(
'fusion.page11.sign.request', 'sale_order_id',
string='Page 11 Signing Requests',
)
page11_sign_request_count = fields.Integer(
compute='_compute_page11_sign_request_count',
string='Signing Requests',
)
page11_sign_status = fields.Selection([
('none', 'Not Requested'),
('sent', 'Pending Signature'),
('signed', 'Signed'),
], compute='_compute_page11_sign_request_count', string='Page 11 Remote Status')
# ==========================================================================
# PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature)
# Page 12 must be signed by: Authorizer (OT) and Vendor (our company)
@@ -3120,11 +3138,49 @@ class SaleOrder(models.Model):
self.ensure_one()
return self._action_open_document('x_fc_original_application', 'Original ADP Application')
@api.depends('page11_sign_request_ids', 'page11_sign_request_ids.state')
def _compute_page11_sign_request_count(self):
for order in self:
requests = order.page11_sign_request_ids
order.page11_sign_request_count = len(requests)
signed = requests.filtered(lambda r: r.state == 'signed')
pending = requests.filtered(lambda r: r.state == 'sent')
if signed:
order.page11_sign_status = 'signed'
elif pending:
order.page11_sign_status = 'sent'
else:
order.page11_sign_status = 'none'
def action_open_signed_pages(self):
"""Open the Page 11 & 12 PDF."""
self.ensure_one()
return self._action_open_document('x_fc_signed_pages_11_12', 'Page 11 & 12 (Signed)')
def action_request_page11_signature(self):
"""Open the wizard to send Page 11 for remote signing."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Request Page 11 Signature',
'res_model': 'fusion_claims.send.page11.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_sale_order_id': self.id},
}
def action_view_page11_requests(self):
"""Open the list of Page 11 signing requests."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Page 11 Signing Requests',
'res_model': 'fusion.page11.sign.request',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
def action_open_final_application(self):
"""Open the Final Submitted Application PDF."""
self.ensure_one()
@@ -3686,6 +3742,41 @@ class SaleOrder(models.Model):
return True
def action_resubmit_from_withdrawn(self):
"""Return a withdrawn application to Ready for Submission for correction and resubmission."""
self.ensure_one()
if self.x_fc_adp_application_status != 'withdrawn':
raise UserError("This action is only available for withdrawn applications.")
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_submission',
})
user_name = self.env.user.name
resubmit_date = fields.Date.today().strftime('%B %d, %Y')
message_body = f'''
<div class="alert alert-info" role="alert">
<h5 class="alert-heading"><i class="fa fa-repeat"></i> Application Returned for Resubmission</h5>
<ul>
<li><strong>Returned By:</strong> {user_name}</li>
<li><strong>Date:</strong> {resubmit_date}</li>
<li><strong>Status Returned To:</strong> Ready for Submission</li>
</ul>
<hr>
<p class="mb-0"><i class="fa fa-info-circle"></i> Make corrections and click <strong>Submit Application</strong> to resubmit.</p>
</div>
'''
self.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_set_ready_to_bill(self):
"""Open the Ready to Bill wizard to collect POD and delivery date.
@@ -4520,6 +4611,12 @@ class SaleOrder(models.Model):
if 'x_fc_device_placement' in self.env['account.move.line']._fields:
line_vals['x_fc_device_placement'] = line.x_fc_device_placement
# Copy deduction fields so export verification can recalculate correctly
if 'x_fc_deduction_type' in self.env['account.move.line']._fields:
line_vals['x_fc_deduction_type'] = line.x_fc_deduction_type or 'none'
if 'x_fc_deduction_value' in self.env['account.move.line']._fields:
line_vals['x_fc_deduction_value'] = line.x_fc_deduction_value or 0
# Store BOTH portions on invoice line (for display)
if 'x_fc_adp_portion' in self.env['account.move.line']._fields:
line_vals['x_fc_adp_portion'] = adp_portion
@@ -5170,13 +5267,13 @@ class SaleOrder(models.Model):
f'border-bottom:2px solid #4a5568;{font}"'
)
cell_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;"'
'style="padding:7px 10px;font-size:12px;'
'border-bottom:1px solid rgba(128,128,128,0.15);"'
)
alt_row = 'style="background:#f7fafc;"'
alt_row = 'style="background:rgba(128,128,128,0.06);"'
amt_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;text-align:right;"'
'style="padding:7px 10px;font-size:12px;'
'border-bottom:1px solid rgba(128,128,128,0.15);text-align:right;"'
)
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
@@ -5187,9 +5284,9 @@ class SaleOrder(models.Model):
html = (
'<div style="margin:20px 0;">'
f'<h3 style="color:#1a202c;font-size:15px;font-weight:700;'
f'<h3 style="font-size:15px;font-weight:700;'
f'margin:0 0 10px 0;{font}">Approved Items</h3>'
'<table style="width:100%;border-collapse:collapse;border:1px solid #e2e8f0;">'
'<table style="width:100%;border-collapse:collapse;border:1px solid rgba(128,128,128,0.25);">'
'<thead><tr>'
f'<th {hdr_style}>S/N</th>'
f'<th {hdr_style}>ADP Code</th>'
@@ -5241,13 +5338,13 @@ class SaleOrder(models.Model):
colspan = 5
total_style = (
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"'
)
total_label_style = (
f'style="padding:8px 10px;font-size:12px;font-weight:700;'
f'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"'
)
html += f'<tr style="background:#edf2f7;">'
html += '<tr style="background:rgba(128,128,128,0.08);">'
html += f'<td colspan="{colspan}" {total_label_style}>Total</td>'
html += f'<td {total_style}>${total_adp:,.2f}</td>'
html += f'<td {total_style}>${total_client:,.2f}</td>'
@@ -5529,8 +5626,13 @@ class SaleOrder(models.Model):
_logger.error(f"Failed to send case closed email for {self.name}: {e}")
return False
def _send_withdrawal_email(self, reason=None):
"""Send notification when application is withdrawn."""
def _send_withdrawal_email(self, reason=None, intent=None):
"""Send notification when application is withdrawn.
Args:
reason: Free-text reason for withdrawal.
intent: 'cancel' or 'resubmit' — determines email wording.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
@@ -5542,17 +5644,34 @@ class SaleOrder(models.Model):
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
note_text = 'This application has been withdrawn from the Assistive Devices Program.'
if intent == 'cancel':
note_text = ('This application has been permanently withdrawn and cancelled. '
'The sale order and all related invoices have been cancelled.')
title = 'Application Withdrawn & Cancelled'
subject_suffix = 'Withdrawn & Cancelled'
note_color = '#dc3545'
elif intent == 'resubmit':
note_text = ('This application has been withdrawn for correction and will be resubmitted. '
'The application has been returned to Ready for Submission status.')
title = 'Application Withdrawn for Correction'
subject_suffix = 'Withdrawn for Correction'
note_color = '#d69e2e'
else:
note_text = 'This application has been withdrawn from the Assistive Devices Program.'
title = 'Application Withdrawn'
subject_suffix = 'Withdrawn'
note_color = '#d69e2e'
if reason:
note_text += f'<br/><strong>Reason:</strong> {reason}'
body_html = self._email_build(
title='Application Withdrawn',
title=title,
summary=f'The ADP application for <strong>{client_name}</strong> has been withdrawn.',
email_type='attention',
sections=[('Case Details', self._build_case_detail_rows())],
note=note_text,
note_color='#d69e2e',
note_color=note_color,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
@@ -5560,12 +5679,12 @@ class SaleOrder(models.Model):
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Application Withdrawn - {client_name} - {self.name}',
'subject': f'Application {subject_suffix} - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log('Application Withdrawn email sent', email_to, email_cc)
self._email_chatter_log(f'{title} email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send withdrawal email for {self.name}: {e}")
@@ -5862,7 +5981,10 @@ class SaleOrder(models.Model):
'x_fc_proof_of_delivery',
'x_fc_approval_letter',
]
doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)}
if self.env.context.get('skip_document_chatter'):
doc_changes = {}
else:
doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)}
# Preserve old documents in chatter BEFORE they get replaced or deleted
# This ensures document history is maintained for audit purposes
@@ -5885,7 +6007,7 @@ class SaleOrder(models.Model):
for order in self:
for field_name in document_fields:
if field_name in vals and field_name not in correction_handled:
if field_name in vals and field_name not in correction_handled and not self.env.context.get('skip_document_chatter'):
old_data = getattr(order, field_name, None)
new_data = vals.get(field_name)
label = document_labels.get(field_name, field_name)
@@ -6584,96 +6706,6 @@ class SaleOrder(models.Model):
except Exception as e:
_logger.error(f" Failed to sync serial to invoice line {inv_line.id}: {e}")
def action_sync_adp_fields(self):
"""Manual action to sync all ADP fields to invoices."""
synced_invoices = 0
for order in self:
# First sync Studio fields to FC fields on the SO itself
order._sync_studio_to_fc_fields()
# Then sync to invoices
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
if invoices:
order._sync_fields_to_invoices()
synced_invoices += len(invoices)
# Force refresh of the view
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Fields Synchronized',
'message': f'Synced ADP fields from {len(self)} sale order(s) to {synced_invoices} invoice(s). Please refresh the page to see updated values.',
'type': 'success',
'sticky': False,
}
}
@api.model
def _cron_sync_adp_fields(self):
"""Cron job to sync ADP fields from Sale Orders to Invoices.
Processes all ADP sales created/modified in the last 7 days.
Uses dynamic field mappings from Settings.
"""
from datetime import timedelta
cutoff_date = fields.Datetime.now() - timedelta(days=7)
# Get field mappings
mappings = self._get_field_mappings()
sale_type_field = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.field_sale_type', 'x_fc_sale_type'
)
# Build domain - check FC sale type fields
domain = [('write_date', '>=', cutoff_date)]
or_conditions = []
# Check FC sale type field
if sale_type_field in self._fields:
or_conditions.append((sale_type_field, 'in', ['adp', 'adp_odsp', 'ADP', 'ADP/ODSP']))
# Check claim number fields
claim_field = mappings.get('so_claim_number', 'x_fc_claim_number')
if claim_field in self._fields:
or_conditions.append((claim_field, '!=', False))
# Combine with OR - each '|' must be a separate element in the domain list
if or_conditions:
# Add (n-1) OR operators for n conditions
for _ in range(len(or_conditions) - 1):
domain.append('|')
# Add all conditions
for cond in or_conditions:
domain.append(cond)
try:
orders = self.search(domain)
except Exception as e:
_logger.error(f"Error searching for ADP orders: {e}")
# Fallback to simpler search
orders = self.search([
('write_date', '>=', cutoff_date),
('invoice_ids', '!=', False),
])
synced_count = 0
error_count = 0
for order in orders:
try:
# Only sync if it's an ADP sale
if order._is_adp_sale() or order.x_fc_claim_number:
order._sync_studio_to_fc_fields()
order._sync_fields_to_invoices()
synced_count += 1
except Exception as e:
error_count += 1
_logger.warning(f"Failed to sync order {order.name}: {e}")
_logger.info(f"Fusion Claims sync complete: {synced_count} orders synced, {error_count} errors")
return synced_count
# ==========================================================================
# EMAIL SEND OVERRIDE (Use ADP templates for ADP sales)
# ==========================================================================