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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 3–5 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 3–5 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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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(_(
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user