This commit is contained in:
gsinghpal
2026-02-27 14:32:32 -05:00
parent b649246e81
commit b925766966
80 changed files with 7831 additions and 1041 deletions

View File

@@ -22,7 +22,7 @@ class SaleOrder(models.Model):
for order in self:
name = order.name or ''
if order.partner_id and order.partner_id.name:
name = f"{name} -- {order.partner_id.name}"
name = f"{name} - {order.partner_id.name}"
order.display_name = name
# ==========================================================================
@@ -1318,17 +1318,12 @@ class SaleOrder(models.Model):
att_ids = []
att_names = []
# 1. Signed SA Form -- reuse existing attachment created by attachment=True
signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else (
'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None)
if signed_field:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', signed_field),
], order='id desc', limit=1)
if att:
att_ids.append(att.id)
att_id = self._get_and_prepare_field_attachment(signed_field, 'Signed SA Form')
if att_id:
att_ids.append(att_id)
att_names.append('Signed SA Form')
# 2. Internal POD -- generate on-the-fly from the standard report
@@ -1353,12 +1348,14 @@ class SaleOrder(models.Model):
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'Invoice_{invoice.name}.pdf',
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
@@ -1388,16 +1385,10 @@ class SaleOrder(models.Model):
att_ids = []
att_names = []
# 1. Approval document
if self.x_fc_odsp_approval_document:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_odsp_approval_document'),
], order='id desc', limit=1)
if att:
att_ids.append(att.id)
att_names.append('ODSP Approval Document')
att_id = self._get_and_prepare_field_attachment('x_fc_odsp_approval_document', 'ODSP Approval Document')
if att_id:
att_ids.append(att_id)
att_names.append('ODSP Approval Document')
# 2. Internal POD
try:
@@ -1426,12 +1417,14 @@ class SaleOrder(models.Model):
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'Invoice_{invoice.name}.pdf',
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
@@ -1680,44 +1673,6 @@ class SaleOrder(models.Model):
)
_logger.info("POD signature applied to approval form for %s", self.name)
# ==========================================================================
# DELIVERY STATUS FIELDS
# ==========================================================================
x_fc_delivery_status = fields.Selection(
selection=[
('waiting', 'Waiting'),
('waiting_approval', 'Waiting for Approval'),
('ready', 'Ready for Delivery'),
('scheduled', 'Delivery Scheduled'),
('shipped_warehouse', 'Shipped to Warehouse'),
('received_warehouse', 'Received in Warehouse'),
('delivered', 'Delivered'),
('hold', 'Hold'),
('cancelled', 'Cancelled'),
],
string='Delivery Status',
tracking=True,
help='Current delivery status of the order',
)
x_fc_delivery_datetime = fields.Datetime(
string='Delivery Date & Time',
tracking=True,
help='Scheduled or actual delivery date and time',
)
# Computed field to show/hide delivery datetime
x_fc_show_delivery_datetime = fields.Boolean(
compute='_compute_show_delivery_datetime',
string='Show Delivery DateTime',
)
@api.depends('x_fc_delivery_status')
def _compute_show_delivery_datetime(self):
"""Compute whether to show delivery datetime field."""
for order in self:
order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered')
# ==========================================================================
# ADP CLAIM FIELDS
# ==========================================================================
@@ -1729,11 +1684,11 @@ class SaleOrder(models.Model):
)
x_fc_client_ref_1 = fields.Char(
string='Client Reference 1',
help='Primary client reference (e.g., Health Card Number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
x_fc_client_ref_2 = fields.Char(
string='Client Reference 2',
help='Secondary client reference',
help='Last four digits of the client\'s health card number. Example: 1234',
)
x_fc_adp_delivery_date = fields.Date(
string='ADP Delivery Date',
@@ -2988,10 +2943,136 @@ class SaleOrder(models.Model):
# ==========================================================================
# PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler)
# ==========================================================================
MIME_TO_EXT = {
'application/pdf': '.pdf',
'application/xml': '.xml',
'text/xml': '.xml',
'text/plain': '.txt',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-excel': '.xls',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/zip': '.zip',
'application/octet-stream': '',
}
FIELD_NAME_TEMPLATE = {
'x_fc_final_submitted_application': '{first}_{last}.pdf',
'x_fc_xml_file': '{first}_{last}_data.xml',
'x_fc_original_application': '{first}_{last}_Original_Application.pdf',
'x_fc_signed_pages_11_12': '{first}_{last}_Signed_Pages.pdf',
'x_fc_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_approval_letter': '{first}_{last}_Approval_Letter.pdf',
'x_fc_sa_signed_form': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_physical_signed_copy': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_approval_form': '{first}_{last}_SA_Approval.pdf',
'x_fc_odsp_approval_document': '{first}_{last}_ODSP_Approval.pdf',
'x_fc_odsp_authorizer_letter': '{first}_{last}_ODSP_Authorizer_Letter.pdf',
'x_fc_ow_discretionary_form': '{first}_{last}_OW_Discretionary_Form.pdf',
'x_fc_ow_authorizer_letter': '{first}_{last}_OW_Authorizer_Letter.pdf',
'x_fc_mod_drawing': '{first}_{last}_Drawing.pdf',
'x_fc_mod_initial_photos': '{first}_{last}_Initial_Photos.pdf',
'x_fc_mod_pca_document': '{first}_{last}_PCA_Document.pdf',
'x_fc_mod_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_mod_completion_photos': '{first}_{last}_Completion_Photos.pdf',
}
FIELD_FILENAME_MAP = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
'x_fc_approval_letter': 'x_fc_approval_letter_filename',
'x_fc_sa_signed_form': 'x_fc_sa_signed_form_filename',
'x_fc_sa_physical_signed_copy': 'x_fc_sa_physical_signed_copy_filename',
'x_fc_sa_approval_form': 'x_fc_sa_approval_form_filename',
'x_fc_odsp_approval_document': 'x_fc_odsp_approval_document_filename',
'x_fc_odsp_authorizer_letter': 'x_fc_odsp_authorizer_letter_filename',
'x_fc_ow_discretionary_form': 'x_fc_ow_discretionary_form_filename',
'x_fc_ow_authorizer_letter': 'x_fc_ow_authorizer_letter_filename',
'x_fc_mod_drawing': 'x_fc_mod_drawing_filename',
'x_fc_mod_initial_photos': 'x_fc_mod_initial_photos_filename',
'x_fc_mod_pca_document': 'x_fc_mod_pca_filename',
'x_fc_mod_proof_of_delivery': 'x_fc_mod_pod_filename',
'x_fc_mod_completion_photos': 'x_fc_mod_completion_photos_filename',
}
def _get_ext_from_mime(self, mimetype):
"""Return a file extension (with dot) for a MIME type."""
return self.MIME_TO_EXT.get(mimetype or '', '')
def _get_client_name_parts(self):
"""Return (first_name, last_name) cleaned for filenames."""
full_name = (self.partner_id.name or 'Client').strip()
parts = full_name.split()
first = parts[0] if parts else 'Client'
last = parts[-1] if len(parts) > 1 else ''
clean = lambda s: s.replace(',', '').replace("'", '').replace('"', '')
return clean(first), clean(last)
def _build_attachment_name(self, field_name, mimetype=None):
"""Build the proper filename for a field attachment.
Uses FIELD_NAME_TEMPLATE for known fields with Firstname_Lastname convention.
For the XML file, respects the actual mimetype (could be .xml, .docx, .txt).
"""
first, last = self._get_client_name_parts()
template = self.FIELD_NAME_TEMPLATE.get(field_name)
if template:
name = template.format(first=first, last=last)
if field_name == 'x_fc_xml_file' and mimetype:
ext = self._get_ext_from_mime(mimetype)
if ext and ext != '.xml':
name = f'{first}_{last}_data{ext}'
return name
ext = self._get_ext_from_mime(mimetype) if mimetype else '.pdf'
return f'{first}_{last}_Document{ext}'
def _prepare_attachment_for_email(self, attachment, field_name=None, label=None):
"""Rename an attachment to a clean, professional filename.
Always renames to the standard convention (Firstname_Lastname pattern)
so recipients get consistently named files regardless of what was uploaded.
"""
if not attachment:
return None
new_name = self._build_attachment_name(field_name, attachment.mimetype)
if attachment.name == new_name:
return attachment.id
try:
attachment.sudo().write({'name': new_name})
except Exception:
_logger.warning("Could not rename attachment %s to %s", attachment.id, new_name)
return attachment.id
def _get_and_prepare_field_attachment(self, field_name, label=None):
"""Find the ir.attachment for a binary field, rename it properly, return its id.
Convenience wrapper combining _get_document_attachment + _prepare_attachment_for_email.
Returns None if the field has no data or attachment is not found.
"""
self.ensure_one()
if not getattr(self, field_name, None):
return None
attachment = self._get_document_attachment(field_name)
if not attachment:
return None
return self._prepare_attachment_for_email(attachment, field_name=field_name, label=label)
def _get_document_attachment(self, field_name):
"""Get the ir.attachment record for a binary field stored as attachment."""
self.ensure_one()
# Find the attachment by field name - get most recent one
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
@@ -3001,9 +3082,9 @@ class SaleOrder(models.Model):
def _get_or_create_attachment(self, field_name, document_label):
"""Get the current attachment for a binary field (attachment=True).
For attachment=True fields, Odoo creates attachments automatically.
We find the one with res_field set and return it.
We find the one with res_field set, ensure it has a proper name, and return it.
"""
self.ensure_one()
@@ -3011,43 +3092,20 @@ class SaleOrder(models.Model):
if not data:
return None
# For attachment=True fields, Odoo creates/updates an attachment with res_field set
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', field_name),
], order='id desc', limit=1)
if attachment:
# If attachment name is the field name (Odoo default), use the actual filename
if attachment.name == field_name:
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
filename_field = filename_mapping.get(field_name)
if filename_field:
filename = getattr(self, filename_field, None)
if filename and filename != field_name:
attachment.sudo().write({'name': filename})
self._prepare_attachment_for_email(attachment, field_name=field_name, label=document_label)
return attachment
# Fallback: create attachment manually (shouldn't happen for attachment=True fields)
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
filename_field = filename_mapping.get(field_name)
filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf'
filename = self._build_attachment_name(field_name)
attachment = self.env['ir.attachment'].sudo().create({
'name': filename or f'{document_label}.pdf',
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
@@ -3399,16 +3457,20 @@ class SaleOrder(models.Model):
}
def action_complete_assessment(self):
"""Open wizard to mark assessment as completed with date."""
"""Open wizard to mark assessment as completed with date.
Allowed from 'quotation' (override) or 'assessment_scheduled' (normal flow)."""
self.ensure_one()
if self.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
if self.x_fc_adp_application_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
return {
'name': 'Assessment Completed',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.assessment.completed.wizard',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'active_id': self.id,
@@ -4627,82 +4689,67 @@ class SaleOrder(models.Model):
# ==========================================================================
# DOCUMENT CHATTER POSTING
# ==========================================================================
def _post_document_to_chatter(self, field_name, document_label=None):
"""Post a document attachment to the chatter with a link.
def _post_document_to_chatter(self, field_name, document_label=None, preserve_copy=False):
"""Post a document to the chatter, reusing the existing field attachment.
By default, references the existing ir.attachment (created by Odoo for
attachment=True fields) instead of creating a duplicate.
Args:
field_name: The binary field name (e.g., 'x_fc_final_submitted_application')
document_label: Optional label for the document (defaults to field string)
preserve_copy: If True, creates a separate copy (used when the original
is about to be deleted/replaced and we need to keep a snapshot).
"""
self.ensure_one()
# Map field names to filename fields
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
data_field = field_name
filename_field = filename_mapping.get(field_name, field_name + '_filename')
data = getattr(self, data_field, None)
original_filename = getattr(self, filename_field, None) or 'document'
data = getattr(self, field_name, None)
if not data:
return
# Get document label from field definition if not provided
if not document_label:
field_obj = self._fields.get(data_field)
document_label = field_obj.string if field_obj else data_field
# Check for existing attachments with same name for revision numbering
existing_count = self.env['ir.attachment'].sudo().search_count([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('name', '=like', original_filename.rsplit('.', 1)[0] + '%'),
])
# Add revision number if this is a replacement
if existing_count > 0 and '(replaced)' in (document_label or ''):
# This is an old document being replaced - add revision number
base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '')
filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}"
field_obj = self._fields.get(field_name)
document_label = field_obj.string if field_obj else field_name
if preserve_copy:
proper_name = self._build_attachment_name(field_name)
base, _, ext = proper_name.rpartition('.')
if base:
copy_name = f"{base}_archived.{ext}"
else:
copy_name = f"{proper_name}_archived"
attachment = self.env['ir.attachment'].sudo().create({
'name': copy_name,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
else:
filename = original_filename
# Create attachment with the original/revised filename
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
# Post message with attachment (shows as native Odoo attachment with preview)
attachment = self._get_document_attachment(field_name)
if not attachment:
return
self._prepare_attachment_for_email(attachment, field_name=field_name)
user_name = self.env.user.name
now = fields.Datetime.now()
body = Markup("""
<p><strong>{label}</strong> uploaded by <b>{user}</b></p>
<p class="text-muted small">{timestamp}</p>
""").format(
body = Markup(
'<p><strong>{label}</strong> uploaded by <b>{user}</b></p>'
'<p class="text-muted small">{timestamp}</p>'
).format(
label=document_label,
user=user_name,
timestamp=now.strftime('%Y-%m-%d %H:%M:%S')
timestamp=now.strftime('%Y-%m-%d %H:%M:%S'),
)
# Use attachment_ids to show as native attachment with preview capability
self.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=[attachment.id],
)
return attachment
# ==========================================================================
@@ -4844,28 +4891,15 @@ class SaleOrder(models.Model):
if not to_emails and not cc_emails:
return False
# Reuse existing field attachments (created by Odoo for attachment=True fields)
# instead of creating duplicates
attachments = []
attachment_names = []
Attachment = self.env['ir.attachment'].sudo()
if self.x_fc_final_submitted_application:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_final_submitted_application'),
], order='id desc', limit=1)
if att:
attachments.append(att.id)
att_id = self._get_and_prepare_field_attachment('x_fc_final_submitted_application', 'ADP Application')
if att_id:
attachments.append(att_id)
attachment_names.append('Final ADP Application (PDF)')
if self.x_fc_xml_file:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_xml_file'),
], order='id desc', limit=1)
if att:
attachments.append(att.id)
att_id = self._get_and_prepare_field_attachment('x_fc_xml_file', 'ADP XML Data')
if att_id:
attachments.append(att_id)
attachment_names.append('XML Data File')
client_name = recipients.get('client', self.partner_id).name or 'Client'
@@ -5113,8 +5147,144 @@ class SaleOrder(models.Model):
_logger.error(f"Failed to send billed email for {self.name}: {e}")
return False
def _build_approved_items_html(self, for_pdf=False):
"""Build an HTML table of approved order line items.
Columns: S/N, ADP Code, Device Type, Product Name, Qty,
ADP Portion, Client Portion, Deduction.
"""
self.ensure_one()
lines = self.order_line.filtered(
lambda l: l.product_id and l.display_type not in ('line_section', 'line_note')
)
if not lines:
return ''
font = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"
if for_pdf:
font = "font-family:Arial,Helvetica,sans-serif;"
hdr_style = (
f'style="background:#2d3748;color:#fff;padding:8px 10px;'
f'font-size:11px;font-weight:600;text-align:left;'
f'border-bottom:2px solid #4a5568;{font}"'
)
cell_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;"'
)
alt_row = 'style="background:#f7fafc;"'
amt_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;text-align:right;"'
)
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
has_deduction = any(
l.x_fc_deduction_type and l.x_fc_deduction_type != 'none'
for l in lines
)
html = (
'<div style="margin:20px 0;">'
f'<h3 style="color:#1a202c;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;">'
'<thead><tr>'
f'<th {hdr_style}>S/N</th>'
f'<th {hdr_style}>ADP Code</th>'
f'<th {hdr_style}>Device Type</th>'
f'<th {hdr_style}>Product</th>'
f'<th {hdr_r}>Qty</th>'
f'<th {hdr_r}>ADP Portion</th>'
f'<th {hdr_r}>Client Portion</th>'
)
if has_deduction:
html += f'<th {hdr_r}>Deduction</th>'
html += '</tr></thead><tbody>'
total_adp = 0.0
total_client = 0.0
for idx, line in enumerate(lines, 1):
row_attr = alt_row if idx % 2 == 0 else ''
adp_code = line._get_adp_code_for_report()
device_type = line._get_adp_device_type()
product_name = line.product_id.name or '-'
if len(product_name) > 40 and not for_pdf:
product_name = product_name[:37] + '...'
qty = int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty
adp_portion = line.x_fc_adp_portion or 0.0
client_portion = line.x_fc_client_portion or 0.0
total_adp += adp_portion
total_client += client_portion
deduction_str = '-'
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
deduction_str = f'{line.x_fc_deduction_value:.0f}%'
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
deduction_str = f'${line.x_fc_deduction_value:,.2f}'
html += f'<tr {row_attr}>'
html += f'<td {cell_style}>{idx}</td>'
html += f'<td {cell_style}>{adp_code}</td>'
html += f'<td {cell_style}>{device_type}</td>'
html += f'<td {cell_style}>{product_name}</td>'
html += f'<td {amt_style}>{qty}</td>'
html += f'<td {amt_style}>${adp_portion:,.2f}</td>'
html += f'<td {amt_style}>${client_portion:,.2f}</td>'
if has_deduction:
html += f'<td {amt_style}>{deduction_str}</td>'
html += '</tr>'
# Totals row
colspan = 5
total_style = (
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'color:#1a202c;border-top:2px solid #2d3748;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;"'
)
html += f'<tr style="background:#edf2f7;">'
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>'
if has_deduction:
html += f'<td {total_style}></td>'
html += '</tr>'
html += '</tbody></table></div>'
return html
def _generate_approved_items_pdf(self):
"""Generate the Approved Items PDF using the QWeb report and return an ir.attachment id."""
self.ensure_one()
import base64
first, last = self._get_client_name_parts()
try:
report = self.env.ref('fusion_claims.action_report_approved_items')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
except Exception as e:
_logger.error("Failed to generate approved items PDF for %s: %s", self.name, e)
return None
filename = f'{first}_{last}_Approved_Items.pdf'
att = self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content) if isinstance(pdf_content, bytes) else pdf_content,
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
return att.id
def _send_approval_email(self):
"""Send notification when ADP application is approved."""
"""Send notification when ADP application is approved, with approved items report."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
@@ -5138,27 +5308,46 @@ class SaleOrder(models.Model):
'contact you with the details and next steps for delivery.'
)
items_html = self._build_approved_items_html()
body_html = self._email_build(
title='Application Approved',
summary=f'The ADP application for <strong>{client_name}</strong> has been '
f'<strong>{status_label.lower()}</strong>.',
email_type='success',
sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))],
extra_html=items_html,
note=note_text,
note_color='#38a169',
attachments_note='Approved Items Report (PDF)' if items_html else None,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
attachment_ids = []
try:
att_id = self._generate_approved_items_pdf()
if att_id:
attachment_ids.append(att_id)
except Exception as e:
_logger.warning("Could not generate approved items PDF for %s: %s", self.name, e)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
self.env['mail.mail'].sudo().create({
mail_vals = {
'subject': f'Application {status_label} - {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(f'Application {status_label} email sent', email_to, email_cc)
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(
f'Application {status_label} email sent', email_to, email_cc,
['Attached: Approved Items Report'] if attachment_ids else None,
)
return True
except Exception as e:
_logger.error(f"Failed to send approval email for {self.name}: {e}")
@@ -5492,6 +5681,18 @@ class SaleOrder(models.Model):
# ==========================================================================
# OVERRIDE WRITE
# ==========================================================================
def web_save(self, vals, specification):
"""TEMP DEBUG: Intercept web_save to diagnose 'Missing required fields' on old orders."""
_logger.warning(
"DEBUG web_save() on %s: vals keys = %s",
[r.name for r in self], list(vals.keys())
)
try:
return super().web_save(vals, specification)
except Exception as e:
_logger.error("DEBUG web_save() FAILED on %s: %s", [r.name for r in self], e)
raise
def write(self, vals):
"""Override write to handle ADP status changes, date auto-population, and document tracking."""
from datetime import date as date_class
@@ -5690,19 +5891,17 @@ class SaleOrder(models.Model):
label = document_labels.get(field_name, field_name)
if old_data and new_data:
# REPLACEMENT: Old document being replaced with new one
# Preserve old document in chatter as attachment
order._post_document_to_chatter(
field_name,
f"{label} (replaced)"
field_name,
f"{label} (replaced)",
preserve_copy=True,
)
elif old_data and not new_data:
# DELETION: Document is being deleted
# Preserve the deleted document in chatter
order._post_document_to_chatter(
field_name,
f"{label} (DELETED)"
field_name,
f"{label} (DELETED)",
preserve_copy=True,
)
# Post deletion notice
@@ -5737,11 +5936,17 @@ class SaleOrder(models.Model):
for order in self:
# Post existing final application to chatter before clearing
if order.x_fc_final_submitted_application:
order._post_document_to_chatter('x_fc_final_submitted_application',
'Final Application (before correction)')
order._post_document_to_chatter(
'x_fc_final_submitted_application',
'Final Application (before correction)',
preserve_copy=True,
)
if order.x_fc_xml_file:
order._post_document_to_chatter('x_fc_xml_file',
'XML File (before correction)')
order._post_document_to_chatter(
'x_fc_xml_file',
'XML File (before correction)',
preserve_copy=True,
)
# Clear the document fields AND submission date
# Use _correction_cleared to prevent the audit trail from posting duplicates
@@ -6131,8 +6336,9 @@ class SaleOrder(models.Model):
for order in self:
order._send_correction_needed_email()
elif new_app_status == 'case_closed':
for order in self:
order._send_case_closed_email()
if not self.env.context.get('skip_status_emails'):
for order in self:
order._send_case_closed_email()
# ==================================================================
# MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS
@@ -6679,7 +6885,7 @@ class SaleOrder(models.Model):
for order in orders_to_close:
try:
# Use context to skip status validation for automated process
order.with_context(skip_status_validation=True).write({
order.with_context(skip_status_validation=True, skip_status_emails=True).write({
'x_fc_adp_application_status': 'case_closed',
})
@@ -6725,7 +6931,7 @@ class SaleOrder(models.Model):
if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received':
continue
try:
order._odsp_advance_status(
order.with_context(skip_status_emails=True)._odsp_advance_status(
'case_closed',
"Case automatically closed 7 days after %s." % status.replace('_', ' '),
)