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:
@@ -10,7 +10,7 @@ from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDirectOrderLine(models.Model):
|
||||
"""Sub 9 — persistent so the parent draft survives navigation."""
|
||||
"""Sub 9 - persistent so the parent draft survives navigation."""
|
||||
_name = 'fp.direct.order.line'
|
||||
_description = 'Fusion Plating - Direct Order Line'
|
||||
_order = 'sequence, id'
|
||||
@@ -54,7 +54,7 @@ class FpDirectOrderLine(models.Model):
|
||||
# Specification picker (customer_spec_id) added by
|
||||
# fusion_plating_quality. Legacy coating_config_id +
|
||||
# treatment_ids removed.
|
||||
# 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
|
||||
@@ -64,7 +64,7 @@ class FpDirectOrderLine(models.Model):
|
||||
string='Process Variant',
|
||||
domain="[('id', 'in', recipe_choice_ids)]",
|
||||
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. Cross-part picks '
|
||||
'are cloned onto this part on save so per-line edits stay '
|
||||
'scoped. Use the Customize button to open the Process '
|
||||
@@ -88,7 +88,7 @@ class FpDirectOrderLine(models.Model):
|
||||
SOL = self.env['sale.order.line']
|
||||
for rec in self:
|
||||
ids = set()
|
||||
# 1) Templates — the "parent recipes" the operator sees first.
|
||||
# 1) Templates - the "parent recipes" the operator sees first.
|
||||
templates = Node.search([
|
||||
('parent_id', '=', False),
|
||||
('node_type', '=', 'recipe'),
|
||||
@@ -108,7 +108,7 @@ class FpDirectOrderLine(models.Model):
|
||||
], order='create_date desc', limit=500
|
||||
).mapped('x_fc_process_variant_id')
|
||||
ids.update(used.ids)
|
||||
# 4) The wizard's order-level Material/Process recipe — must be
|
||||
# 4) The wizard's order-level Material/Process recipe - must be
|
||||
# selectable on the line so the G3 propagation can write it
|
||||
# without the domain rejecting (2026-05-27 fix).
|
||||
if rec.wizard_id and rec.wizard_id.material_process:
|
||||
@@ -117,7 +117,7 @@ class FpDirectOrderLine(models.Model):
|
||||
save_as_default_process = fields.Boolean(
|
||||
string='Set as Part Default',
|
||||
help='When ticked, the chosen process variant becomes this part\'s '
|
||||
'default on order submit — future orders for the same part '
|
||||
'default on order submit - future orders for the same part '
|
||||
'pre-fill with this variant.',
|
||||
)
|
||||
# Read-only preview of the process tree that WILL drive WO generation
|
||||
@@ -160,7 +160,7 @@ class FpDirectOrderLine(models.Model):
|
||||
"""Pre-fill the line from the part's saved defaults when the part
|
||||
changes.
|
||||
|
||||
2026-04-28 polish: variant is no longer cleared — instead it
|
||||
2026-04-28 polish: variant is no longer cleared - instead it
|
||||
pre-fills from the part's `default_process_id` so the estimator
|
||||
gets a sensible starting point. (Domain is system-wide now, so
|
||||
a stale value would still load fine; we just upgrade the UX.)
|
||||
@@ -168,7 +168,7 @@ class FpDirectOrderLine(models.Model):
|
||||
Pre-fill coating + treatments from the part's saved defaults so
|
||||
the estimator doesn't re-pick the same coating every repeat
|
||||
customer. Defaults only apply when the line currently has no
|
||||
coating set — editing an existing line with a chosen coating
|
||||
coating set - editing an existing line with a chosen coating
|
||||
doesn't get clobbered.
|
||||
|
||||
For BRAND-NEW parts (no defaults saved yet) auto-tick
|
||||
@@ -176,13 +176,13 @@ class FpDirectOrderLine(models.Model):
|
||||
back to the part. Without this, the estimator has to remember
|
||||
to tick the toggle and the second order doesn't pre-fill.
|
||||
(The explanatory popup was removed 2026-05-29 at the client's
|
||||
request — the ticked "Save as Default" checkbox is the cue now.)
|
||||
request - the ticked "Save as Default" checkbox is the cue now.)
|
||||
"""
|
||||
warning = None
|
||||
for rec in self:
|
||||
# Pre-fill variant from the part's default (was: blanket clear).
|
||||
if rec.part_catalog_id and rec.part_catalog_id.default_process_id:
|
||||
# Only overwrite when blank or pointing at a different part —
|
||||
# Only overwrite when blank or pointing at a different part -
|
||||
# don't clobber a deliberate cross-part pick the estimator
|
||||
# made before changing the part.
|
||||
if (not rec.process_variant_id
|
||||
@@ -201,7 +201,7 @@ class FpDirectOrderLine(models.Model):
|
||||
# falls back to the most recent SO line for (part, customer)
|
||||
# when the part default is empty. If that lookup will find
|
||||
# a hit, this is NOT a first-time use from the operator's
|
||||
# perspective — the spec will silently pre-fill from history.
|
||||
# perspective - the spec will silently pre-fill from history.
|
||||
# Suppress the warning in that case so we don't pop a
|
||||
# misleading "no saved specification" alert right when the
|
||||
# spec actually does auto-fill.
|
||||
@@ -220,7 +220,7 @@ class FpDirectOrderLine(models.Model):
|
||||
# of the part. Auto-tick the push_to_defaults toggle so
|
||||
# whatever the estimator picks becomes the saved default.
|
||||
# The explanatory popup was removed 2026-05-29 at the
|
||||
# client's request — the ticked "Save as Default" checkbox on
|
||||
# client's request - the ticked "Save as Default" checkbox on
|
||||
# the line is the visible cue now (no nag dialog).
|
||||
# `is_one_off` always wins (operator opted out of catalog
|
||||
# persistence), so don't auto-tick in that case.
|
||||
@@ -255,7 +255,7 @@ class FpDirectOrderLine(models.Model):
|
||||
currency_field='currency_id',
|
||||
compute='_compute_line_subtotal',
|
||||
)
|
||||
# Sub 9 — taxes per line. Defaults from the FP-SERVICE product's
|
||||
# Sub 9 - taxes per line. Defaults from the FP-SERVICE product's
|
||||
# sale taxes; fiscal-position-mapped from the customer when the
|
||||
# wizard creates the SO line. Overridable per row.
|
||||
tax_ids = fields.Many2many(
|
||||
@@ -311,7 +311,7 @@ class FpDirectOrderLine(models.Model):
|
||||
string='Start at Node',
|
||||
domain="[('id', 'child_of', process_variant_id and process_variant_id.id or 0)]",
|
||||
help='For re-work jobs: pick the recipe step where this job should '
|
||||
'begin. Pick a recipe first — nodes are scoped to it. Skips '
|
||||
'begin. Pick a recipe first - nodes are scoped to it. Skips '
|
||||
'earlier steps in the generated WO but keeps later siblings '
|
||||
'and sub-processes.',
|
||||
)
|
||||
@@ -342,12 +342,12 @@ class FpDirectOrderLine(models.Model):
|
||||
string='Line Description',
|
||||
help='Customer-facing text. Becomes the SO line description and '
|
||||
'prints on the acknowledgement, invoice, and packing slip. '
|
||||
'Edit freely — your changes override the template.',
|
||||
'Edit freely - your changes override the template.',
|
||||
)
|
||||
internal_description = fields.Text(
|
||||
string='Internal Description',
|
||||
help='Shop-floor instructions. Prints on WO / traveler. Never on '
|
||||
'customer docs. Edit freely — your changes override the template.',
|
||||
'customer docs. Edit freely - your changes override the template.',
|
||||
)
|
||||
|
||||
# ---- Missing info per line ----
|
||||
@@ -356,12 +356,12 @@ class FpDirectOrderLine(models.Model):
|
||||
compute='_compute_is_missing_info',
|
||||
)
|
||||
|
||||
# ---- Sub 5 / Phase 1 — Serials / Job# / Thickness --------------------
|
||||
# ---- Sub 5 / Phase 1 - Serials / Job# / Thickness --------------------
|
||||
# These mirror the SO-line fields and are carried over when the wizard
|
||||
# creates the sale order. Serial stays optional; Job# is left blank
|
||||
# here and gets auto-assigned by action_confirm on the SO.
|
||||
#
|
||||
# 2026-04-28 Phase 1 — multi-serial. M2M is the source of truth;
|
||||
# 2026-04-28 Phase 1 - multi-serial. M2M is the source of truth;
|
||||
# serial_id stays as a computed alias so existing flows that read
|
||||
# the singular continue to work.
|
||||
serial_ids = fields.Many2many(
|
||||
@@ -383,7 +383,7 @@ class FpDirectOrderLine(models.Model):
|
||||
compute='_compute_primary_serial',
|
||||
inverse='_inverse_primary_serial',
|
||||
store=False,
|
||||
help='First of the line\'s serials — back-compat alias.',
|
||||
help='First of the line\'s serials - back-compat alias.',
|
||||
)
|
||||
|
||||
@api.depends('serial_ids')
|
||||
@@ -503,7 +503,7 @@ class FpDirectOrderLine(models.Model):
|
||||
compute='_compute_serials_text',
|
||||
inverse='_inverse_serials_text',
|
||||
store=False,
|
||||
help='Comma-separated list of serial numbers — typing here parses, '
|
||||
help='Comma-separated list of serial numbers - typing here parses, '
|
||||
'creates new fp.serial records as needed, and updates the M2M.',
|
||||
)
|
||||
|
||||
@@ -549,7 +549,7 @@ class FpDirectOrderLine(models.Model):
|
||||
new = Serial.sudo().create({'name': name})
|
||||
ids.append(new.id)
|
||||
rec.serial_ids = [(6, 0, ids)]
|
||||
# Anchor field for the FpExpressActionBtns widget — renders the
|
||||
# Anchor field for the FpExpressActionBtns widget - renders the
|
||||
# stacked DWG / OPEN buttons in one list column. The widget reads
|
||||
# part_catalog_id from the line; this field's value is unused.
|
||||
action_btns_anchor = fields.Many2one(
|
||||
@@ -599,7 +599,7 @@ class FpDirectOrderLine(models.Model):
|
||||
|
||||
@api.onchange('part_catalog_id')
|
||||
def _onchange_part_default_thickness(self):
|
||||
"""Auto-fill thickness range + Express defaults — same chain as the SO line.
|
||||
"""Auto-fill thickness range + Express defaults - same chain as the SO line.
|
||||
|
||||
For each cell, the chain is:
|
||||
1. Operator already typed → keep
|
||||
@@ -683,7 +683,7 @@ class FpDirectOrderLine(models.Model):
|
||||
def _fp_sync_to_part(self):
|
||||
"""Push tracked line fields back to the linked part's defaults.
|
||||
|
||||
Called from create + write. Last-write-wins semantics — if two
|
||||
Called from create + write. Last-write-wins semantics - if two
|
||||
orders simultaneously edit the same part, the later one's values
|
||||
become the part's defaults. Acceptable per dev-stage policy;
|
||||
the part chatter records the change either way.
|
||||
@@ -777,7 +777,7 @@ class FpDirectOrderLine(models.Model):
|
||||
def action_upload_masking_ref(self):
|
||||
"""Attach a masking reference (image/PDF) to this line.
|
||||
|
||||
Called by the Express 'MASK REF' button — once per file (multi-select
|
||||
Called by the Express 'MASK REF' button - once per file (multi-select
|
||||
loops in JS), via context keys fp_masking_file + fp_masking_filename.
|
||||
Stored on the line's masking_attachment_ids; carried to the SO line
|
||||
and the job's masking step at order confirm.
|
||||
@@ -800,7 +800,7 @@ class FpDirectOrderLine(models.Model):
|
||||
def action_upload_drawing(self):
|
||||
"""Attach a file (via context) to the line's part as a drawing.
|
||||
|
||||
Mirrors sale.order.line.action_upload_drawing — same behaviour,
|
||||
Mirrors sale.order.line.action_upload_drawing - same behaviour,
|
||||
same context keys (fp_drawing_file + fp_drawing_filename).
|
||||
"""
|
||||
self.ensure_one()
|
||||
@@ -833,7 +833,7 @@ class FpDirectOrderLine(models.Model):
|
||||
def action_generate_serial(self):
|
||||
"""Generate one auto-sequenced fp.serial and append to the M2M.
|
||||
|
||||
Phase 1: appends instead of replacing — repeated clicks add more.
|
||||
Phase 1: appends instead of replacing - repeated clicks add more.
|
||||
"""
|
||||
self.ensure_one()
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
|
||||
@@ -897,8 +897,8 @@ class FpDirectOrderLine(models.Model):
|
||||
|
||||
Order of precedence for "remember last entered" fields
|
||||
(process_variant_id, unit_price, tax_ids):
|
||||
1. What the operator already typed on this line — never clobber
|
||||
2. Most recent SO line for (part_catalog_id, partner) — the
|
||||
1. What the operator already typed on this line - never clobber
|
||||
2. Most recent SO line for (part_catalog_id, partner) - the
|
||||
"last entered" carry-over so repeat orders feel sticky
|
||||
3. Fall back to product / part defaults
|
||||
|
||||
@@ -934,17 +934,17 @@ class FpDirectOrderLine(models.Model):
|
||||
], order='create_date desc', limit=1)
|
||||
if not recent:
|
||||
return
|
||||
# Process variant — only if the line doesn't already have a pick.
|
||||
# Process variant - only if the line doesn't already have a pick.
|
||||
# The part's default still applies as a fallback in
|
||||
# _onchange_part_clears_variant above; this beats it when the
|
||||
# customer's last SO had a specific variant.
|
||||
if not self.process_variant_id and recent.x_fc_process_variant_id:
|
||||
self.process_variant_id = recent.x_fc_process_variant_id
|
||||
# Unit price — only when blank/zero. Avoids overwriting a
|
||||
# Unit price - only when blank/zero. Avoids overwriting a
|
||||
# quote-driven or hand-typed price.
|
||||
if not self.unit_price and recent.price_unit:
|
||||
self.unit_price = recent.price_unit
|
||||
# Taxes — only when blank. The downstream
|
||||
# Taxes - only when blank. The downstream
|
||||
# _seed_default_taxes() fallback handles the no-prior-line case.
|
||||
# NB: SO line field is `tax_ids` (Odoo 19 renamed from tax_id).
|
||||
if not self.tax_ids and recent.tax_ids:
|
||||
@@ -966,7 +966,7 @@ class FpDirectOrderLine(models.Model):
|
||||
if taxes:
|
||||
self.tax_ids = [(6, 0, taxes.ids)]
|
||||
|
||||
# Auto-fill unit_price from a customer price list — extended in
|
||||
# Auto-fill unit_price from a customer price list - extended in
|
||||
# fusion_plating_quality (the spec field lives there). The base
|
||||
# configurator wizard no longer triggers price lookup since
|
||||
# coating_config_id is gone.
|
||||
@@ -989,12 +989,12 @@ class FpDirectOrderLine(models.Model):
|
||||
|
||||
@api.onchange('part_catalog_id')
|
||||
def _onchange_suggest_template(self):
|
||||
"""Offer a sensible default template — part-specific wins.
|
||||
"""Offer a sensible default template - part-specific wins.
|
||||
|
||||
Priority (first non-empty result wins):
|
||||
1. This part's lowest-sequence active template
|
||||
2. This customer's templates (no part)
|
||||
3. Don't auto-pick — user has to choose
|
||||
3. Don't auto-pick - user has to choose
|
||||
"""
|
||||
if self.description_template_id or self.line_description:
|
||||
return
|
||||
@@ -1033,7 +1033,7 @@ class FpDirectOrderLine(models.Model):
|
||||
"""Seed a Direct Order line from a `fp.quote.configurator` row.
|
||||
|
||||
Single source of truth for both the per-quote "Promote" action and
|
||||
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
|
||||
the bulk "Add From Quotes" sub-wizard - keeps the field mapping
|
||||
in one place so the two flows can never drift.
|
||||
"""
|
||||
if not quote.part_catalog_id:
|
||||
@@ -1087,13 +1087,13 @@ class FpDirectOrderLine(models.Model):
|
||||
return new_rev
|
||||
|
||||
# ==================================================================
|
||||
# 2026-04-28 polish — recipe handling shared with sale.order.line
|
||||
# 2026-04-28 polish - recipe handling shared with sale.order.line
|
||||
# ==================================================================
|
||||
def _fp_clone_recipe_to_part(self):
|
||||
"""Deep-copy the picked recipe onto this line's part if it isn't
|
||||
already scoped there. Returns the cloned (or unchanged) variant.
|
||||
|
||||
Mirrors `sale.order.line._fp_clone_recipe_to_part` — same
|
||||
Mirrors `sale.order.line._fp_clone_recipe_to_part` - same
|
||||
contract, same edge cases. The wizard runs this on every save
|
||||
path (create/write) plus when Customize is clicked, so a
|
||||
cross-part pick never leaks edits to the source recipe.
|
||||
@@ -1107,7 +1107,7 @@ class FpDirectOrderLine(models.Model):
|
||||
return recipe
|
||||
clone_name = recipe.name or _('Untitled Recipe')
|
||||
if part.part_number and part.part_number.lower() not in clone_name.lower():
|
||||
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,
|
||||
@@ -1148,12 +1148,12 @@ class FpDirectOrderLine(models.Model):
|
||||
line.part_catalog_id.action_set_default_variant(recipe.id)
|
||||
|
||||
def action_customize_process(self):
|
||||
"""Open the Process Composer for this line's variant — auto-clones
|
||||
"""Open the Process Composer for this line's variant - auto-clones
|
||||
first if the variant isn't yet scoped to this part."""
|
||||
self.ensure_one()
|
||||
if not self.part_catalog_id:
|
||||
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.process_variant_id:
|
||||
@@ -1167,7 +1167,7 @@ class FpDirectOrderLine(models.Model):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_part_process_composer',
|
||||
'name': _('Customize Process — %s') % (
|
||||
'name': _('Customize Process - %s') % (
|
||||
self.part_catalog_id.display_name
|
||||
or self.part_catalog_id.part_number
|
||||
or '?'
|
||||
|
||||
@@ -12,7 +12,7 @@ from odoo.exceptions import UserError
|
||||
class FpDirectOrderWizard(models.Model):
|
||||
"""Direct order entry for repeat customers.
|
||||
|
||||
Sub 9 — converted from TransientModel to persistent Model so an
|
||||
Sub 9 - converted from TransientModel to persistent Model so an
|
||||
estimator can save a draft, navigate elsewhere (part form, Process
|
||||
Composer, customer record), and come back. Entries persist across
|
||||
sessions; finished drafts move to state='confirmed' and link to the
|
||||
@@ -48,7 +48,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
readonly=True, copy=False, tracking=True,
|
||||
help='Set when the draft is confirmed — points to the SO created.',
|
||||
help='Set when the draft is confirmed - points to the SO created.',
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', string='Estimator',
|
||||
@@ -98,7 +98,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
internal_deadline = fields.Date(string='Internal Deadline')
|
||||
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
|
||||
# Lead Time — promised production window. Mirrors directly to
|
||||
# Lead Time - promised production window. Mirrors directly to
|
||||
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
|
||||
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
|
||||
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
|
||||
@@ -115,7 +115,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
|
||||
# ---- PO ----
|
||||
# Originally required at wizard time — that's what makes this a
|
||||
# Originally required at wizard time - that's what makes this a
|
||||
# "direct" order vs. a quote. Relaxed 2026-04-23: some customers
|
||||
# don't send their PO until after the order is in progress. The
|
||||
# wizard now accepts a PO Pending flag in lieu of a PO#/doc; the
|
||||
@@ -172,7 +172,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
|
||||
@api.model
|
||||
def _fp_default_terms_and_conditions(self):
|
||||
"""Seed Terms & Conditions from the Accounting default — same source
|
||||
"""Seed Terms & Conditions from the Accounting default - same source
|
||||
as the standard sale.order.note field.
|
||||
|
||||
Respects the `account.use_invoice_terms` system parameter (toggled
|
||||
@@ -191,7 +191,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
if not raw:
|
||||
return False
|
||||
# Defensive HTML strip — works whether the source was clean plain
|
||||
# Defensive HTML strip - works whether the source was clean plain
|
||||
# text, a real html field, or a "plain" field polluted by the
|
||||
# rich-text editor (entech case 2026-05-27).
|
||||
if '<' in raw and '>' in raw:
|
||||
@@ -199,7 +199,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
from lxml import html as lxml_html
|
||||
raw = lxml_html.fromstring(raw).text_content().strip()
|
||||
except Exception:
|
||||
# Last-ditch regex fallback — no lxml, malformed html, etc.
|
||||
# Last-ditch regex fallback - no lxml, malformed html, etc.
|
||||
import re
|
||||
raw = re.sub(r'<[^>]+>', '', raw).strip()
|
||||
return raw or False
|
||||
@@ -212,7 +212,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
set the recipe line-by-line.
|
||||
|
||||
Lines that have explicitly overridden their recipe AFTER this
|
||||
onchange last fired won't be clobbered — we only update lines
|
||||
onchange last fired won't be clobbered - we only update lines
|
||||
whose current process_variant_id is empty OR matches the PREVIOUS
|
||||
material_process value (i.e. they inherited from the header and
|
||||
haven't been customised).
|
||||
@@ -290,7 +290,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
progress_initial_percent = fields.Float(
|
||||
string='Progress - Initial %', default=50.0,
|
||||
)
|
||||
# Sub 9 — payment terms surfaced on the wizard so the resulting SO
|
||||
# Sub 9 - payment terms surfaced on the wizard so the resulting SO
|
||||
# picks them up. Auto-seeded from the customer's invoice-strategy
|
||||
# default (or the partner's property_payment_term_id), then nudged
|
||||
# again when the strategy changes (COD/Prepay → Immediate Payment).
|
||||
@@ -313,7 +313,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
terms_and_conditions = fields.Text(
|
||||
string='Terms & Conditions',
|
||||
default=lambda self: self._fp_default_terms_and_conditions(),
|
||||
help='Customer-facing terms — prints on quote / SO / invoice. '
|
||||
help='Customer-facing terms - prints on quote / SO / invoice. '
|
||||
'Seeded from the Accounting default terms '
|
||||
'(Settings → Invoicing → Default Terms & Conditions).',
|
||||
)
|
||||
@@ -326,18 +326,18 @@ class FpDirectOrderWizard(models.Model):
|
||||
# Material/Process Tag IS the recipe: when set on the order header,
|
||||
# every line auto-uses this recipe (unless the line explicitly
|
||||
# overrides via its own process_variant_id). Was a Char tag until
|
||||
# 19.0.22.1.0 — converted to Many2One per customer feedback.
|
||||
# 19.0.22.1.0 - converted to Many2One per customer feedback.
|
||||
material_process = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Material / Process Tag',
|
||||
domain="[('node_type', '=', 'recipe')]",
|
||||
help='Pick a recipe — applies automatically to every line on this '
|
||||
help='Pick a recipe - applies automatically to every line on this '
|
||||
'order. Individual lines can still override via their own '
|
||||
'Process / Recipe column.',
|
||||
)
|
||||
validity_date = fields.Date(
|
||||
string='Quote Validity',
|
||||
help='Mirrors sale.order.validity_date — when the quote/SO expires.',
|
||||
help='Mirrors sale.order.validity_date - when the quote/SO expires.',
|
||||
)
|
||||
view_source = fields.Selection(
|
||||
[('express', 'Express Orders View'),
|
||||
@@ -506,7 +506,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
for rec in self:
|
||||
has_missing = False
|
||||
for line in rec.line_ids:
|
||||
# coating_config_id intentionally NOT in the gate —
|
||||
# coating_config_id intentionally NOT in the gate -
|
||||
# it's optional now (rework / inspection-only / masking
|
||||
# work doesn't need a primary treatment).
|
||||
if (not line.part_catalog_id
|
||||
@@ -535,7 +535,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
self._apply_strategy_payment_term()
|
||||
return
|
||||
|
||||
# Partner-level plating defaults — primary cascade. Customers
|
||||
# Partner-level plating defaults - primary cascade. Customers
|
||||
# migrated to the new partner fields skip the legacy lookup below.
|
||||
partner = self.partner_id
|
||||
if partner.x_fc_default_invoice_strategy:
|
||||
@@ -544,7 +544,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
self.deposit_percent = partner.x_fc_default_deposit_percent
|
||||
if partner.x_fc_default_delivery_method:
|
||||
self.delivery_method = partner.x_fc_default_delivery_method
|
||||
# Lead-time default band — set once per customer in their
|
||||
# Lead-time default band - set once per customer in their
|
||||
# Plating profile, auto-copies onto every new Express Order.
|
||||
# Only fills when the operator hasn't already typed a value.
|
||||
if (partner.x_fc_default_lead_time_min_days
|
||||
@@ -554,7 +554,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
and not self.lead_time_max_days):
|
||||
self.lead_time_max_days = partner.x_fc_default_lead_time_max_days
|
||||
|
||||
# Deadline auto-fill — anchored to planned_start_date with today
|
||||
# Deadline auto-fill - anchored to planned_start_date with today
|
||||
# as fallback. Honours explicit deadlines the user already typed.
|
||||
anchor = self.planned_start_date or fields.Date.context_today(self)
|
||||
if (partner.x_fc_default_internal_deadline_days
|
||||
@@ -619,7 +619,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
"""Recompute deadlines from partner offsets when start moves.
|
||||
|
||||
Runs only if the partner has offsets configured AND deadlines
|
||||
are still blank — typing a manual deadline locks it.
|
||||
are still blank - typing a manual deadline locks it.
|
||||
"""
|
||||
if not self.partner_id or not self.planned_start_date:
|
||||
return
|
||||
@@ -645,7 +645,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
blocks invoice posting, which silently strands SOs at the
|
||||
invoicing step. Better to default to net-30 and let the
|
||||
estimator override if the customer's terms are different.
|
||||
Never overwrites an explicit user choice — only fills the gap.
|
||||
Never overwrites an explicit user choice - only fills the gap.
|
||||
"""
|
||||
Pt = self.env['account.payment.term']
|
||||
for rec in self:
|
||||
@@ -678,7 +678,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
|
||||
Wired to the "New Direct Order" menu / button. Creating the
|
||||
record up front means the draft is auto-persisted from the
|
||||
first keystroke — the estimator can navigate away (to the
|
||||
first keystroke - the estimator can navigate away (to the
|
||||
part form, the Process Composer, etc.) without losing work.
|
||||
"""
|
||||
draft = self.create({})
|
||||
@@ -752,7 +752,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
|
||||
Returns an action that opens the newly-created SO in form view so
|
||||
the user can review, adjust, and manually confirm / send. The
|
||||
wizard deliberately does not auto-confirm or auto-email — see
|
||||
wizard deliberately does not auto-confirm or auto-email - see
|
||||
Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md).
|
||||
"""
|
||||
self.ensure_one()
|
||||
@@ -760,14 +760,14 @@ class FpDirectOrderWizard(models.Model):
|
||||
raise UserError(_('Pick a customer before confirming.'))
|
||||
if not self.line_ids:
|
||||
raise UserError(_('Add at least one part line before confirming.'))
|
||||
# Account-hold hard block — same policy as sale.order.action_confirm
|
||||
# Account-hold hard block - same policy as sale.order.action_confirm
|
||||
# but enforced earlier so the wizard doesn't waste Sarah's time.
|
||||
# Manager override allowed via context key fp_skip_account_hold=True.
|
||||
# Resolved through commercial_partner so a hold on the company
|
||||
# blocks every child-contact entry too.
|
||||
commercial = self.partner_id.commercial_partner_id
|
||||
# Bypass: Plating Manager (or anything above — Quality Manager,
|
||||
# Owner — via the Phase A implied_ids diamond). Phase G fix:
|
||||
# Bypass: Plating Manager (or anything above - Quality Manager,
|
||||
# Owner - via the Phase A implied_ids diamond). Phase G fix:
|
||||
# old code also checked 'group_fusion_plating_administrator',
|
||||
# an xmlid that never existed and always returned False
|
||||
# (audit-finding-11). The Manager check alone is now correct
|
||||
@@ -872,7 +872,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'note': self.terms_and_conditions or False,
|
||||
# Express Orders header (2026-05-26)
|
||||
'x_fc_internal_notes': self.internal_notes or False,
|
||||
# material_process is a Many2One since 19.0.22.1.0 — pass .id
|
||||
# material_process is a Many2One since 19.0.22.1.0 - pass .id
|
||||
'x_fc_material_process': self.material_process.id if self.material_process else False,
|
||||
'x_fc_tooling_charge': self.charge_amount or self.tooling_charge or 0.0,
|
||||
'pricelist_id': self.pricelist_id.id if self.pricelist_id else False,
|
||||
@@ -888,7 +888,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
part = line._get_or_bump_revision()
|
||||
resolved_parts[line.id] = part
|
||||
# Build the line header. Specification is optional; when
|
||||
# 2026-05-27 — drop the legacy "spec - PART Rev (xN)" header
|
||||
# 2026-05-27 - drop the legacy "spec - PART Rev (xN)" header
|
||||
# entirely from the customer-facing line name. Per user
|
||||
# request, customer-facing reports (SO confirmation, invoice,
|
||||
# CoC, packing slip, BoL) should show ONLY:
|
||||
@@ -898,12 +898,12 @@ class FpDirectOrderWizard(models.Model):
|
||||
# Part Number column. The header was duplicating that info
|
||||
# in the Description column.
|
||||
extended = (line.line_description or '').strip()
|
||||
line_desc = extended or (part.part_number or '—')
|
||||
line_desc = extended or (part.part_number or '-')
|
||||
|
||||
# G3 robustness (2026-05-27): make sure the line's process
|
||||
# variant carries the order-level recipe if no per-line
|
||||
# override exists. Belt-and-braces with the onchange/create
|
||||
# propagation — catches the multi-part case where a new
|
||||
# propagation - catches the multi-part case where a new
|
||||
# part's line never received the order recipe.
|
||||
if not line.process_variant_id and self.material_process:
|
||||
line.process_variant_id = self.material_process.id
|
||||
@@ -924,7 +924,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_internal_description': line.internal_description or False,
|
||||
# x_fc_customer_spec_id is set on the resulting SO line
|
||||
# by an extension in fusion_plating_quality (post-create
|
||||
# patch — see fp_direct_order_line_inherit.py).
|
||||
# patch - see fp_direct_order_line_inherit.py).
|
||||
'x_fc_part_deadline': line.part_deadline,
|
||||
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
|
||||
'x_fc_rush_order': line.rush_order,
|
||||
@@ -933,7 +933,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_start_at_node_id': line.start_at_node_id.id or False,
|
||||
'x_fc_is_one_off': line.is_one_off,
|
||||
'x_fc_quote_id': line.quote_id.id or False,
|
||||
# Recipe propagation (G3) — line's process_variant_id, falling
|
||||
# Recipe propagation (G3) - line's process_variant_id, falling
|
||||
# back to the order-level material_process recipe so multi-part
|
||||
# orders still get the header recipe on every line.
|
||||
'x_fc_process_variant_id': (
|
||||
@@ -941,7 +941,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
or (self.material_process.id if self.material_process else False)
|
||||
),
|
||||
'x_fc_save_as_default_process': line.save_as_default_process,
|
||||
# Sub 5 / Phase 1 — carry serial M2M to the SO line.
|
||||
# Sub 5 / Phase 1 - carry serial M2M to the SO line.
|
||||
# x_fc_serial_id is back-compat alias and auto-resolves
|
||||
# from x_fc_serial_ids on SO-line read; passing both is
|
||||
# safe (the alias setter just appends to the M2M).
|
||||
@@ -950,14 +950,14 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_serial_id': line.serial_id.id or False,
|
||||
'x_fc_job_number': line.job_number or False,
|
||||
'x_fc_thickness_range': line.thickness_range or False,
|
||||
# Express Orders per-line flags (2026-05-26) — carry to
|
||||
# Express Orders per-line flags (2026-05-26) - carry to
|
||||
# the SO line so the override-application helper can read
|
||||
# them at job creation time.
|
||||
'x_fc_customer_line_ref': line.customer_line_ref or False,
|
||||
'x_fc_masking_enabled': line.masking_enabled,
|
||||
'x_fc_bake_instructions': line.bake_instructions or False,
|
||||
'x_fc_masking_attachment_ids': [(6, 0, line.masking_attachment_ids.ids)],
|
||||
# Sub 9 — explicit tax override from the wizard line.
|
||||
# Sub 9 - explicit tax override from the wizard line.
|
||||
# When blank, Odoo will compute taxes from the product
|
||||
# defaults at SO-line save time (the standard behaviour).
|
||||
# NB. Odoo 19 renamed the SO line field to tax_ids.
|
||||
@@ -967,7 +967,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_lot_total': line.lot_total or 0.0,
|
||||
}))
|
||||
|
||||
# 4b. Additional charge — one typed line, taxed by the order-level
|
||||
# 4b. Additional charge - one typed line, taxed by the order-level
|
||||
# tax. The charge type's name labels the line; charge_amount with
|
||||
# legacy tooling_charge fallback for in-flight drafts.
|
||||
charge_amt = self.charge_amount or self.tooling_charge or 0.0
|
||||
@@ -986,7 +986,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
if self.tax_id else False),
|
||||
}))
|
||||
|
||||
# 5. Create — stays in draft / quotation. Sub 1: user reviews
|
||||
# 5. Create - stays in draft / quotation. Sub 1: user reviews
|
||||
# and manually clicks Confirm / Send. No auto-confirm, no
|
||||
# auto-email to the client.
|
||||
so = self.env['sale.order'].create(so_vals)
|
||||
@@ -994,7 +994,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
# Mark this draft as confirmed and link the SO.
|
||||
self.write({'state': 'confirmed', 'sale_order_id': so.id})
|
||||
|
||||
# Sub 10 — flip every linked quote to "won" now that an SO exists.
|
||||
# Sub 10 - flip every linked quote to "won" now that an SO exists.
|
||||
# We deliberately wait until SO creation rather than at promote
|
||||
# time, because "won" should mean "the deal closed", not "we put
|
||||
# it on a draft." A draft can still be cancelled.
|
||||
@@ -1009,10 +1009,10 @@ class FpDirectOrderWizard(models.Model):
|
||||
})
|
||||
for q in linked_quotes:
|
||||
q.message_post(body=_(
|
||||
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
|
||||
'Quote won - promoted onto Direct Order %(doo)s, SO %(so)s.'
|
||||
) % {'doo': self.name, 'so': so.name})
|
||||
|
||||
# 6. Push-to-defaults — Specification carry-over to the part's
|
||||
# 6. Push-to-defaults - Specification carry-over to the part's
|
||||
# x_fc_default_customer_spec_id is handled by an inherit in
|
||||
# fusion_plating_quality (the field lives there).
|
||||
# Thickness range: lives in configurator, push here.
|
||||
@@ -1029,7 +1029,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
# Always-on (no push_to_defaults check): the spec says type-once,
|
||||
# saves to part. Empty cells DON'T clobber existing defaults
|
||||
# (otherwise an empty bake cell would erase a part's bake default
|
||||
# — bad UX). Masking is a Boolean so always written.
|
||||
# - bad UX). Masking is a Boolean so always written.
|
||||
for line in self.line_ids:
|
||||
if line.is_one_off:
|
||||
continue
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<strong>Legacy view.</strong> This form is being retired in favour of the new
|
||||
<a href="#" class="alert-link"><strong>Express Orders</strong></a> view, which
|
||||
is faster for batch entry — every column inline, type-once-and-remember per-part
|
||||
is faster for batch entry - every column inline, type-once-and-remember per-part
|
||||
defaults, masking + bake toggles per line. Click <em>Switch to Express ➜</em>
|
||||
above to flip this draft into the new view.
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
<field name="user_id" readonly="state != 'draft'"
|
||||
options="{'no_create': True}"/>
|
||||
<p class="text-muted" invisible="state != 'draft'">
|
||||
Skip the quotation stage — create a confirmed order
|
||||
Skip the quotation stage - create a confirmed order
|
||||
when the customer has already sent a PO. Drafts auto-save.
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@
|
||||
<field name="planned_start_date"/>
|
||||
<field name="internal_deadline"/>
|
||||
<!-- Labelled "Delivery Date" here to match
|
||||
the SO header field of the same name —
|
||||
the SO header field of the same name -
|
||||
same field, same value, just consistent
|
||||
wording end-to-end. Backing field is
|
||||
still `customer_deadline` (wizard) →
|
||||
@@ -361,7 +361,7 @@
|
||||
</group>
|
||||
<group string="Terms & Conditions (prints on customer docs)">
|
||||
<field name="terms_and_conditions" nolabel="1"
|
||||
placeholder="Customer-facing terms — seeded from company default."/>
|
||||
placeholder="Customer-facing terms - seeded from company default."/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
@@ -373,7 +373,7 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form action — keeps the same external ID as before so existing
|
||||
<!-- Form action - keeps the same external ID as before so existing
|
||||
button references survive (act_window cannot be replaced by a
|
||||
server action with the same xmlid). target='current' lets the
|
||||
estimator breadcrumb between the wizard and the part form / Composer.
|
||||
@@ -471,7 +471,7 @@
|
||||
<field name="context">{'search_default_filter_draft': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No drafts yet — start one!
|
||||
No drafts yet - start one!
|
||||
</p>
|
||||
<p>
|
||||
Drafts persist across sessions. Save your progress, switch to a
|
||||
|
||||
@@ -15,7 +15,7 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# CSV column spec — order matters for the downloadable template
|
||||
# CSV column spec - order matters for the downloadable template
|
||||
# ---------------------------------------------------------------------
|
||||
CSV_COLUMNS = [
|
||||
'part_number', # required
|
||||
@@ -99,7 +99,7 @@ class FpPartCatalogImportWizard(models.TransientModel):
|
||||
Import button. User can fix and re-upload, or commit.
|
||||
"""
|
||||
_name = 'fp.part.catalog.import.wizard'
|
||||
_description = 'Fusion Plating — Part Catalog CSV Import'
|
||||
_description = 'Fusion Plating - Part Catalog CSV Import'
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'), ('preview', 'Preview'), ('done', 'Done')],
|
||||
@@ -152,7 +152,7 @@ class FpPartCatalogImportWizard(models.TransientModel):
|
||||
'PN-12345', 'Widget A', 'Acme Corp', 'Rev A', '1',
|
||||
'steel', '12.5', 'sq_in', 'moderate', '0.4',
|
||||
'50', '30', '20', '2', 'Mask threaded holes',
|
||||
'no', 'no', 'yes', 'Example row — delete before import',
|
||||
'no', 'no', 'yes', 'Example row - delete before import',
|
||||
])
|
||||
data = buf.getvalue().encode('utf-8')
|
||||
att = self.env['ir.attachment'].create({
|
||||
@@ -256,7 +256,7 @@ class FpPartCatalogImportWizard(models.TransientModel):
|
||||
duplicates.append({'row': i, 'customer': partner.name, 'part_number': part_number})
|
||||
continue
|
||||
|
||||
# Build the prepared vals (no partner id yet — may need creating)
|
||||
# Build the prepared vals (no partner id yet - may need creating)
|
||||
valid_rows.append({
|
||||
'row': i,
|
||||
'customer_raw': customer_raw,
|
||||
@@ -313,7 +313,7 @@ class FpPartCatalogImportWizard(models.TransientModel):
|
||||
try:
|
||||
valid_rows = json.loads(self.parsed_rows_json or '[]')
|
||||
except ValueError:
|
||||
raise UserError(_('Preview data lost — please Preview again.'))
|
||||
raise UserError(_('Preview data lost - please Preview again.'))
|
||||
|
||||
Partner = self.env['res.partner']
|
||||
Part = self.env['fp.part.catalog']
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<h1>Import Parts from CSV</h1>
|
||||
<p class="text-muted">
|
||||
Bulk-load part catalog entries. The wizard validates
|
||||
every row before writing — nothing is imported until
|
||||
every row before writing - nothing is imported until
|
||||
you approve the preview.
|
||||
</p>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
<code>surface_area</code>, <code>surface_area_uom</code>,
|
||||
<code>complexity</code>, <code>weight</code>,
|
||||
dimensions, masking, flags. The importer accepts
|
||||
readable values — e.g. "Stainless Steel" maps to
|
||||
readable values - e.g. "Stainless Steel" maps to
|
||||
<code>stainless</code>, "sq in" to <code>sq_in</code>.
|
||||
</div>
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ class FpPartRevisionBumpWizard(models.TransientModel):
|
||||
'model_attachment_id': part.model_attachment_id.id,
|
||||
})
|
||||
|
||||
# Optional new PDF drawing — appended to the drawing list.
|
||||
# Optional new PDF drawing - appended to the drawing list.
|
||||
if self.new_drawing_file:
|
||||
drawing_att = self.env['ir.attachment'].create({
|
||||
'name': self.new_drawing_filename or 'drawing.pdf',
|
||||
@@ -140,7 +140,7 @@ class FpPartRevisionBumpWizard(models.TransientModel):
|
||||
})
|
||||
new_part.drawing_attachment_ids = [(4, drawing_att.id)]
|
||||
|
||||
# Optional new 3D model — replaces the model attachment.
|
||||
# Optional new 3D model - replaces the model attachment.
|
||||
if self.new_model_file:
|
||||
model_att = self.env['ir.attachment'].create({
|
||||
'name': self.new_model_filename or 'model.step',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="text-muted">
|
||||
Bump the revision label for
|
||||
<strong><field name="part_id" readonly="1" nolabel="1" options="{'no_open': True}"/></strong>.
|
||||
The pre-filled label is a best-effort guess —
|
||||
The pre-filled label is a best-effort guess -
|
||||
adjust it to match the customer's actual scheme.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ from odoo.exceptions import UserError
|
||||
class FpQuotePromoteWizard(models.TransientModel):
|
||||
"""Chooser dialog: promote a won quote into a Direct Order draft.
|
||||
|
||||
Sub 10 — quote→direct-order handoff. The estimator picks either an
|
||||
Sub 10 - quote→direct-order handoff. The estimator picks either an
|
||||
existing open draft for this customer (lets multiple quotes
|
||||
consolidate onto a single PO) or creates a fresh draft.
|
||||
"""
|
||||
@@ -62,7 +62,7 @@ class FpQuotePromoteWizard(models.TransientModel):
|
||||
self.ensure_one()
|
||||
q = self.quote_id
|
||||
|
||||
# Re-check the not-already-promoted invariant — a separate user
|
||||
# Re-check the not-already-promoted invariant - a separate user
|
||||
# could have added this quote to a draft between the action open
|
||||
# and the click, so we re-verify before mutating.
|
||||
existing_line = self.env['fp.direct.order.line'].search([
|
||||
@@ -91,7 +91,7 @@ class FpQuotePromoteWizard(models.TransientModel):
|
||||
'currency_id': q.currency_id.id,
|
||||
})
|
||||
|
||||
# Currency must match — Direct Order doesn't convert.
|
||||
# Currency must match - Direct Order doesn't convert.
|
||||
if target.currency_id != q.currency_id:
|
||||
raise UserError(_(
|
||||
'Quote currency (%s) does not match Direct Order '
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
role="alert"
|
||||
invisible="open_drafts_count != 0 or target_mode != 'new'">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
No open drafts for this customer — a fresh Direct
|
||||
No open drafts for this customer - a fresh Direct
|
||||
Order will be created.
|
||||
</div>
|
||||
</sheet>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
"""Bulk-add serial numbers to a sale.order.line or fp.direct.order.line.
|
||||
|
||||
Three input modes — operator picks one:
|
||||
Three input modes - operator picks one:
|
||||
|
||||
1. **Paste a list** — one per line, comma- or whitespace-separated.
|
||||
2. **Range fill** — prefix + start..end (e.g. SN- + 1..30 → SN-001..SN-030).
|
||||
3. **Scan barcodes** — repeated input (kept simple for Phase 1: the same
|
||||
1. **Paste a list** - one per line, comma- or whitespace-separated.
|
||||
2. **Range fill** - prefix + start..end (e.g. SN- + 1..30 → SN-001..SN-030).
|
||||
3. **Scan barcodes** - repeated input (kept simple for Phase 1: the same
|
||||
paste textarea works for a barcode reader that types-and-Enters).
|
||||
|
||||
Existing serials with the same `name` are reused (the company-uniqueness
|
||||
@@ -29,7 +29,7 @@ from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
class FpSerialBulkAddWizard(models.TransientModel):
|
||||
_name = 'fp.serial.bulk.add.wizard'
|
||||
_description = 'Fusion Plating — Bulk Add Serials'
|
||||
_description = 'Fusion Plating - Bulk Add Serials'
|
||||
|
||||
target_model = fields.Selection(
|
||||
[
|
||||
@@ -58,7 +58,7 @@ class FpSerialBulkAddWizard(models.TransientModel):
|
||||
string='Serial List',
|
||||
help='One serial per line, or comma-separated. Whitespace and '
|
||||
'blank lines are ignored. Barcode scanners that emit one '
|
||||
'serial + Enter at a time also work — just leave the cursor '
|
||||
'serial + Enter at a time also work - just leave the cursor '
|
||||
'in this box and scan.',
|
||||
)
|
||||
|
||||
@@ -127,7 +127,7 @@ class FpSerialBulkAddWizard(models.TransientModel):
|
||||
count = self.end_number - self.start_number + 1
|
||||
if count > 1000:
|
||||
raise ValidationError(_(
|
||||
'Range covers %s entries — too many. Cap at 1000 per call.'
|
||||
'Range covers %s entries - too many. Cap at 1000 per call.'
|
||||
) % count)
|
||||
names = []
|
||||
prefix = self.prefix or ''
|
||||
@@ -188,7 +188,7 @@ class FpSerialBulkAddWizard(models.TransientModel):
|
||||
else:
|
||||
raise UserError(_('Unsupported mode: %s') % self.mode)
|
||||
|
||||
# 2. Quantity sanity check — block if we'd exceed the line qty.
|
||||
# 2. Quantity sanity check - block if we'd exceed the line qty.
|
||||
target_field = (
|
||||
'x_fc_serial_ids' if self.target_model == 'sale.order.line'
|
||||
else 'serial_ids'
|
||||
@@ -228,7 +228,7 @@ class FpSerialBulkAddWizard(models.TransientModel):
|
||||
|
||||
all_serials = existing + created
|
||||
# Order-preserving: rebuild from the input order so paste/range
|
||||
# ordering is preserved on the M2M (matters for paste_text — the
|
||||
# ordering is preserved on the M2M (matters for paste_text - the
|
||||
# operator typed them in physical-rack order).
|
||||
serial_by_name = {s.name: s for s in all_serials}
|
||||
ordered_ids = [serial_by_name[n].id for n in names if n in serial_by_name]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<group invisible="mode != 'paste'">
|
||||
<separator string="Paste a List"/>
|
||||
<field name="paste_text" nolabel="1"
|
||||
placeholder="One per line, or comma-separated. Example: SN-001 SN-002 CUST-12345 or scan barcodes — one per Enter."/>
|
||||
placeholder="One per line, or comma-separated. Example: SN-001 SN-002 CUST-12345 or scan barcodes - one per Enter."/>
|
||||
</group>
|
||||
|
||||
<!-- Range mode -->
|
||||
|
||||
Reference in New Issue
Block a user