chore(plating): de-dash shipped code + intake-neutral customer emails
Replace em-dashes and en-dashes with hyphens across 789 shipped source files (py/xml/js/scss) so the delivered module reads as human-written; em-dashes had become a recognizable AI-generated tell. Internal .md dev notes are excluded. The WO-sticker mojibake strippers keep their dash search targets (now written — / –). No logic changes: comments and display strings only; validated with py_compile + lxml parse. Rewrite the 7 customer notification emails to be intake-neutral (ship-in / drop-off / pickup) and repair-aware, and fix the Shipped email documents line (packing slip vs bill of lading; certificate only when issued). Subjects use a hyphen separator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Invoicing',
|
||||
'name': 'Fusion Plating - Invoicing',
|
||||
'version': '19.0.3.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||
'description': """
|
||||
Fusion Plating — Invoicing
|
||||
Fusion Plating - Invoicing
|
||||
===========================
|
||||
|
||||
Part of the Fusion Plating product family by Nexa Systems Inc.
|
||||
|
||||
@@ -10,7 +10,7 @@ The legacy `fp.invoice.strategy.default` model is left in place; the
|
||||
new sale.order onchange falls back to it for any partner whose record
|
||||
hasn't been migrated, so downstream code keeps working mid-rollout.
|
||||
|
||||
property_payment_term_id is intentionally skipped — it lives in
|
||||
property_payment_term_id is intentionally skipped - it lives in
|
||||
ir_property rather than as a plain column, and the legacy onchange
|
||||
fallback already reads payment_term from the strategy default record
|
||||
when the partner doesn't have one set directly.
|
||||
|
||||
@@ -26,11 +26,11 @@ class AccountMove(models.Model):
|
||||
Two defensive defaults so newly-created invoices come out
|
||||
compliant out of the box:
|
||||
|
||||
1. **invoice_payment_term_id** — pulled from the customer's
|
||||
1. **invoice_payment_term_id** - pulled from the customer's
|
||||
property_payment_term_id (Net-30, COD, etc.). Without this
|
||||
the due date silently becomes "immediate", wrong for B2B.
|
||||
|
||||
2. **ref** (customer reference / PO#) — pulled from the source
|
||||
2. **ref** (customer reference / PO#) - pulled from the source
|
||||
sale order's client_order_ref or x_fc_po_number. Customer
|
||||
AP teams reject invoices that don't quote their PO# back.
|
||||
We already populate this on the SO confirm path, but a
|
||||
@@ -60,7 +60,7 @@ class AccountMove(models.Model):
|
||||
"""Block post when:
|
||||
• customer is on account hold (existing rule), or
|
||||
• the invoice has no payment term (auto-fill missed it AND
|
||||
partner had no default — accountant must pick one).
|
||||
partner had no default - accountant must pick one).
|
||||
"""
|
||||
for move in self:
|
||||
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
||||
@@ -69,14 +69,14 @@ class AccountMove(models.Model):
|
||||
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
|
||||
if not is_manager:
|
||||
raise UserError(_(
|
||||
'Cannot post invoice — customer "%s" is on account hold.\n'
|
||||
'Cannot post invoice - customer "%s" is on account hold.\n'
|
||||
'Reason: %s\n\n'
|
||||
'Contact a manager to override.'
|
||||
) % (hold_partner.name,
|
||||
hold_partner.x_fc_account_hold_reason or 'No reason specified'))
|
||||
if not move.invoice_payment_term_id:
|
||||
raise UserError(_(
|
||||
'Cannot post invoice "%s" — no payment terms set.\n\n'
|
||||
'Cannot post invoice "%s" - no payment terms set.\n\n'
|
||||
'Pick payment terms (Net-30, COD, etc.) on the invoice, '
|
||||
'or set a default on the customer "%s" so future '
|
||||
'invoices inherit it automatically.'
|
||||
|
||||
@@ -19,7 +19,7 @@ class FpDelivery(models.Model):
|
||||
def action_mark_delivered(self):
|
||||
res = super().action_mark_delivered()
|
||||
SaleOrder = self.env['sale.order']
|
||||
# Sub 11 — MRP gone; resolve via delivery.job_ref → fp.job.name → fp.job.origin.
|
||||
# Sub 11 - MRP gone; resolve via delivery.job_ref → fp.job.name → fp.job.origin.
|
||||
Job = self.env['fp.job'] if 'fp.job' in self.env else None
|
||||
for delivery in self:
|
||||
so = False
|
||||
|
||||
@@ -13,7 +13,7 @@ class FpInvoiceStrategyDefault(models.Model):
|
||||
strategy and deposit percentage auto-fill from this record.
|
||||
"""
|
||||
_name = 'fp.invoice.strategy.default'
|
||||
_description = 'Fusion Plating — Invoice Strategy Default'
|
||||
_description = 'Fusion Plating - Invoice Strategy Default'
|
||||
_order = 'partner_id'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
@@ -42,7 +42,7 @@ class FpInvoiceStrategyDefault(models.Model):
|
||||
bits.append(rec.partner_id.display_name)
|
||||
if rec.default_strategy:
|
||||
bits.append(labels.get(rec.default_strategy, rec.default_strategy))
|
||||
rec.display_name = ' — '.join(bits) or 'Invoice Strategy'
|
||||
rec.display_name = ' - '.join(bits) or 'Invoice Strategy'
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_invoice_strategy_partner_uniq', 'unique(partner_id)',
|
||||
|
||||
@@ -22,14 +22,14 @@ class ResPartner(models.Model):
|
||||
Plating Manager OR Plating Administrator qualifies. Administrator
|
||||
is checked explicitly (in addition to the implied chain) because
|
||||
Odoo's ``implied_ids`` cascade does NOT reliably propagate to
|
||||
existing users on module upgrade — admin (uid 1) typically lands
|
||||
existing users on module upgrade - admin (uid 1) typically lands
|
||||
in Administrator only, with no Manager membership. Without this
|
||||
defensive check, the highest-privileged user can't bypass holds.
|
||||
|
||||
See CLAUDE.md "Implied group cascade" rule.
|
||||
"""
|
||||
user = self.env.user
|
||||
# Phase G: fixed audit-finding-11 — old code referenced
|
||||
# Phase G: fixed audit-finding-11 - old code referenced
|
||||
# 'fusion_plating.group_fusion_plating_administrator', an xmlid
|
||||
# that never existed, so the gate always returned False. Replaced
|
||||
# with group_fp_manager which transitively implies Owner via
|
||||
@@ -48,7 +48,7 @@ class ResPartner(models.Model):
|
||||
# invoice strategy, delivery method, and deadlines on every new SO so
|
||||
# repeat customers don't need re-typing the same values each order.
|
||||
# Tax type lives on `property_account_position_id` (Odoo native fiscal
|
||||
# position) and payment terms on `property_payment_term_id` — both are
|
||||
# position) and payment terms on `property_payment_term_id` - both are
|
||||
# surfaced on the same Plating Defaults tab in the partner form.
|
||||
|
||||
x_fc_default_invoice_strategy = fields.Selection(
|
||||
|
||||
@@ -15,7 +15,7 @@ _logger = logging.getLogger(__name__)
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
# Explicit related field — dotted refs like `partner_id.x_fc_account_hold`
|
||||
# Explicit related field - dotted refs like `partner_id.x_fc_account_hold`
|
||||
# in `invisible=` modifiers are fragile in Odoo 19 (the related field
|
||||
# has to be in the record cache for the evaluator). Surfacing it as a
|
||||
# plain field on sale.order makes the banner condition deterministic.
|
||||
@@ -67,7 +67,7 @@ class SaleOrder(models.Model):
|
||||
|
||||
@api.onchange('x_fc_planned_start_date')
|
||||
def _onchange_planned_start_date_deadlines(self):
|
||||
"""Recompute deadlines when planned start changes — without it
|
||||
"""Recompute deadlines when planned start changes - without it
|
||||
the partner offsets would only fire on partner_id change."""
|
||||
self._fp_recompute_default_deadlines()
|
||||
|
||||
@@ -104,14 +104,14 @@ class SaleOrder(models.Model):
|
||||
# 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.
|
||||
# (FP-specific) - accept either as filled.
|
||||
#
|
||||
# Two escape hatches for real-world cases:
|
||||
# * x_fc_po_pending — estimator flag: "PO coming later".
|
||||
# * 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
|
||||
# * 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)
|
||||
@@ -120,11 +120,11 @@ class SaleOrder(models.Model):
|
||||
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'
|
||||
'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" — '
|
||||
'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.'
|
||||
@@ -133,14 +133,14 @@ class SaleOrder(models.Model):
|
||||
# --- Account hold check ---
|
||||
# Hold lives on the commercial_partner (the company). Resolve
|
||||
# through that so a hold on the parent applies to every child
|
||||
# contact too — typical case is "all of Acme is on hold", not
|
||||
# contact too - typical case is "all of Acme is on hold", not
|
||||
# "specifically the AP clerk's contact card".
|
||||
hold_partner = order.partner_id.commercial_partner_id
|
||||
if hold_partner.x_fc_account_hold:
|
||||
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
|
||||
if not is_manager:
|
||||
raise UserError(_(
|
||||
'Cannot confirm — customer "%s" is on account hold.\n'
|
||||
'Cannot confirm - customer "%s" is on account hold.\n'
|
||||
'Reason: %s\n\n'
|
||||
'Contact a manager to override.'
|
||||
) % (hold_partner.name,
|
||||
@@ -164,7 +164,7 @@ class SaleOrder(models.Model):
|
||||
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
|
||||
continue # PO already set - nothing to chase
|
||||
from datetime import timedelta
|
||||
expected = (
|
||||
order.x_fc_po_expected_date
|
||||
@@ -197,7 +197,7 @@ class SaleOrder(models.Model):
|
||||
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
|
||||
# 'net_terms' - no action on confirm; invoiced when delivery is marked delivered
|
||||
|
||||
return res
|
||||
|
||||
@@ -212,7 +212,7 @@ class SaleOrder(models.Model):
|
||||
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
|
||||
# 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,
|
||||
@@ -224,7 +224,7 @@ class SaleOrder(models.Model):
|
||||
})
|
||||
wizard.create_invoices()
|
||||
self.message_post(
|
||||
body=_('Deposit invoice (%.0f%%) created — strategy: Deposit.') % percent,
|
||||
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)
|
||||
@@ -239,7 +239,7 @@ class SaleOrder(models.Model):
|
||||
invoices = self._create_invoices()
|
||||
if invoices:
|
||||
self.message_post(
|
||||
body=_('Full invoice created — strategy: COD / Prepay.'),
|
||||
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)
|
||||
@@ -248,7 +248,7 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
|
||||
def _create_progress_initial_invoice(self):
|
||||
"""Progress Billing — first invoice at SO confirm.
|
||||
"""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`.
|
||||
@@ -269,7 +269,7 @@ class SaleOrder(models.Model):
|
||||
wizard.create_invoices()
|
||||
self.message_post(
|
||||
body=_(
|
||||
'Progress invoice — initial %.0f%% created — strategy: Progress Billing. '
|
||||
'Progress invoice - initial %.0f%% created - strategy: Progress Billing. '
|
||||
'Final balance will be invoiced on delivery.'
|
||||
) % percent,
|
||||
)
|
||||
@@ -288,7 +288,7 @@ class SaleOrder(models.Model):
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_final_invoice_id:
|
||||
return self.x_fc_final_invoice_id # Already invoiced — don't double
|
||||
return self.x_fc_final_invoice_id # Already invoiced - don't double
|
||||
if self.invoice_status == 'invoiced':
|
||||
return False # Nothing more to bill
|
||||
try:
|
||||
@@ -300,7 +300,7 @@ class SaleOrder(models.Model):
|
||||
).get(self.x_fc_invoice_strategy, self.x_fc_invoice_strategy)
|
||||
self.message_post(
|
||||
body=_(
|
||||
'Final invoice created on delivery — strategy: %s.'
|
||||
'Final invoice created on delivery - strategy: %s.'
|
||||
) % strategy_label,
|
||||
)
|
||||
return invoices
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<!-- Backward-compat: new Manager role implies old Accounting group.
|
||||
2026-05-29: Manager (+ QM + Owner via implication) also gets Odoo's
|
||||
Billing group so they can create customer invoices from a Sale
|
||||
Order. Billing only — not Accountant. The SO-origin workflow gate
|
||||
Order. Billing only - not Accountant. The SO-origin workflow gate
|
||||
in fusion_plating_jobs is unchanged (off-SO invoices still blocked).
|
||||
Spec: docs/superpowers/specs/2026-05-29-manager-invoice-permission-design.md -->
|
||||
<record id="fusion_plating.group_fp_manager" model="res.groups">
|
||||
|
||||
@@ -30,7 +30,7 @@ class TestManagerInvoicePermission(TransactionCase):
|
||||
self.assertTrue(owner.has_group('account.group_account_invoice'))
|
||||
|
||||
def test_shop_manager_does_not_get_billing(self):
|
||||
# Shop Manager is BELOW Manager — must NOT inherit Billing
|
||||
# Shop Manager is BELOW Manager - must NOT inherit Billing
|
||||
# ("managers and ABOVE" scope).
|
||||
shop = self._user_with('fusion_plating.group_fp_shop_manager_v2')
|
||||
self.assertFalse(shop.has_group('account.group_account_invoice'))
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<xpath expr="//sheet" position="before">
|
||||
<div class="alert alert-danger mb-0" role="alert"
|
||||
invisible="not x_fc_account_hold">
|
||||
<strong>Account Hold Active</strong> —
|
||||
<strong>Account Hold Active</strong> -
|
||||
<field name="x_fc_account_hold_reason" readonly="1" nolabel="1"/>
|
||||
<br/>
|
||||
<small>
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Single "Plating Defaults" tab — invoice strategy, delivery,
|
||||
<!-- Single "Plating Defaults" tab - invoice strategy, delivery,
|
||||
deadlines, tax type, payment terms. Set once here, cascades
|
||||
onto every new SO for this customer. -->
|
||||
<xpath expr="//notebook" position="inside">
|
||||
@@ -57,7 +57,7 @@
|
||||
</group>
|
||||
</page>
|
||||
|
||||
<!-- Phase D5 — Account Hold management (the override gate per
|
||||
<!-- Phase D5 - Account Hold management (the override gate per
|
||||
spec section 2.E Layer 3). Was previously gated on the
|
||||
fold-in group_fp_accounting; consolidated to group_fp_manager
|
||||
and resolves audit-finding-11 _administrator typo by removing
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
role="alert"
|
||||
invisible="not x_fc_partner_account_hold">
|
||||
<i class="fa fa-ban me-1" title="Account hold"/>
|
||||
<strong>Account Hold</strong> — SO confirmation, invoicing
|
||||
<strong>Account Hold</strong> - SO confirmation, invoicing
|
||||
and shipping are blocked for non-managers.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
Reference in New Issue
Block a user