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:
@@ -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': """
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user