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:
gsinghpal
2026-06-05 00:16:19 -04:00
parent c9eb61ee0c
commit 8c76a16366
789 changed files with 4692 additions and 4692 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 2 Task 19 propagate the customer part reference from SO line to
# Sub 2 Task 19 - propagate the customer part reference from SO line to
# invoice line so customer-facing invoice PDFs can print the part number
# via the shared fusion_plating_reports.customer_line_header macro.
@@ -34,7 +34,7 @@ class AccountMoveLine(models.Model):
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n-—–:').strip()
return tail.lstrip(' \t\r\n---:').strip()
return name
x_fc_part_catalog_id = fields.Many2one(
@@ -67,7 +67,7 @@ class AccountMoveLine(models.Model):
)
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Carried from the SO line prints on the invoice PDF.',
help='Carried from the SO line - prints on the invoice PDF.',
)
# x_fc_customer_spec_id added by fusion_plating_quality.
x_fc_revision_snapshot = fields.Char(

View File

@@ -13,7 +13,7 @@ class FpAdditionalChargeType(models.Model):
Spec: docs/superpowers/specs/2026-05-29-configurable-charge-tax-lot-pricing-design.md
"""
_name = 'fp.additional.charge.type'
_description = 'Fusion Plating Additional Charge Type'
_description = 'Fusion Plating - Additional Charge Type'
_order = 'sequence, name'
name = fields.Char(string='Charge Type', required=True)

View File

@@ -28,7 +28,7 @@ def _bump_revision_label(label):
return 'A'
label = label.strip()
# Trailing digits "Rev 1" → "Rev 2", "A1" → "A2".
# Trailing digits - "Rev 1" → "Rev 2", "A1" → "A2".
# Preserve zero-padding when the original was padded ("014" → "015").
m = re.match(r'^(.*?)(\d+)$', label)
if m:
@@ -44,12 +44,12 @@ def _bump_revision_label(label):
return 'AA' if label.isupper() else 'aa'
return chr(ord(label) + 1)
# Multi-char ending in letter "AB" → "AC"
# Multi-char ending in letter - "AB" → "AC"
m = re.match(r'^(.*?)([A-Za-z])$', label)
if m and m.group(2).upper() != 'Z':
return m.group(1) + chr(ord(m.group(2)) + 1)
# Unknown format caller must edit
# Unknown format - caller must edit
return label + '*'
@@ -61,7 +61,7 @@ class FpPartCatalog(models.Model):
entries for instant re-quoting; one-off parts create new entries.
"""
_name = 'fp.part.catalog'
_description = 'Fusion Plating Part Catalog'
_description = 'Fusion Plating - Part Catalog'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'partner_id, part_number, revision desc'
# Customers always type the part NUMBER in m2o pickers, never the part
@@ -84,7 +84,7 @@ class FpPartCatalog(models.Model):
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
revision = fields.Char(
string='Revision', required=True, default='A',
help="Customer's drawing revision label. Free-text accepts any "
help="Customer's drawing revision label. Free-text - accepts any "
"format the customer uses (A, B, C / A1, B2 / Rev 1, Rev 2 / "
"ECO-2024-014 etc.).",
)
@@ -128,7 +128,7 @@ class FpPartCatalog(models.Model):
'ir.attachment', string='3D Model File',
help='STEP, STL, or IGES file.', tracking=True,
)
# Binary upload proxy lets the user drop a file in the form; the
# Binary upload proxy - lets the user drop a file in the form; the
# onchange below wraps it in an ir.attachment and links it to
# model_attachment_id. Without this, the Many2one only offers a
# search dropdown with no upload affordance.
@@ -189,7 +189,7 @@ class FpPartCatalog(models.Model):
)
is_manifold = fields.Boolean(
string='Watertight (Manifold)',
help='False indicates open/broken geometry review before quoting.',
help='False indicates open/broken geometry - review before quoting.',
)
hole_count = fields.Integer(
string='Holes',
@@ -198,7 +198,7 @@ class FpPartCatalog(models.Model):
)
hole_summary = fields.Char(
string='Hole Diameters',
help='Holes grouped by diameter e.g. "4× Ø10.2mm, 2× Ø7.9mm".',
help='Holes grouped by diameter - e.g. "4× Ø10.2mm, 2× Ø7.9mm".',
)
masking_area_sqin = fields.Float(
string='Masking Area (sq in)', digits=(12, 4),
@@ -208,7 +208,7 @@ class FpPartCatalog(models.Model):
string='Effective Plating Area (sq in)', digits=(12, 4),
compute='_compute_effective_area',
store=True,
help='Surface area minus masked area used for per-sq-in pricing.',
help='Surface area minus masked area - used for per-sq-in pricing.',
)
notes = fields.Html(string='Notes')
@@ -229,11 +229,11 @@ class FpPartCatalog(models.Model):
'"Inherit" reads the customer\'s default on the partner form.',
)
# Sub 3 part's cloned process tree. NULL until the user first
# Sub 3 - part's cloned process tree. NULL until the user first
# composes a process. The Composer client action sets this to the
# root node of the cloned tree.
#
# Sub 9 multiple variants per part. `default_process_id` now points
# Sub 9 - multiple variants per part. `default_process_id` now points
# to "the variant flagged is_default_variant". `process_variant_ids`
# is the full set; estimators pick one per order line.
default_process_id = fields.Many2one(
@@ -246,7 +246,7 @@ class FpPartCatalog(models.Model):
'specific variant, this one is used.',
)
# Computed instead of plain One2many because the One2many `domain=`
# was silently NOT being applied `part.process_variant_ids` was
# was silently NOT being applied - `part.process_variant_ids` was
# returning every node (root + children) for the part instead of
# only the root recipe variants. Computing explicitly via search
# is bulletproof and survives the Odoo 19 ORM rewrites. The store
@@ -277,14 +277,14 @@ class FpPartCatalog(models.Model):
rec.process_variant_ids = variants
rec.process_variant_count = len(variants)
# ---- Direct-order defaults (Phase C C4) ----
# ---- Direct-order defaults (Phase C - C4) ----
# x_fc_default_customer_spec_id added by fusion_plating_quality.
# Legacy default_coating_config_id + default_treatment_ids removed.
x_fc_default_thickness_range = fields.Char(
string='Default Thickness',
help='Default thickness range as free text (e.g. "0.0005-0.0008 mils" '
'or "5-10 mils"). Pre-fills the thickness on new sale order '
'lines for this part falls back when no recent order for '
'lines for this part - falls back when no recent order for '
'the same (part, customer) pair exists. Updated when the '
'wizard\'s "Save as Default" toggle is ticked.',
)
@@ -293,7 +293,7 @@ class FpPartCatalog(models.Model):
default_specification_text = fields.Text(
string='Default Specification (Customer-Facing)',
help='Pre-fills the Specification cell when this part is added to an '
'Express Order. Written here automatically on order confirm '
'Express Order. Written here automatically on order confirm - '
'type once, reuse forever.',
)
default_bake_instructions = fields.Text(
@@ -397,7 +397,7 @@ class FpPartCatalog(models.Model):
string='Saved Descriptions',
help='Canned descriptions for this specific part. When an order is '
'created for this part, these show up first in the picker. '
'Typically 35 variants per part covering different masking, '
'Typically 3-5 variants per part covering different masking, '
'packaging, or spec callouts.',
)
description_template_count = fields.Integer(
@@ -480,7 +480,7 @@ class FpPartCatalog(models.Model):
messages.append(Markup(_('<b>Drawing attached:</b> %s')) % att.name)
for att_id in removed:
att = self.env['ir.attachment'].browse(att_id)
# Browse even if deleted may still have name if not purged
# Browse even if deleted - may still have name if not purged
name = att.exists() and att.name or f'#{att_id}'
messages.append(Markup(_('<b>Drawing removed:</b> %s')) % name)
@@ -516,7 +516,7 @@ class FpPartCatalog(models.Model):
[('part_catalog_id', '=', part.id)])
def _compute_workorder_count(self):
# Sub 11 MRP gone; count fp.job.step rows scoped to this part's SOs.
# Sub 11 - MRP gone; count fp.job.step rows scoped to this part's SOs.
for part in self:
part.workorder_count = 0
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
@@ -580,7 +580,7 @@ class FpPartCatalog(models.Model):
if (latest
and norm(latest.internal_description) == norm(internal_desc)
and norm(latest.customer_facing_description) == norm(customer_desc)):
return latest # unchanged no new version
return latest # unchanged - no new version
vals = {
'part_catalog_id': self.id,
'internal_description': internal_desc or '',
@@ -601,7 +601,7 @@ class FpPartCatalog(models.Model):
@api.depends('part_number', 'revision', 'name')
@api.depends_context('fp_express_part_picker')
def _compute_display_name(self):
"""Display = 'PART-NUMBER (Rev X) Optional Name'.
"""Display = 'PART-NUMBER (Rev X) - Optional Name'.
Used by m2o pickers, breadcrumbs, kanban cards. Falls back to
name-only when part_number is missing (legacy / in-progress records).
@@ -614,7 +614,7 @@ class FpPartCatalog(models.Model):
`fp_express_part_picker=True` context, return JUST part_number.
The FpExpressPartCell OWL widget shows revision and part name on
their own rows, so the picker display should be just the bare
part number to avoid 'PART (Rev A) NAME' duplicating with the
part number to avoid 'PART (Rev A) - NAME' duplicating with the
widget's separate rev / name rows.
"""
express = self.env.context.get('fp_express_part_picker')
@@ -630,7 +630,7 @@ class FpPartCatalog(models.Model):
rev = rev[4:].strip()
core += f" (Rev {rev})"
if rec.name:
core += f" {rec.name}"
core += f" - {rec.name}"
rec.display_name = core
else:
rec.display_name = rec.name or _('[unnamed part]')
@@ -641,7 +641,7 @@ class FpPartCatalog(models.Model):
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': 'Process Composer %s' % (self.display_name or self.part_number),
'name': 'Process Composer - %s' % (self.display_name or self.part_number),
'params': {
'part_id': self.id,
'part_display': self.display_name or self.part_number,
@@ -652,7 +652,7 @@ class FpPartCatalog(models.Model):
def action_open_default_simple_editor(self):
"""Open the Simple Recipe Editor for this part's default variant.
One-click path that skips the Composer's variants list useful
One-click path that skips the Composer's variants list - useful
when the part only has one variant and the user wants to dive
straight into editing.
"""
@@ -710,7 +710,7 @@ class FpPartCatalog(models.Model):
}
def action_view_workorders(self):
# Sub 11 MRP gone; navigate to fp.job.step rows scoped to this part.
# Sub 11 - MRP gone; navigate to fp.job.step rows scoped to this part.
self.ensure_one()
so_names = self.env['sale.order'].search(
[('x_fc_part_catalog_id', '=', self.id)]
@@ -720,7 +720,7 @@ class FpPartCatalog(models.Model):
jobs = self.env['fp.job'].sudo().search([('origin', 'in', so_names)])
return {
'type': 'ir.actions.act_window',
'name': _('Work Orders %s') % (self.part_number or self.name),
'name': _('Work Orders - %s') % (self.part_number or self.name),
'res_model': 'fp.job.step',
'domain': [('job_id', 'in', jobs.ids)],
'view_mode': 'list,form',
@@ -731,7 +731,7 @@ class FpPartCatalog(models.Model):
root = self.parent_part_id or self
return {
'type': 'ir.actions.act_window',
'name': _('Revisions %s') % (root.part_number or root.name),
'name': _('Revisions - %s') % (root.part_number or root.name),
'res_model': 'fp.part.catalog',
'domain': ['|', ('id', '=', root.id), ('parent_part_id', '=', root.id)],
'view_mode': 'list,form',
@@ -783,7 +783,7 @@ class FpPartCatalog(models.Model):
This is what the form-header button calls. The wizard asks
the user for the revision label, note, and optionally a new
drawing/3D file BEFORE the new record is created which is
drawing/3D file BEFORE the new record is created - which is
what most users want.
For non-interactive callers (auto-rev on 3D upload, direct
@@ -843,7 +843,7 @@ class FpPartCatalog(models.Model):
"""Wrap an uploaded binary file in an ir.attachment and link it.
Fires as soon as the user drops a file in the "Upload 3D Model"
widget the attachment is created in-memory (no DB commit) so
widget - the attachment is created in-memory (no DB commit) so
saving the part persists both at once.
"""
if not self.model_upload:

View File

@@ -16,7 +16,7 @@ class FpPartDescriptionVersion(models.Model):
Spec: docs/superpowers/specs/2026-05-29-part-description-history-design.md
"""
_name = 'fp.part.description.version'
_description = 'Fusion Plating Part Description Version'
_description = 'Fusion Plating - Part Description Version'
_order = 'part_catalog_id, version_no desc, id desc'
part_catalog_id = fields.Many2one(
@@ -72,7 +72,7 @@ class FpPartDescriptionVersion(models.Model):
vals['name'] = self._fp_build_name(vals)
vals['is_latest'] = True
records = super().create(vals_list)
# Exactly one latest per part flip prior latest rows off.
# Exactly one latest per part - flip prior latest rows off.
for rec in records:
rec.part_catalog_id.description_version_ids.filtered(
lambda v, r=rec: v.id != r.id and v.is_latest

View File

@@ -16,7 +16,7 @@ class FpPartMaterial(models.Model):
material-weight rollups.
"""
_name = 'fp.part.material'
_description = 'Fusion Plating Part Material'
_description = 'Fusion Plating - Part Material'
_order = 'sequence, name'
_rec_name = 'name'

View File

@@ -9,7 +9,7 @@ from odoo import api, fields, models
class FpPricingComplexitySurcharge(models.Model):
"""Complexity-based surcharge line on a pricing rule."""
_name = 'fp.pricing.complexity.surcharge'
_description = 'Fusion Plating Pricing Complexity Surcharge'
_description = 'Fusion Plating - Pricing Complexity Surcharge'
_order = 'complexity'
rule_id = fields.Many2one('fp.pricing.rule', string='Pricing Rule', required=True, ondelete='cascade')

View File

@@ -14,7 +14,7 @@ class FpPricingRule(models.Model):
Global rules (no filters set) act as fallbacks.
"""
_name = 'fp.pricing.rule'
_description = 'Fusion Plating Pricing Rule'
_description = 'Fusion Plating - Pricing Rule'
_order = 'sequence, id'
name = fields.Char(string='Rule Name', required=True)

View File

@@ -6,7 +6,7 @@
Lives here (not in core fusion_plating) so the core module doesn't have
to depend on the configurator. Any field that references a model defined
in configurator like fp.pricing.rule, fp.part.catalog must be
in configurator - like fp.pricing.rule, fp.part.catalog - must be
declared here.
"""
from odoo import api, fields, models, _
@@ -57,7 +57,7 @@ class FpProcessNode(models.Model):
# A part can carry multiple recipe-root trees ("variants"). Examples:
# "Standard ENP", "Selective Masking", "Rework". Each order line picks a
# variant; the MO walker resolves through it. One variant per part is the
# default used when the order line doesn't pick one explicitly.
# default - used when the order line doesn't pick one explicitly.
#
# Variant identification only applies to root nodes (parent_id IS NULL,
# node_type='recipe') with a part_catalog_id set. Non-root nodes carry
@@ -105,7 +105,7 @@ class FpProcessNode(models.Model):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Linked Parts %s', self.name),
'name': _('Linked Parts - %s', self.name),
'res_model': 'fusion.plating.process.node',
'view_mode': 'list,form',
'domain': [

View File

@@ -19,7 +19,7 @@ class FpQuoteConfigurator(models.Model):
can override the calculated price. Creates a sale.order when confirmed.
"""
_name = 'fp.quote.configurator'
_description = 'Fusion Plating Quote Configurator'
_description = 'Fusion Plating - Quote Configurator'
_inherit = ['mail.thread']
_order = 'create_date desc'
@@ -244,7 +244,7 @@ class FpQuoteConfigurator(models.Model):
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
upload_po_filename = fields.Char(string='PO Filename')
# Renamed from coating_config_id (Phase E Promote Customer Spec).
# Renamed from coating_config_id (Phase E - Promote Customer Spec).
# Now points at the recipe directly. The quote's specification
# (customer-facing audit ref) is added by quality inherit as
# customer_spec_id.
@@ -277,7 +277,7 @@ class FpQuoteConfigurator(models.Model):
material_id = fields.Many2one(
'fp.part.material', string='Material',
ondelete='restrict',
help='Picks from the shared material library same picker as '
help='Picks from the shared material library - same picker as '
'the Part Catalog. Create custom alloys (e.g. "Aluminium '
'6061") on the fly.',
)
@@ -343,7 +343,7 @@ class FpQuoteConfigurator(models.Model):
self.surface_area_uom = cat.surface_area_uom
self.complexity = cat.complexity
self.masking_zones = cat.masking_zones
# Pull the m2o material from the part substrate_material
# Pull the m2o material from the part - substrate_material
# auto-derives via the compute. Fall back to the legacy
# Selection only if the part has no material_id yet.
if cat.material_id:
@@ -484,7 +484,7 @@ class FpQuoteConfigurator(models.Model):
def _find_matching_rule(self):
"""Find the best pricing rule matching this configurator's filters.
Scores rules by specificity most specific match wins.
Scores rules by specificity - most specific match wins.
If no rule matches filters, returns None.
When the chosen recipe has `pricing_rule_ids` configured, the
@@ -544,7 +544,7 @@ class FpQuoteConfigurator(models.Model):
return super().create(vals_list)
def action_promote_to_direct_order(self):
"""Sub 10 push this quote onto a Direct Order draft.
"""Sub 10 - push this quote onto a Direct Order draft.
Replaces the legacy 1-line-SO creation. The estimator picks an
existing draft for the customer (consolidating multiple quotes
@@ -634,7 +634,7 @@ class FpQuoteConfigurator(models.Model):
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,
'name': '%s %s (x%d)' % (recipe_name, part_name, self.quantity),
'name': '%s - %s (x%d)' % (recipe_name, part_name, self.quantity),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
# Propagate part + recipe to the LINE.
@@ -688,7 +688,7 @@ class FpQuoteConfigurator(models.Model):
# Auto-create or update part catalog with revision tracking
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
# Part already has a 3D model create a new revision
# Part already has a 3D model - create a new revision
old_part = self.part_catalog_id
old_part.is_latest_revision = False
root = old_part.parent_part_id or old_part
@@ -707,13 +707,13 @@ class FpQuoteConfigurator(models.Model):
self.surface_area = new_part.surface_area
self.surface_area_uom = new_part.surface_area_uom
elif self.part_catalog_id:
# Part exists but no 3D model yet just attach
# Part exists but no 3D model yet - just attach
self.part_catalog_id.model_attachment_id = att.id
self.part_catalog_id._compute_surface_area_from_model()
self.surface_area = self.part_catalog_id.surface_area
self.surface_area_uom = self.part_catalog_id.surface_area_uom
else:
# No part catalog create new entry
# No part catalog - create new entry
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
@@ -728,7 +728,7 @@ class FpQuoteConfigurator(models.Model):
# Post to chatter so user sees confirmation (only if record is saved)
if self.id and not isinstance(self.id, models.NewId):
self.sudo().message_post(
body=Markup(_('3D model attached: <b>%s</b> surface area: %.4f %s')) % (
body=Markup(_('3D model attached: <b>%s</b> - surface area: %.4f %s')) % (
fname, self.surface_area, self.surface_area_uom or ''),
message_type='notification',
subtype_xmlid='mail.mt_note',
@@ -819,7 +819,7 @@ class FpQuoteConfigurator(models.Model):
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.rfq_attachment_id.id,
'title': _('RFQ %s') % (self.rfq_attachment_id.name or ''),
'title': _('RFQ - %s') % (self.rfq_attachment_id.name or ''),
},
}
@@ -832,7 +832,7 @@ class FpQuoteConfigurator(models.Model):
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.po_attachment_id.id,
'title': _('PO %s') % (self.po_attachment_id.name or ''),
'title': _('PO - %s') % (self.po_attachment_id.name or ''),
},
}
@@ -868,7 +868,7 @@ class FpQuoteConfigurator(models.Model):
def action_mark_lost(self):
"""Move this quote to 'lost' state. Caller should populate
`lost_reason` first a simple validation enforces that."""
`lost_reason` first - a simple validation enforces that."""
for rec in self:
if not rec.lost_reason:
from odoo.exceptions import UserError
@@ -880,7 +880,7 @@ class FpQuoteConfigurator(models.Model):
'lost_date': fields.Date.today(),
})
rec.message_post(
body=_('Quote marked lost reason: %s') % dict(
body=_('Quote marked lost - reason: %s') % dict(
rec._fields['lost_reason'].selection
).get(rec.lost_reason, rec.lost_reason),
)
@@ -972,7 +972,7 @@ class FpQuoteConfigurator(models.Model):
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.first_drawing_id.id,
'title': _('Drawing %s') % (self.first_drawing_id.name or ''),
'title': _('Drawing - %s') % (self.first_drawing_id.name or ''),
},
}
# No drawing: fall back to part catalog
@@ -980,7 +980,7 @@ class FpQuoteConfigurator(models.Model):
return
return {
'type': 'ir.actions.act_window',
'name': _('Drawings %s') % self.part_catalog_id.name,
'name': _('Drawings - %s') % self.part_catalog_id.name,
'res_model': 'fp.part.catalog',
'res_id': self.part_catalog_id.id,
'view_mode': 'form',

View File

@@ -7,12 +7,12 @@ from odoo import fields, models
class FpSaleDescriptionTemplate(models.Model):
"""Saved description snippets most often attached to a specific part.
"""Saved description snippets - most often attached to a specific part.
Real-world usage: a plating shop keeps 35 canned descriptions PER
Real-world usage: a plating shop keeps 3-5 canned descriptions PER
PART because the same customer part runs with different masking,
packaging, or spec-callout variations. With 3,500 parts and 5
variants each, that's ~17,500 rows so descriptions are scoped
variants each, that's ~17,500 rows - so descriptions are scoped
primarily by part, with optional fallback to customer / coating /
global.
@@ -23,14 +23,14 @@ class FpSaleDescriptionTemplate(models.Model):
4. Else show global (generic) templates.
"""
_name = 'fp.sale.description.template'
_description = 'Fusion Plating Sale Order Line Description Template'
_description = 'Fusion Plating - Sale Order Line Description Template'
_order = 'part_catalog_id, sequence, name'
name = fields.Char(
string='Template Name', required=True,
help='Short name shown in the picker (e.g. "Standard masking", "With threaded holes masked").',
)
# Sub 2 dual descriptions. Replaces the legacy `description` field
# Sub 2 - dual descriptions. Replaces the legacy `description` field
# (dropped in Phase C / Task 27). Migration Step 3 duplicated the old
# value into both columns; Step 6 drops the old column.
internal_description = fields.Text(
@@ -49,10 +49,10 @@ class FpSaleDescriptionTemplate(models.Model):
'fp.part.catalog', string='Part',
ondelete='cascade', index=True,
help='If set, this description belongs to one specific customer '
'part it only appears in the picker when this part is on '
'part - it only appears in the picker when this part is on '
'the order. Leave blank for generic fallback templates.',
)
# Related fields surface the part's partner for search & grouping
# Related fields - surface the part's partner for search & grouping
# without writing it twice.
partner_id = fields.Many2one(
'res.partner', string='Customer',

View File

@@ -16,10 +16,10 @@ class FpSerial(models.Model):
Most serials are customer-supplied (pass-through from the customer's
own end-user); a smaller share are shop-generated via the sequence.
The registry is optional SO lines can carry no serial at all.
The registry is optional - SO lines can carry no serial at all.
"""
_name = 'fp.serial'
_description = 'Fusion Plating Serial Number'
_description = 'Fusion Plating - Serial Number'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'name'
@@ -59,7 +59,7 @@ class FpSerial(models.Model):
notes = fields.Text(string='Notes')
# ==================================================================
# Phase 2 (2026-04-28) per-serial state machine
# Phase 2 (2026-04-28) - per-serial state machine
# ==================================================================
# Each physical part owns its own state independent of the parent
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
@@ -106,7 +106,7 @@ class FpSerial(models.Model):
'Surfaces on per-serial CoC entries (Phase 4).',
)
# Reverse from move log Phase 3 will populate this directly when
# Reverse from move log - Phase 3 will populate this directly when
# operators record per-serial moves on the tablet. Defined here so
# views can already render the count column.
move_count = fields.Integer(
@@ -120,15 +120,15 @@ class FpSerial(models.Model):
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
mapping = {
'received': 8, # blue fresh
'racked': 7, # sky staged
'in_process': 3, # yellow running
'inspected': 11, # olive passed QC, ready to ship
'packed': 4, # green boxed
'shipped': 4, # green out the door
'returned': 2, # orange back from customer
'received': 8, # blue - fresh
'racked': 7, # sky - staged
'in_process': 3, # yellow - running
'inspected': 11, # olive - passed QC, ready to ship
'packed': 4, # green - boxed
'shipped': 4, # green - out the door
'returned': 2, # orange - back from customer
'scrapped': 1, # red
'on_hold': 1, # red quality issue
'on_hold': 1, # red - quality issue
}
for rec in self:
rec.state_color = mapping.get(rec.state, 0)
@@ -142,7 +142,7 @@ class FpSerial(models.Model):
rec.move_count = 0
# ------------------------------------------------------------------
# State transitions log each one to chatter and stamp last_state_change
# State transitions - log each one to chatter and stamp last_state_change
# ------------------------------------------------------------------
def _set_state(self, new_state, message=None):
"""Internal helper. Validates the source state, flips, stamps,
@@ -157,7 +157,7 @@ class FpSerial(models.Model):
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
from odoo.exceptions import UserError
raise UserError(_(
'Serial %(name)s is %(old)s cannot transition to '
'Serial %(name)s is %(old)s - cannot transition to '
'%(new)s. Use Reopen if this is a correction.'
) % {
'name': rec.name,
@@ -203,12 +203,12 @@ class FpSerial(models.Model):
def action_mark_scrapped(self):
"""Scrap a single serial. Operator should fill scrap_reason next
view enforces it via a wizard form. Phase 3 hooks this into
- view enforces it via a wizard form. Phase 3 hooks this into
the move log so the parent job's qty_scrapped auto-increments."""
return self._set_state('scrapped')
def action_reopen(self):
"""Manager-only override un-pin a terminal state when a
"""Manager-only override - un-pin a terminal state when a
correction is needed (e.g. wrong serial marked shipped). Audit
trail preserved via chatter; never silently rewrites history."""
for rec in self:
@@ -219,10 +219,10 @@ class FpSerial(models.Model):
'serial state. Contact your shop manager.'
))
return self._set_state('in_process', message=_(
'Serial reopened by %s terminal state reverted for correction.'
'Serial reopened by %s - terminal state reverted for correction.'
) % self.env.user.name)
# Reverse link to invoice lines safe here because account.move.line
# Reverse link to invoice lines - safe here because account.move.line
# lives in this same module. Production (mrp) and delivery (logistics)
# reverse links are defined in their own modules' fp_serial inherits
# to keep module load order consistent.

View File

@@ -9,14 +9,14 @@ from odoo import api, fields, models
class FpSoJobSort(models.Model):
"""A user-defined grouping bucket for sale orders ("Job Sorting").
Same pattern as `fusion.plating.tank.section` every shop slices its
Same pattern as `fusion.plating.tank.section` - every shop slices its
SO backlog differently (by customer programme, by priority, by
fabricator group, by week, etc.). Sections are free-form, renameable,
quick-creatable from the M2O dropdown, and let users group the SO
list with fold/expand sections.
"""
_name = 'fp.so.job.sort'
_description = 'Fusion Plating Sale Order Job Sort'
_description = 'Fusion Plating - Sale Order Job Sort'
_order = 'sequence, name'
name = fields.Char(

View File

@@ -13,9 +13,9 @@ class ProductPricelist(models.Model):
by the Express Orders form's pricelist_id field), prefix the display
name with the currency code so the dropdown reads:
CAD Public Pricelist (CAD)
USD Westin USA Pricelist
EUR Public Pricelist (EUR)
CAD - Public Pricelist (CAD)
USD - Westin USA Pricelist
EUR - Public Pricelist (EUR)
Elsewhere in Odoo (partner form, sale.order, settings), the standard
pricelist display name is unchanged.
@@ -29,4 +29,4 @@ class ProductPricelist(models.Model):
if self.env.context.get('fp_express_currency_picker'):
for pl in self:
if pl.currency_id and pl.currency_id.name not in (pl.display_name or ''):
pl.display_name = f"{pl.currency_id.name} {pl.name}"
pl.display_name = f"{pl.currency_id.name} - {pl.name}"

View File

@@ -43,7 +43,7 @@ class ResPartner(models.Model):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Parts {self.name}',
'name': f'Parts - {self.name}',
'res_model': 'fp.part.catalog',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id), ('is_latest_revision', '=', True)],

View File

@@ -28,7 +28,7 @@ class SaleOrder(models.Model):
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
upload_po_filename = fields.Char(string='PO Filename')
x_fc_po_override = fields.Boolean(string='PO Override',
help='Manager override proceed without formal PO (handshake deal).')
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
@@ -57,7 +57,7 @@ class SaleOrder(models.Model):
x_fc_deposit_percent = fields.Float(string='Deposit %',
help='Deposit percentage if strategy is Deposit.')
x_fc_progress_initial_percent = fields.Float(
string='Progress Initial %',
string='Progress - Initial %',
default=50.0,
help='First-phase percentage for Progress Billing strategy. '
'Billed on SO confirmation; remainder billed on delivery.',
@@ -69,11 +69,11 @@ class SaleOrder(models.Model):
)
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
# Lead Time (Phase D11) promised production window in business
# Lead Time (Phase D11) - promised production window in business
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
# so we render a proper expectation on the SO confirmation instead
# of the binary Standard/Rush we had before. Both fields default to
# 0 `x_fc_lead_time_display` computes the right human-readable
# 0 - `x_fc_lead_time_display` computes the right human-readable
# string (range / single value / Rush / Standard) for the PDF.
x_fc_lead_time_min_days = fields.Integer(
string='Lead Time Min (days)', tracking=True,
@@ -100,7 +100,7 @@ class SaleOrder(models.Model):
('received', 'Received')],
string='Receiving Status', default='not_received', tracking=True,
help='State of the linked fp.receiving record(s). Inspection is '
"no longer a receiving state Sub 8 moved part inspection "
"no longer a receiving state - Sub 8 moved part inspection "
'into the recipe (racking step), so receiving stops at '
'"received" (boxes counted, staged, closed).',
)
@@ -117,19 +117,19 @@ class SaleOrder(models.Model):
ondelete='set null',
tracking=True,
help='Free-form bucket that groups this SO in the "Sale Orders '
'by Sorting" list view. Quick-create from the dropdown '
'by Sorting" list view. Quick-create from the dropdown - '
'each shop slices its backlog differently (customer programme, '
'priority, week, etc.).',
)
# ---- Express Orders header-level (2026-05-26) ----
# 2026-05-27: changed from Char to Many2One Material/Process Tag
# 2026-05-27: changed from Char to Many2One - Material/Process Tag
# IS the order's recipe. Auto-applies to every line at confirm time.
x_fc_material_process = fields.Many2one(
'fusion.plating.process.node',
string='Material / Process Tag',
domain="[('node_type', '=', 'recipe')]",
help='Order-level recipe auto-applies to every line. Individual '
help='Order-level recipe - auto-applies to every line. Individual '
'lines can still override via x_fc_process_variant_id.',
)
x_fc_internal_notes = fields.Text(
@@ -207,7 +207,7 @@ class SaleOrder(models.Model):
store=True,
help='When the LATEST line is actually due. Auto-rolled up from '
'each line\'s effective deadline. Distinct from Customer '
'Deadline (what we promised) this reflects shop reality.',
'Deadline (what we promised) - this reflects shop reality.',
)
x_fc_is_late_forecast = fields.Boolean(
string='Late Forecast',
@@ -228,7 +228,7 @@ class SaleOrder(models.Model):
x_fc_margin_available = fields.Boolean(
string='Margin Available',
compute='_compute_margin',
help='False when no order line has a costed coating the '
help='False when no order line has a costed coating - the '
'margin fields should render "n/a" in the UI.',
)
@@ -270,7 +270,7 @@ class SaleOrder(models.Model):
rec.x_fc_has_wo_group_tag = bool(tags)
rec.x_fc_wo_group_count = len(tags)
# Sub 9 process variant summary across order lines. Renders one
# Sub 9 - process variant summary across order lines. Renders one
# variant label when all lines share one, otherwise "Mixed (N)".
x_fc_process_summary = fields.Char(
string='Process',
@@ -318,7 +318,7 @@ class SaleOrder(models.Model):
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
# Delivered → Invoiced → Paid → Cancelled.
# Rendered as an Html field so each kind can carry its own tint via
# an .fp-kind-* class Bootstrap's 5 decoration-* slots aren't
# an .fp-kind-* class - Bootstrap's 5 decoration-* slots aren't
# enough to give every phase a distinct colour. SCSS bundle at
# static/src/scss/fp_job_status_pill.scss owns the colour map.
x_fc_fp_job_status = fields.Html(
@@ -340,7 +340,7 @@ class SaleOrder(models.Model):
('danger', 'Cancelled (red)')],
string='Job Status Kind',
compute='_compute_fp_job_status',
help='Colour category that backs the Job Status pill also '
help='Colour category that backs the Job Status pill - also '
'usable for filtering / grouping in the list search panel.',
)
@@ -380,7 +380,7 @@ class SaleOrder(models.Model):
):
return ('Paid', 'paid')
# Shipping phase signals read once.
# Shipping phase signals - read once.
ship_status = None
if 'x_fc_receiving_ids' in so._fields:
for r in so.x_fc_receiving_ids:
@@ -408,7 +408,7 @@ class SaleOrder(models.Model):
if ship_status == 'in_transit':
return ('In Transit', 'shipping')
# WO phase figure out total steps and the current step name.
# WO phase - figure out total steps and the current step name.
tot = 0
current_step_name = None
Job = so.env.get('fp.job')
@@ -469,7 +469,7 @@ class SaleOrder(models.Model):
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in self.
Sub 11 MRP is gone; we count fp.job.step completion instead of
Sub 11 - MRP is gone; we count fp.job.step completion instead of
mrp.workorder. The selection is the same shape: completed steps
out of total steps across every fp.job for this SO.
"""
@@ -486,7 +486,7 @@ class SaleOrder(models.Model):
if not jobs:
return
job_to_origin = {j.id: j.origin for j in jobs}
# Odoo 19 use _read_group with aggregates=['__count'].
# Odoo 19 - use _read_group with aggregates=['__count'].
rows = Step._read_group(
domain=[('job_id', 'in', jobs.ids)],
groupby=['job_id', 'state'],
@@ -563,8 +563,8 @@ class SaleOrder(models.Model):
a read notification for any email message on this SO)
- state sale / done => won
'Opened' is scoped to the CUSTOMER partner's notifications
not internal CCs to avoid false positives from sales-ops
'Opened' is scoped to the CUSTOMER partner's notifications -
not internal CCs - to avoid false positives from sales-ops
viewing the thread.
"""
for rec in self:
@@ -643,7 +643,7 @@ class SaleOrder(models.Model):
for rec in self:
rec.x_fc_picking_count = len(rec.picking_ids)
# NCR counts only if the module is installed.
# NCR counts - only if the module is installed.
ids = self.ids
NCR = self.env.get('fusion.plating.ncr')
ncr_counts = {}
@@ -790,7 +790,7 @@ class SaleOrder(models.Model):
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Margin computation stub.
"""Margin computation - stub.
Pre-promote-customer-spec, this rolled up cost from
fp.coating.config.unit_cost. Coating Config is retired; cost
@@ -843,7 +843,7 @@ class SaleOrder(models.Model):
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.x_fc_rfq_attachment_id.id,
'title': 'RFQ %s' % (self.x_fc_rfq_attachment_id.name or ''),
'title': 'RFQ - %s' % (self.x_fc_rfq_attachment_id.name or ''),
},
}
@@ -856,11 +856,11 @@ class SaleOrder(models.Model):
'tag': 'fp_pdf_preview_open',
'params': {
'attachment_id': self.x_fc_po_attachment_id.id,
'title': 'PO %s' % (self.x_fc_po_attachment_id.name or ''),
'title': 'PO - %s' % (self.x_fc_po_attachment_id.name or ''),
},
}
# ---- Sub 5 auto-assign Job # on confirm -------------------------------
# ---- Sub 5 - auto-assign Job # on confirm -------------------------------
# Job # is the shop-floor reference that prints on travellers and WOs.
# Auto-assigned once at confirm so every confirmed line has one; still
# editable afterwards (clearable, overridable to match a customer scheme).
@@ -869,7 +869,7 @@ class SaleOrder(models.Model):
# Sale Orders. Sales Rep can save drafts but cannot move them to
# 'sale' state. The has_group() check resolves True for Sales Manager,
# Manager (implies Sales Manager via diamond), Quality Manager
# (implies Manager), and Owner (implies Quality Manager) see
# (implies Manager), and Owner (implies Quality Manager) - see
# spec Section 2.B.
if not self.env.user.has_group('fusion_plating.group_fp_sales_manager'):
raise UserError(_(

View File

@@ -39,7 +39,7 @@ class SaleOrderLine(models.Model):
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n-—–:').strip()
return tail.lstrip(' \t\r\n---:').strip()
return name
@api.onchange('x_fc_part_catalog_id')
@@ -59,7 +59,7 @@ class SaleOrderLine(models.Model):
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)
# Sub 2 dual descriptions captured from a template row at order
# Sub 2 - dual descriptions captured from a template row at order
# entry. `name` remains Odoo's standard customer-facing line
# description; x_fc_internal_description is ops-only (prints on WO).
# Nullable during Phase A; flipped to required in Phase C.
@@ -101,7 +101,7 @@ class SaleOrderLine(models.Model):
string='Shop Target',
compute='_compute_effective_internal_deadline',
store=True,
help='Internal deadline for this line effective customer '
help='Internal deadline for this line - effective customer '
'deadline minus the order\'s shop buffer (commitment_date '
'internal_deadline gap). Clamped so it never exceeds the '
'effective customer deadline.',
@@ -133,7 +133,7 @@ class SaleOrderLine(models.Model):
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
# Sub 9 (polished 2026-04-28) process variant per line. The picker
# Sub 9 (polished 2026-04-28) - process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
# marked is_template. Cross-part picks auto-clone onto this part on
@@ -144,7 +144,7 @@ class SaleOrderLine(models.Model):
string='Process Variant',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick any recipe the part\'s own variant, another part\'s '
help='Pick any recipe - the part\'s own variant, another part\'s '
'recipe, or a template from the library. If the chosen recipe '
'doesn\'t belong to this part, it will be cloned onto the part '
'when the order saves so per-line edits stay scoped. Use the '
@@ -154,7 +154,7 @@ class SaleOrderLine(models.Model):
string='Save as Default for Part',
default=False,
help='When ticked, the chosen process variant becomes this part\'s '
'default on order save future orders for the same part '
'default on order save - future orders for the same part '
'pre-fill with this variant.',
)
x_fc_archived = fields.Boolean(
@@ -164,12 +164,12 @@ class SaleOrderLine(models.Model):
'preserved for audit. Useful when a part is cancelled mid-order.',
)
# ---- Sub 5 Order-line fields (serial / job# / thickness / revision) ---
# ---- Sub 5 - Order-line fields (serial / job# / thickness / revision) ---
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
# inherited fields Odoo emits a warning and ignores it. Audit trail
# inherited fields - Odoo emits a warning and ignores it. Audit trail
# for these values lives on fp.serial.mail.thread instead.
#
# 2026-04-28 Phase 1 multi-serial support. Customer can ship 30 parts
# 2026-04-28 Phase 1 - multi-serial support. Customer can ship 30 parts
# with 30 distinct serials on a single line. The M2M is the source of
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
# serial so existing reports / smart buttons / downstream code that
@@ -194,7 +194,7 @@ class SaleOrderLine(models.Model):
search='_search_primary_serial',
store=False,
copy=False,
help='First of the line\'s serials back-compat alias kept so '
help='First of the line\'s serials - back-compat alias kept so '
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
'reverse links) keeps working. Setting this prepends the '
'serial to the M2M.',
@@ -278,7 +278,7 @@ class SaleOrderLine(models.Model):
if commit:
line.x_fc_effective_part_deadline = commit
continue
# 5. last resort planned start so the field is never null
# 5. last resort - planned start so the field is never null
line.x_fc_effective_part_deadline = start
@api.depends(
@@ -321,7 +321,7 @@ class SaleOrderLine(models.Model):
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Target thickness range as the operator types it, e.g. '
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text '
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text - '
'auto-fills from the last order for this (part, customer) '
'pair, falling back to the part\'s default range. Prints '
'verbatim on the cert, packing slip, and invoice.',
@@ -367,7 +367,7 @@ class SaleOrderLine(models.Model):
'or the catalog row is removed.',
)
# Revision picker non-stored compute that re-points x_fc_part_catalog_id
# Revision picker - non-stored compute that re-points x_fc_part_catalog_id
# to any revision of the same part number. The Part M2O itself is domain-
# filtered to latest revisions only, so the picker is what surfaces
# earlier revisions when the estimator needs one.
@@ -395,7 +395,7 @@ class SaleOrderLine(models.Model):
honour the Save-as-Default toggle.
Called from create() and write() so the polish runs on every
save path onchange alone doesn't cover programmatic creates
save path - onchange alone doesn't cover programmatic creates
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
"""
for line in self:
@@ -422,7 +422,7 @@ class SaleOrderLine(models.Model):
but programmatic creators (sale_mrp bridge, migration scripts,
external integrations, demo seeders) may not know about this
field. Instead of forcing every call site to update, fall back
to `name` same rule the upgrade migration used when it
to `name` - same rule the upgrade migration used when it
back-filled historical lines.
"""
Product = self.env['product.product']
@@ -430,7 +430,7 @@ class SaleOrderLine(models.Model):
for vals in vals_list:
if not vals.get('x_fc_internal_description'):
# Try the explicit `name` first. If the caller didn't pass
# one (sale_mrp + some Odoo internals don't they let the
# one (sale_mrp + some Odoo internals don't - they let the
# name compute from product_id later), fall back to the
# product's display_name so we have SOMETHING non-empty.
fallback = vals.get('name')
@@ -438,16 +438,16 @@ class SaleOrderLine(models.Model):
prod = Product.browse(vals['product_id']).exists()
if prod:
fallback = prod.display_name or prod.name
vals['x_fc_internal_description'] = fallback or ''
vals['x_fc_internal_description'] = fallback or '-'
# Sub 5 freeze the revision letter on the line at save time.
# Sub 5 - freeze the revision letter on the line at save time.
# Protects historical SOs from later edits to the catalog row.
if not vals.get('x_fc_revision_snapshot') and vals.get('x_fc_part_catalog_id'):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.revision:
vals['x_fc_revision_snapshot'] = part.revision
# Auto-fill thickness range same logic as the onchange but
# Auto-fill thickness range - same logic as the onchange but
# for programmatic creators (wizard, sale_mrp, imports).
# Resolution: explicit > last-used (part, partner) > part default.
if (not vals.get('x_fc_thickness_range')
@@ -477,7 +477,7 @@ class SaleOrderLine(models.Model):
return lines
def write(self, vals):
# Sub 5 keep the revision snapshot in lockstep with the line's
# Sub 5 - keep the revision snapshot in lockstep with the line's
# part catalog pointer. Only refresh when the part changes; never
# overwrite a snapshot that's already been set on a historical line.
if 'x_fc_part_catalog_id' in vals:
@@ -490,7 +490,7 @@ class SaleOrderLine(models.Model):
if line.x_fc_part_catalog_id.id != new_part.id:
line.x_fc_revision_snapshot = new_part.revision
result = super().write(vals)
# Only run the polish when something relevant actually changed
# Only run the polish when something relevant actually changed -
# avoids re-running on every unrelated write (e.g. price updates).
if any(k in vals for k in (
'x_fc_process_variant_id',
@@ -527,11 +527,11 @@ class SaleOrderLine(models.Model):
def _prepare_invoice_line(self, **optional_values):
"""Carry x_fc_part_catalog_id + Sub 5 fields from SO line to invoice line.
Sub 2 Task 19 lets the customer-facing invoice PDF render the
Sub 2 Task 19 - lets the customer-facing invoice PDF render the
customer's part number via the shared customer_line_header macro
instead of the internal service SKU.
Sub 5 also carry serial / job# / thickness / revision snapshot so
Sub 5 - also carry serial / job# / thickness / revision snapshot so
the same macro can print them unchanged on invoices.
"""
vals = super()._prepare_invoice_line(**optional_values)
@@ -563,7 +563,7 @@ class SaleOrderLine(models.Model):
Previously cleared the variant entirely when the part changed
(because the variant picker was scoped to the part). Now that
the picker is system-wide, we instead pre-fill from the part's
default much more useful.
default - much more useful.
"""
for line in self:
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
@@ -590,8 +590,8 @@ class SaleOrderLine(models.Model):
if not recipe or not part:
return recipe
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
return recipe # already scoped nothing to do
# Clone Odoo's default copy() recurses through child_ids when the
return recipe # already scoped - nothing to do
# Clone - Odoo's default copy() recurses through child_ids when the
# field has copy=True. fp.process.node sets that on its tree, so
# one call gets us a full sub-tree clone.
clone_name = recipe.name or _('Untitled Recipe')
@@ -599,7 +599,7 @@ class SaleOrderLine(models.Model):
# the customer's part number for quick identification on the
# variant dropdown later.
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
clone_name = '%s %s' % (clone_name, part.part_number or part.display_name)
clone_name = '%s - %s' % (clone_name, part.part_number or part.display_name)
clone = recipe.copy({
'name': clone_name,
'part_catalog_id': part.id,
@@ -611,7 +611,7 @@ class SaleOrderLine(models.Model):
def action_customize_process(self):
"""Open the Process Composer for this line's process variant.
Auto-clones first if the variant isn't yet scoped to this part
Auto-clones first if the variant isn't yet scoped to this part -
the operator should never edit a recipe that's shared across
customers (their edits would bleed). After cloning, the line
ends up pointing at the fresh per-part copy.
@@ -620,7 +620,7 @@ class SaleOrderLine(models.Model):
if not self.x_fc_part_catalog_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a part on this line before customizing the process '
'Pick a part on this line before customizing the process - '
'the recipe needs a part to scope the variant.'
))
if not self.x_fc_process_variant_id:
@@ -635,7 +635,7 @@ class SaleOrderLine(models.Model):
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': _('Customize Process %s') % (
'name': _('Customize Process - %s') % (
self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number
or '?'
@@ -658,7 +658,7 @@ class SaleOrderLine(models.Model):
2. Most recent SO line for (this part, this customer) with a
non-empty thickness_range → copy that
3. Part's x_fc_default_thickness_range → copy
4. Blank operator types
4. Blank - operator types
"""
for line in self:
if line.x_fc_thickness_range:
@@ -741,7 +741,7 @@ class SaleOrderLine(models.Model):
# ---- Customer references mirrored from parent sale.order ----------
# Related (not stored) display-only on the line list so shipping /
# Related (not stored) - display-only on the line list so shipping /
# invoicing operators see the customer's job/PO ref per-line without
# navigating up to the order header.
x_fc_customer_job_number = fields.Char(
@@ -802,7 +802,7 @@ class SaleOrderLine(models.Model):
)
# ============================================================
# Express Orders backend helpers (Phase B 2026-05-26)
# Express Orders backend helpers (Phase B - 2026-05-26)
# ============================================================
def _fp_apply_express_overrides_to_job(self, job):
@@ -837,7 +837,7 @@ class SaleOrderLine(models.Model):
msgs = []
# 1. Masking opt out of masking + de_masking AS A PAIR
# 1. Masking - opt out of masking + de_masking AS A PAIR
if not self.x_fc_masking_enabled:
nodes = recipe._fp_all_nodes_with_kind(('mask', 'demask'))
for node in nodes:
@@ -862,7 +862,7 @@ class SaleOrderLine(models.Model):
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
% len(self.x_fc_masking_attachment_ids))
# 2. Bake empty = opt out; non-empty = keep + write step.instructions
# 2. Bake - empty = opt out; non-empty = keep + write step.instructions
bake_text = (self.x_fc_bake_instructions or '').strip()
bake_nodes = recipe._fp_all_nodes_with_kind(('bake',))
if not bake_text:
@@ -876,7 +876,7 @@ class SaleOrderLine(models.Model):
msgs.append(_('Baking steps opted out (per SO line)'))
else:
# Step instructions write only succeeds if steps exist. The
# helper is called twice first call (before action_confirm)
# helper is called twice - first call (before action_confirm)
# finds no steps and skips; second call (after step gen) lands.
bake_steps = job.step_ids.filtered(
lambda s: s.recipe_node_id.default_kind == 'bake'