feat(configurator): "PO Pending" escape hatch for customers who send PO later

Customer feedback: some customers don't send their PO with the
initial order — they send it days or weeks later. The system was
blocking SO confirmation without a PO, which forced the shop to
either wait on paperwork or ask a manager for a formal override.

New estimator-level path: a "PO Pending" boolean on sale.order +
an optional "PO Expected By" date.

  * Confirm without a PO# / PO document when PO Pending is ticked.
  * action_confirm skips the hard error if po_pending OR po_override
    is set (keeps the existing manager-override path too).
  * On confirm with PO Pending, the system schedules a chase
    activity for po_expected_date (or +3 days if blank), assigned
    via mail.activity so it shows up in the sales user's activity
    list. Chatter note logged so audit is obvious.
  * Direct-order wizard: po_number and po_attachment_file become
    optional. Ticking "PO Pending" in the wizard is the trade-in;
    a help note under the toggle explains the chase behaviour.
  * Once the PO arrives, user fills in the PO# / uploads the doc,
    and turns PO Pending off — existing downstream flow resumes.

Difference from x_fc_po_override (kept):
  * PO Override = manager waiver, permanent ("handshake deal").
  * PO Pending = estimator flag, time-boxed ("customer will send it
    by Friday").

fusion_plating_configurator → 19.0.14.0.0
fusion_plating_invoicing    → 19.0.3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-23 10:08:00 -04:00
parent 2d3ee03f86
commit 8142bd229a
7 changed files with 131 additions and 21 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.13.6.0',
'version': '19.0.14.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -28,6 +28,25 @@ class SaleOrder(models.Model):
x_fc_po_override = fields.Boolean(string='PO Override',
help='Manager override — proceed without formal PO (handshake deal).')
x_fc_po_override_reason = fields.Text(string='Override Reason')
# Estimator-level "PO is coming later" flag. Unlike PO Override
# (permanent, manager-only), this one is time-boxed: the order
# confirms with no PO yet, but a chase activity is scheduled for
# po_expected_date so sales chases the customer for the paperwork.
x_fc_po_pending = fields.Boolean(
string='PO Pending',
tracking=True,
help='Customer will provide the PO later. Confirms the order '
'without a PO number, schedules a chase activity, and '
'shows a "PO Pending" ribbon on the form. Toggle off once '
'the real PO arrives and you\'ve entered it below.',
)
x_fc_po_expected_date = fields.Date(
string='PO Expected By',
tracking=True,
help='Date the customer promised to send the PO. A follow-up '
'activity is scheduled for this date when the order is '
'confirmed with PO Pending set.',
)
x_fc_invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],

View File

@@ -109,6 +109,9 @@
<field name="x_fc_po_attachment_id"
invisible="not x_fc_po_attachment_id"/>
<field name="x_fc_po_received"/>
<field name="x_fc_po_pending" widget="boolean_toggle"/>
<field name="x_fc_po_expected_date"
invisible="not x_fc_po_pending"/>
<field name="x_fc_po_override"
groups="fusion_plating.group_fusion_plating_manager"/>
<field name="x_fc_po_override_reason"

View File

@@ -58,10 +58,26 @@ class FpDirectOrderWizard(models.TransientModel):
help='Ship all-or-nothing; partial pickings are blocked.',
)
# ---- PO (required — that's what makes this a "direct" order) ----
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
# ---- PO ----
# Originally required at wizard time — that's what makes this a
# "direct" order vs. a quote. Relaxed 2026-04-23: some customers
# don't send their PO until after the order is in progress. The
# wizard now accepts a PO Pending flag in lieu of a PO#/doc; the
# underlying SO is confirmed with a chase activity scheduled for
# the expected date.
po_number = fields.Char(string='Customer PO #')
po_attachment_file = fields.Binary(string='PO Document')
po_attachment_filename = fields.Char(string='PO Filename')
po_pending = fields.Boolean(
string='PO Pending',
help='Customer will send the PO later. Enter their expected '
'date below; a follow-up activity will remind sales to '
'chase the paperwork.',
)
po_expected_date = fields.Date(
string='PO Expected By',
help='When the customer promised to send the PO.',
)
# ---- Fulfilment (order-level) ----
delivery_method = fields.Selection(
@@ -193,15 +209,30 @@ class FpDirectOrderWizard(models.TransientModel):
self.ensure_one()
if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.'))
if not self.po_attachment_file:
raise UserError(_('Upload the customer PO document.'))
# 1. Save the PO attachment once
po_att = self.env['ir.attachment'].create({
'name': self.po_attachment_filename or 'po.pdf',
'datas': self.po_attachment_file,
'mimetype': 'application/pdf',
})
# Accept EITHER a PO (document + number) OR the PO Pending
# flag. Customers who haven't sent paperwork yet use Pending;
# sales chases them on po_expected_date.
if not self.po_pending:
if not self.po_attachment_file:
raise UserError(_(
'Upload the customer PO document, or tick "PO Pending" '
'if the customer will send the PO later.'
))
if not self.po_number:
raise UserError(_(
'Enter the Customer PO #, or tick "PO Pending" if the '
'customer will send the PO later.'
))
# 1. Save the PO attachment if one was uploaded
po_att = False
if self.po_attachment_file:
po_att = self.env['ir.attachment'].create({
'name': self.po_attachment_filename or 'po.pdf',
'datas': self.po_attachment_file,
'mimetype': 'application/pdf',
})
# 2. Find or create the generic plating service product
product = self.env['product.product'].search(
@@ -226,9 +257,11 @@ class FpDirectOrderWizard(models.TransientModel):
'partner_shipping_id': (
self.partner_shipping_id.id or self.partner_id.id
),
'x_fc_po_number': self.po_number,
'x_fc_po_attachment_id': po_att.id,
'x_fc_po_received': True,
'x_fc_po_number': self.po_number or False,
'x_fc_po_attachment_id': po_att.id if po_att else False,
'x_fc_po_received': bool(po_att),
'x_fc_po_pending': self.po_pending,
'x_fc_po_expected_date': self.po_expected_date or False,
'x_fc_customer_job_number': self.customer_job_number or False,
'x_fc_planned_start_date': self.planned_start_date,
'x_fc_internal_deadline': self.internal_deadline,

View File

@@ -40,10 +40,22 @@
<field name="customer_job_number"/>
</group>
<group string="Purchase Order">
<field name="po_number"/>
<field name="po_number"
placeholder="Enter the customer PO number"/>
<field name="po_attachment_file"
filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
<field name="po_pending" widget="boolean_toggle"/>
<field name="po_expected_date"
invisible="not po_pending"/>
<div colspan="2" class="text-muted small"
invisible="not po_pending">
<i class="fa fa-info-circle me-1"/>
Order will confirm without a PO number or
document. A chase activity will be scheduled
for the expected date so sales follows up
with the customer.
</div>
</group>
</group>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Invoicing',
'version': '19.0.2.3.0',
'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
'description': """

View File

@@ -31,23 +31,36 @@ class SaleOrder(models.Model):
"""Override to check account hold + customer PO# and trigger
the invoice strategy."""
for order in self:
# --- Customer PO# required ---
# --- 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)
)
if not po_set:
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. Aerospace customers\' AP teams reject '
'invoices that don\'t quote their PO# back.'
'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 ---
@@ -73,6 +86,36 @@ class SaleOrder(models.Model):
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