changes
This commit is contained in:
@@ -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('_', ' '),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user