# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. import logging from datetime import timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _inherit = 'sale.order' @api.onchange('partner_id') def _onchange_partner_id_invoice_strategy(self): """Auto-fill plating defaults from customer profile. Cascade order: partner-level defaults first (the new fast-order path), then fall back to the legacy fp.invoice.strategy.default records for customers migrated before that model was retired. Native Odoo cascades (payment terms, fiscal position) handle themselves via property_* fields and don't need code here. """ if not self.partner_id: return partner = self.partner_id if partner.x_fc_default_invoice_strategy: self.x_fc_invoice_strategy = partner.x_fc_default_invoice_strategy if partner.x_fc_default_deposit_percent: self.x_fc_deposit_percent = partner.x_fc_default_deposit_percent if partner.x_fc_default_delivery_method: self.x_fc_delivery_method = partner.x_fc_default_delivery_method self._fp_recompute_default_deadlines() # Legacy fallback: invoice strategy default model. Only fills # gaps left by the partner fields above so a partial migration # doesn't clobber explicit partner-level values. legacy = self.env['fp.invoice.strategy.default'].search( [('partner_id', '=', partner.id)], limit=1, ) if legacy: if not self.x_fc_invoice_strategy: self.x_fc_invoice_strategy = legacy.default_strategy if not self.x_fc_deposit_percent: self.x_fc_deposit_percent = legacy.default_deposit_percent if legacy.payment_term_id and not self.payment_term_id: self.payment_term_id = legacy.payment_term_id @api.onchange('x_fc_planned_start_date') def _onchange_planned_start_date_deadlines(self): """Recompute deadlines when planned start changes — without it the partner offsets would only fire on partner_id change.""" self._fp_recompute_default_deadlines() def _fp_recompute_default_deadlines(self): """Apply partner deadline offsets relative to planned_start_date. Falls back to today when planned_start is unset so the estimator gets a value immediately. Never overwrites a deadline already set by the user (we honour explicit input over auto-fill). """ for order in self: partner = order.partner_id if not partner: continue anchor = order.x_fc_planned_start_date or fields.Date.context_today(order) if (partner.x_fc_default_internal_deadline_days and not order.x_fc_internal_deadline): order.x_fc_internal_deadline = ( anchor + timedelta(days=partner.x_fc_default_internal_deadline_days) ) if (partner.x_fc_default_customer_deadline_days and not order.commitment_date): order.commitment_date = ( anchor + timedelta(days=partner.x_fc_default_customer_deadline_days) ) def action_confirm(self): """Override to check account hold + customer PO# and trigger the invoice strategy.""" for order in self: # --- Customer PO# required (with escape hatches) --- # Aerospace AP teams reject invoices without their PO# # quoted back. Catching this at SO confirm prevents the # whole downstream chain (CoC, BoL, invoice) from going # out unreferenced. The PO# is on `client_order_ref` # (Odoo standard) AND mirrored to `x_fc_po_number` # (FP-specific) — accept either as filled. # # Two escape hatches for real-world cases: # * x_fc_po_pending — estimator flag: "PO coming later". # Confirms the order without a PO number and schedules # a chase activity for x_fc_po_expected_date so sales # doesn't forget to chase the paperwork. # * x_fc_po_override — manager waiver: "proceed without # a formal PO (handshake deal)". Permanent. po_set = bool(order.client_order_ref) or bool( getattr(order, 'x_fc_po_number', False) ) po_pending = bool(getattr(order, 'x_fc_po_pending', False)) po_override = bool(getattr(order, 'x_fc_po_override', False)) if not po_set and not po_pending and not po_override: raise UserError(_( 'Cannot confirm SO "%(so)s" — Customer PO# is required.\n\n' 'Set the customer\'s purchase order number in the ' '"Customer Reference" field (or x_fc_po_number) before ' 'confirming. If the customer will provide the PO ' 'later, tick "PO Pending" and set "PO Expected By" — ' 'the order will confirm with a follow-up activity to ' 'chase the paperwork. A Plating Manager can also set ' '"PO Override" for handshake deals.' ) % {'so': order.name}) # --- Account hold check --- if order.partner_id.x_fc_account_hold: is_manager = self.env.user.has_group( 'fusion_plating.group_fusion_plating_manager' ) if not is_manager: raise UserError(_( 'Cannot confirm — customer "%s" is on account hold.\n' 'Reason: %s\n\n' 'Contact a manager to override.' ) % (order.partner_id.name, order.partner_id.x_fc_account_hold_reason or 'No reason specified')) else: order.message_post( body=_( 'Warning: Customer "%s" is on account hold (reason: %s). ' 'Order confirmed by manager override.' ) % (order.partner_id.name, order.partner_id.x_fc_account_hold_reason or 'N/A'), ) res = super().action_confirm() # --- PO-pending chase activity --- # Sales team needs to chase the customer for the real PO before # any invoice goes out. Schedule a follow-up on the expected # date (or 3 days out if unset). for order in self: if not getattr(order, 'x_fc_po_pending', False): continue if getattr(order, 'x_fc_po_number', False) or order.client_order_ref: continue # PO already set — nothing to chase from datetime import timedelta expected = ( order.x_fc_po_expected_date or (fields.Date.context_today(order) + timedelta(days=3)) ) order.activity_schedule( 'mail.mail_activity_data_todo', date_deadline=expected, summary=_('Chase customer PO for %s') % order.name, note=_( 'Order confirmed with PO Pending. Follow up with ' '%(partner)s for their PO number (and PDF if ' 'available), then tick PO Pending off and enter the ' 'PO# on the sale order.' ) % {'partner': order.partner_id.display_name}, ) order.message_post(body=_( 'Order confirmed without PO. Chase activity scheduled ' 'for %(date)s.' ) % {'date': expected.strftime('%Y-%m-%d')}) # --- Invoice strategy automation (on confirm) --- for order in self: strategy = order.x_fc_invoice_strategy if not strategy: continue if strategy == 'deposit' and order.x_fc_deposit_percent: order._create_deposit_invoice() elif strategy == 'cod_prepay': order._create_full_invoice() elif strategy == 'progress' and order.x_fc_progress_initial_percent: order._create_progress_initial_invoice() # 'net_terms' — no action on confirm; invoiced when delivery is marked delivered return res # ------------------------------------------------------------------ # Strategy implementations # ------------------------------------------------------------------ def _create_deposit_invoice(self): """Deposit strategy: down-payment invoice for the deposit %.""" self.ensure_one() percent = self.x_fc_deposit_percent if not percent or percent <= 0: return try: # The wizard's sale_order_ids default reads active_ids AT CREATE # time — context must be set on .with_context(), not on the # subsequent create_invoices() call. wizard = self.env['sale.advance.payment.inv'].with_context( active_ids=self.ids, active_model='sale.order', active_id=self.id, ).create({ 'advance_payment_method': 'percentage', 'amount': percent, }) wizard.create_invoices() self.message_post( body=_('Deposit invoice (%.0f%%) created — strategy: Deposit.') % percent, ) except Exception as e: _logger.warning('Failed to create deposit invoice for SO %s: %s', self.name, e) self.message_post( body=_('Failed to auto-create deposit invoice: %s. Create manually.') % str(e), ) def _create_full_invoice(self): """COD / Prepay: invoice the entire order immediately.""" self.ensure_one() try: invoices = self._create_invoices() if invoices: self.message_post( body=_('Full invoice created — strategy: COD / Prepay.'), ) except Exception as e: _logger.warning('Failed to create COD invoice for SO %s: %s', self.name, e) self.message_post( body=_('Failed to auto-create invoice: %s. Create manually.') % str(e), ) def _create_progress_initial_invoice(self): """Progress Billing — first invoice at SO confirm. Uses Odoo's down-payment mechanism to bill the initial percentage. The remainder is billed on delivery via `_create_final_balance_invoice`. """ self.ensure_one() percent = self.x_fc_progress_initial_percent if not percent or percent <= 0: return try: wizard = self.env['sale.advance.payment.inv'].with_context( active_ids=self.ids, active_model='sale.order', active_id=self.id, ).create({ 'advance_payment_method': 'percentage', 'amount': percent, }) wizard.create_invoices() self.message_post( body=_( 'Progress invoice — initial %.0f%% created — strategy: Progress Billing. ' 'Final balance will be invoiced on delivery.' ) % percent, ) except Exception as e: _logger.warning('Failed progress-initial invoice for SO %s: %s', self.name, e) self.message_post( body=_('Failed to auto-create progress invoice: %s') % str(e), ) def _create_final_balance_invoice(self): """Create the closing invoice for Progress Billing / Net Terms. Called when delivery is marked delivered. Uses the standard `_create_invoices()` method which bills the remainder (net of any previously-posted down payments). """ self.ensure_one() if self.x_fc_final_invoice_id: return self.x_fc_final_invoice_id # Already invoiced — don't double if self.invoice_status == 'invoiced': return False # Nothing more to bill try: invoices = self._create_invoices(final=True) if invoices: self.x_fc_final_invoice_id = invoices[:1].id strategy_label = dict( self._fields['x_fc_invoice_strategy'].selection ).get(self.x_fc_invoice_strategy, self.x_fc_invoice_strategy) self.message_post( body=_( 'Final invoice created on delivery — strategy: %s.' ) % strategy_label, ) return invoices except Exception as e: _logger.warning('Failed final invoice for SO %s: %s', self.name, e) self.message_post( body=_( 'Failed to auto-create final invoice: %s. ' 'Create manually from the SO.' ) % str(e), ) return False