This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.12.5.0',
'version': '19.0.12.6.2',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -71,6 +71,8 @@ class SimpleRecipeController(http.Controller):
'requires_signoff': step.requires_signoff,
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
'description': step.description or '',
'notes': step.notes or '',
'tank_ids': [
{'id': t.id, 'name': t.name, 'code': t.code}
for t in step.tank_ids
@@ -160,13 +162,24 @@ class SimpleRecipeController(http.Controller):
def _sequence_for_position(self, recipe, position):
siblings = recipe.child_ids.sorted('sequence')
if not siblings or position >= len(siblings):
return (siblings[-1].sequence + 10) if siblings else 10
if not siblings:
return 10
if position >= len(siblings):
return siblings[-1].sequence + 10
if position <= 0:
return max(1, siblings[0].sequence - 10)
before = siblings[position - 1].sequence
after = siblings[position].sequence
return (before + after) // 2 if (after - before) > 1 else before + 1
if after - before > 1:
return (before + after) // 2
# Sequences are tightly packed (gap == 1 → midpoint == after,
# which collides). Renumber siblings to 10/20/30… first, then
# the new step lands cleanly between renumbered neighbours.
for idx, sib in enumerate(siblings):
new_seq = (idx + 1) * 10
if sib.sequence != new_seq:
sib.sequence = new_seq
return position * 10 + 5
def _copy_inputs_from_template(self, tpl, new_node):
NodeInput = request.env['fusion.plating.process.node.input']

View File

@@ -16,12 +16,40 @@
# cancelled (rework reverts here)
# on_hold can be entered from confirmed or in_progress.
import pytz
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJob(models.Model):
_name = 'fp.job'
def fp_format_local(self, dt, fmt='%Y-%m-%d %H:%M'):
"""Format a UTC datetime in the viewer's local timezone.
Used by report templates: QWeb's eval scope doesn't expose pytz
or format_datetime, but record methods are always callable, so
templates do `<span t-esc="job.fp_format_local(dt, '%H:%M')"/>`.
Resolution order matches the rest of the module: env.user.tz →
company.x_fc_default_tz → UTC.
"""
if not dt:
return ''
tz_name = (
self.env.user.tz
or ('x_fc_default_tz' in self.env.company._fields
and self.env.company.x_fc_default_tz)
or 'UTC'
)
try:
tz = pytz.timezone(tz_name)
except Exception:
tz = pytz.UTC
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.astimezone(tz).strftime(fmt)
_description = 'Plating Job'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'priority desc, date_deadline asc, id desc'

View File

@@ -168,6 +168,56 @@ class FpJobStep(models.Model):
)
qty_at_step_start = fields.Integer(string='Qty at Step Start')
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
# Live "qty currently parked at this step" — drives partial-qty
# workflows. = (incoming moves' qty outgoing moves' qty), with a
# first-step seed: the lowest-sequence step on a confirmed job
# implicitly receives the full job qty when the job starts (no
# explicit "kickoff" move record). Without that seed, the first
# step would always show 0 here until the operator manually moved
# parts in, which doesn't match how the floor thinks about it.
qty_at_step = fields.Integer(
string='Qty Here',
compute='_compute_qty_at_step',
help='Quantity currently parked at this step. Drains as moves '
'transfer parts to later steps. The Move dialog defaults '
'to this value and blocks moves above it.',
)
@api.depends('move_ids.qty_moved', 'move_ids.to_step_id',
'incoming_move_ids.qty_moved',
'incoming_move_ids.from_step_id',
'state', 'job_id.qty', 'job_id.step_ids',
'job_id.step_ids.sequence', 'sequence')
def _compute_qty_at_step(self):
for rec in self:
# Terminal states: nothing parked here anymore. Operators
# don't care if "done" steps technically have qty residue —
# surfacing zero keeps the column readable.
if rec.state in ('done', 'cancelled', 'skipped'):
rec.qty_at_step = 0
continue
# Self-loop moves (from_step == to_step, transfer_type='step')
# are how the Record Inputs wizard logs measurements; they
# don't move qty so we exclude them on both sides.
incoming = sum(
m.qty_moved for m in rec.incoming_move_ids
if m.from_step_id != rec
)
outgoing = sum(
m.qty_moved for m in rec.move_ids
if m.to_step_id != rec
)
# First-step seed: the earliest non-terminal step on a job
# implicitly receives the full job qty when the job kicks
# off (no explicit kickoff move). Without this seed, qty
# here would read 0 even when the floor has the full batch.
if not incoming and rec.job_id and rec.job_id.qty:
first_active = rec.job_id.step_ids.filtered(
lambda s: s.state not in ('done', 'cancelled', 'skipped')
).sorted('sequence')[:1]
if rec == first_active:
incoming = int(rec.job_id.qty)
rec.qty_at_step = max(0, incoming - outgoing)
@api.depends('rack_id')
def _compute_is_racked(self):
@@ -226,7 +276,7 @@ class FpJobStep(models.Model):
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
open_log.write({'date_finished': now, 'state': 'paused'})
step.state = 'paused'
step.message_post(body=_('Step paused by %s') % self.env.user.name)
return True
@@ -269,7 +319,7 @@ class FpJobStep(models.Model):
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
open_log.write({'date_finished': now, 'state': 'stopped'})
step.state = 'cancelled'
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
return True
@@ -305,7 +355,7 @@ class FpJobStep(models.Model):
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
open_log.write({'date_finished': now, 'state': 'stopped'})
step.state = 'done'
# First-finish audit (mirrors button_start first-start guard)
if not step.date_finished:

View File

@@ -13,6 +13,7 @@ import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class FpSimpleRecipeEditor extends Component {
@@ -37,6 +38,12 @@ export class FpSimpleRecipeEditor extends Component {
dragOverIndex: null, // 0..N (insertion index)
dragPreviewLabel: "", // shown next to the indicator line
dragPreviewIcon: "fa-cog",
// Inline edit panel — id of the step currently being edited
// (null = no panel open). Mirrors live values so the textarea
// stays controlled without RPC roundtrip on every keystroke.
editingStepId: null,
editName: "",
editInstructions: "",
});
this._recipeId = null;
@@ -90,11 +97,24 @@ export class FpSimpleRecipeEditor extends Component {
async reorderStep(stepId, newIndex) {
const ids = this.state.steps.map((s) => s.id);
const oldIndex = ids.indexOf(stepId);
if (oldIndex < 0 || oldIndex === newIndex) {
if (oldIndex < 0) {
return;
}
// dragOverIndex is the insertion point in the ORIGINAL list. Once
// we splice the dragged item out, every position to the right of
// oldIndex shifts left by one — so an insertion at newIndex when
// newIndex > oldIndex must be decremented. Without this, dropping
// right after itself moves the row one slot down instead of
// staying put.
let adjusted = newIndex;
if (newIndex > oldIndex) {
adjusted -= 1;
}
if (adjusted === oldIndex) {
return;
}
ids.splice(oldIndex, 1);
ids.splice(Math.min(newIndex, ids.length), 0, stepId);
ids.splice(Math.max(0, Math.min(adjusted, ids.length)), 0, stepId);
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
await this.loadAll();
}
@@ -199,6 +219,26 @@ export class FpSimpleRecipeEditor extends Component {
this.state.dragOverIndex = this.state.steps.length;
}
/**
* Panel-level dragover. Required so HTML5 `drop` actually fires
* across the whole panel surface — including the gap between rows
* (.25rem margin) and the panel padding (1rem). Without this, drops
* on those areas are silently rejected by the browser. Row-level
* dragover handlers still run first and set the precise index;
* this is the safety net that keeps the most-recently-set index
* (or end-of-list fallback) live until the user releases.
*/
onPanelDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
if (this.state.dragOverIndex === null) {
this.state.dragOverIndex = this.state.steps.length;
}
}
async onDrop(ev) {
ev.preventDefault();
const targetIndex = this.state.dragOverIndex !== null
@@ -236,12 +276,87 @@ export class FpSimpleRecipeEditor extends Component {
this.state.dragPreviewIcon = "fa-cog";
}
// ------------------------------------------------------------- edit panel
/**
* Toggle the inline edit panel for a step. Closing without explicit
* Save discards changes — operator-style "I clicked the wrong row"
* shouldn't write garbage to the recipe.
*/
onToggleEdit(stepId) {
if (this.state.editingStepId === stepId) {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
return;
}
const step = this.state.steps.find((s) => s.id === stepId);
if (!step) return;
this.state.editingStepId = stepId;
this.state.editName = step.name || "";
this.state.editInstructions = this._htmlToText(step.description || "");
}
async onSaveStep() {
const stepId = this.state.editingStepId;
if (!stepId) return;
const vals = {
name: this.state.editName || _t("Untitled Step"),
description: this._textToHtml(this.state.editInstructions),
};
await rpc("/fp/simple_recipe/step/write", {
node_id: stepId,
vals: vals,
});
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
await this.loadAll();
this.notification.add(_t("Step updated"), { type: "success" });
}
onCancelEdit() {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
}
/**
* Render stored HTML as plain text for the textarea. Strips tags,
* collapses block elements to newlines. Good enough for the simple
* editor — the tree editor handles full rich text.
*/
_htmlToText(html) {
if (!html) return "";
const tmp = document.createElement("div");
tmp.innerHTML = html;
// Replace block elements + <br> with newlines before reading text.
tmp.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
tmp.querySelectorAll("p, div, li").forEach((el) => {
el.append("\n");
});
return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
}
/** Wrap user text into safe HTML so the Html field stores cleanly. */
_textToHtml(text) {
if (!text) return "";
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped
.split(/\n{2,}/)
.map((p) => `<p>${p.replace(/\n/g, "<br/>")}</p>`)
.join("");
}
// --------------------------------------------------------------- helpers
async _confirm(message) {
return await new Promise((resolve) => {
this.dialog.add(
"web.ConfirmationDialog",
ConfirmationDialog,
{
body: message,
confirm: () => resolve(true),

View File

@@ -152,6 +152,12 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
background: $fp-se-drop;
border-color: $fp-se-accent;
}
&.o_fp_step_row_editing {
border-color: $fp-se-accent;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.o_fp_drag_handle {
color: $fp-se-muted;
@@ -163,6 +169,10 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
min-width: 1.5rem;
}
.o_fp_step_name { flex: 1; }
.o_fp_step_has_instructions {
color: $fp-se-accent;
font-size: .85rem;
}
.o_fp_station_badge {
font-size: .75rem;
color: $fp-se-muted;
@@ -170,19 +180,64 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
padding: .125rem .5rem;
border-radius: 999px;
}
.o_fp_step_edit,
.o_fp_step_remove {
background: none;
border: none;
color: $fp-se-muted;
font-size: 1.25rem;
cursor: pointer;
opacity: 0;
transition: opacity .1s;
padding: 0 .25rem;
}
&:hover .o_fp_step_remove {
.o_fp_step_edit { font-size: .9rem; }
.o_fp_step_remove { font-size: 1.25rem; }
&:hover .o_fp_step_edit,
&:hover .o_fp_step_remove,
&.o_fp_step_row_editing .o_fp_step_edit {
opacity: 1;
}
.o_fp_step_edit:hover {
color: $fp-se-accent;
}
}
.o_fp_step_edit_panel {
background: $fp-se-card;
border: 1px solid $fp-se-accent;
border-top: none;
border-radius: 0 0 4px 4px;
padding: .75rem;
margin-bottom: .25rem;
.o_fp_edit_field {
margin-bottom: .75rem;
label {
display: block;
font-weight: 500;
font-size: .85rem;
margin-bottom: .25rem;
color: $fp-se-accent;
}
textarea {
font-family: inherit;
resize: vertical;
min-height: 5rem;
}
}
.o_fp_edit_hint {
margin: .25rem 0 0 0;
font-size: .75rem;
color: $fp-se-muted;
}
.o_fp_edit_actions {
display: flex;
gap: .5rem;
justify-content: flex-end;
}
}
.o_fp_step_dropzone {

View File

@@ -34,10 +34,11 @@
<div class="o_fp_simple_editor_body" t-if="!state.loading">
<div class="o_fp_selected_panel"
t-on-dragover="(ev) => this.onPanelDragOver(ev)"
t-on-dragleave="(ev) => this.onDragLeave(ev)"
t-on-dragend="() => this.onDragEnd()"
t-on-drop="(ev) => this.onDrop(ev)">
<h3>Selected (drag to reorder)</h3>
<h3>Selected (drag to reorder, click pencil to edit)</h3>
<div class="o_fp_steps_list">
<!-- Top drop indicator (insertion at index 0). Visible
@@ -53,6 +54,7 @@
<t t-foreach="state.steps" t-as="step" t-key="step.id">
<div class="o_fp_step_row"
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
draggable="true"
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
@@ -60,16 +62,58 @@
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
<span class="o_fp_step_name" t-esc="step.name"/>
<span class="o_fp_step_has_instructions"
t-if="step.description"
title="Has operator instructions">
<i class="fa fa-file-text-o"/>
</span>
<span class="o_fp_station_badge"
t-if="step.tank_ids and step.tank_ids.length">
<t t-esc="step.tank_ids.length"/> stations
</span>
<button class="o_fp_step_edit"
title="Edit name &amp; instructions"
t-on-click="() => this.onToggleEdit(step.id)">
<i class="fa fa-pencil"/>
</button>
<button class="o_fp_step_remove"
title="Remove step"
t-on-click="() => this.onRemoveStep(step.id)">
×
</button>
</div>
<!-- Inline edit panel (shown when this step is selected for editing). -->
<div class="o_fp_step_edit_panel"
t-if="state.editingStepId === step.id">
<div class="o_fp_edit_field">
<label>Step name</label>
<input type="text" class="form-control"
t-model="state.editName"
placeholder="e.g. Acid Etch"/>
</div>
<div class="o_fp_edit_field">
<label>Default instructions for operator</label>
<textarea class="form-control"
rows="5"
t-model="state.editInstructions"
placeholder="What the operator/employee sees on the shop floor when running this step. Plain text — line breaks are preserved."/>
<p class="o_fp_edit_hint">
Shown to operators when running this step at the tank. Use line breaks for separate points.
</p>
</div>
<div class="o_fp_edit_actions">
<button class="btn btn-primary btn-sm"
t-on-click="() => this.onSaveStep()">
Save
</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onCancelEdit()">
Cancel
</button>
</div>
</div>
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
<div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.2.0',
'version': '19.0.18.3.2',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -13,6 +13,30 @@ from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def fp_customer_description(self):
"""Strip the "[code] product_name" prefix from line.name.
Mirror of sale.order.line.fp_customer_description so the shared
customer_line_description QWeb macro renders cleanly on invoice
PDFs too.
"""
self.ensure_one()
name = (self.name or '').strip()
if not self.product_id or not name:
return name
code = self.product_id.default_code or ''
pname = self.product_id.name or ''
prefixes = []
if code and pname:
prefixes.append(f'[{code}] {pname}')
if pname:
prefixes.append(pname)
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n-—–:').strip()
return name
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',

View File

@@ -25,9 +25,26 @@ class FpCoatingThickness(models.Model):
ondelete='cascade',
)
value = fields.Float(
string='Nominal',
digits=(10, 4),
required=True,
help='Target thickness value (magnitude only; UoM in the next field).',
help='Target / nominal thickness value (the number printed on the cert). '
'Magnitude only — UoM lives in the next field.',
)
# Hitting an exact thickness on plated parts is impossible — the spec
# is always "X mils ± tolerance" or a min/max range. These fields
# capture the acceptance band so QC can mark a reading pass/fail
# against real customer specs (e.g. AMS-2404 Class 4 = 0.001"0.0015").
# Both optional: leave blank for legacy single-value entries.
value_min = fields.Float(
string='Min',
digits=(10, 4),
help='Lower acceptance bound. Readings below this fail QC.',
)
value_max = fields.Float(
string='Max',
digits=(10, 4),
help='Upper acceptance bound. Readings above this fail QC.',
)
uom = fields.Selection(
[('mils', 'mils (0.001 in)'),
@@ -44,7 +61,7 @@ class FpCoatingThickness(models.Model):
store=True,
)
@api.depends('value', 'uom')
@api.depends('value', 'value_min', 'value_max', 'uom')
def _compute_display_name(self):
uom_labels = dict(self._fields['uom'].selection)
for rec in self:
@@ -52,7 +69,22 @@ class FpCoatingThickness(models.Model):
# Strip the bracketed clarification for a tighter dropdown row.
if ' (' in label:
label = label.split(' (')[0]
if rec.value:
# Range overrides single value when both bounds are set —
# operators see the real spec, not a phantom-precise nominal.
if rec.value_min and rec.value_max:
rec.display_name = (
f'{rec.value_min:g}{rec.value_max:g} {label}'.strip()
)
elif rec.value:
rec.display_name = f'{rec.value:g} {label}'.strip()
else:
rec.display_name = label
@api.constrains('value_min', 'value_max')
def _check_range(self):
for rec in self:
if rec.value_min and rec.value_max and rec.value_min > rec.value_max:
from odoo.exceptions import ValidationError
raise ValidationError(_(
'Thickness Min (%(mn)s) cannot exceed Max (%(mx)s).'
) % {'mn': rec.value_min, 'mx': rec.value_max})

View File

@@ -10,6 +10,36 @@ from odoo.exceptions import ValidationError
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def fp_customer_description(self):
"""Return line.name with the leading "[code] product_name" stripped.
Odoo's _compute_name re-prepends the product code + name on save,
polluting customer-facing PDFs with internal-product noise like
"[FP-SERVICE] Plating Service". This helper peels that prefix
off so the QWeb macros print only what the estimator actually
typed for the customer to see. Same logic mirrored on
account.move.line for invoice rendering.
"""
self.ensure_one()
name = (self.name or '').strip()
if not self.product_id or not name:
return name
code = self.product_id.default_code or ''
pname = self.product_id.name or ''
# Try the bracketed form first ("[CODE] Name"), then bare name.
# Whichever matches gets stripped along with any trailing
# newline / dash / em-dash separator.
prefixes = []
if code and pname:
prefixes.append(f'[{code}] {pname}')
if pname:
prefixes.append(pname)
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n-—–:').strip()
return name
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)

View File

@@ -77,7 +77,9 @@
<field name="thickness_option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="value"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<field name="uom"/>
<field name="display_name" string="Display" readonly="1"/>
<field name="active" widget="boolean_toggle"/>

View File

@@ -100,11 +100,39 @@
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<group>
<group string="Part &amp; Coating">
<field name="x_fc_configurator_id" readonly="1"/>
<!-- Multi-part summary: read-only list of every order line
showing part / coating / process. The Order Lines tab
is the editable surface; this is the at-a-glance view
so you can confirm an order has the right parts/coatings
without scrolling pricing columns. The pre-Sub-12 SO-
header singletons (x_fc_part_catalog_id /
x_fc_coating_config_id) only ever populated when the
order was built via the quote configurator — they're
silent on direct orders, which is why they appeared
empty after confirm. They still exist on the model
(used by configurator/portal) but are no longer the
primary display. -->
<separator string="Parts on this order"/>
<field name="order_line" nolabel="1"
context="{'tree_view_ref': 'fusion_plating_configurator.view_sale_order_line_plating_summary'}"
readonly="1">
<list create="false" delete="false" edit="false">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_thickness_id" optional="show"/>
<field name="x_fc_process_variant_id" optional="show"
string="Process"/>
<field name="product_uom_qty" string="Qty"/>
<field name="x_fc_part_deadline" optional="show"
string="Part Deadline"/>
<field name="x_fc_rush_order" optional="hide"/>
<field name="x_fc_job_number" optional="show"
string="Job #"/>
</list>
</field>
<group>
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
<group string="RFQ / PO">

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -220,23 +222,39 @@ class FpDirectOrderWizard(models.Model):
self._apply_strategy_payment_term()
return
# Legacy partner-field defaults (pre-Sub-5).
if 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
# 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:
self.invoice_strategy = partner.x_fc_default_invoice_strategy
if partner.x_fc_default_deposit_percent:
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
# 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
and not self.internal_deadline):
self.internal_deadline = (
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not self.customer_deadline):
self.customer_deadline = (
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
)
# Addresses.
addrs = self.partner_id.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
addrs = partner.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or partner.id
self.partner_shipping_id = addrs.get('delivery') or partner.id
# Per-customer invoice strategy default (fp.invoice.strategy.default).
# Pull strategy + deposit even when payment_term_id is empty — the
# previous condition `if isd and isd.payment_term_id` silently
# skipped the strategy fill for net-terms customers without
# explicit terms configured.
# Legacy fallback: fp.invoice.strategy.default (kept for sites
# mid-migration). Only fills gaps the partner fields didn't cover.
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
[('partner_id', '=', partner.id)], limit=1,
)
term = False
if isd:
@@ -245,8 +263,8 @@ class FpDirectOrderWizard(models.Model):
if not self.deposit_percent:
self.deposit_percent = isd.default_deposit_percent or 0.0
term = isd.payment_term_id
if not term and self.partner_id.property_payment_term_id:
term = self.partner_id.property_payment_term_id
if not term and partner.property_payment_term_id:
term = partner.property_payment_term_id
self.payment_term_id = term or False
# Re-apply strategy → terms mapping after partner switch.
@@ -271,6 +289,29 @@ class FpDirectOrderWizard(models.Model):
"""Map the strategy onto sensible payment terms."""
self._apply_strategy_payment_term()
@api.onchange('planned_start_date')
def _onchange_planned_start_date(self):
"""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.
"""
if not self.partner_id or not self.planned_start_date:
return
partner = self.partner_id
if (partner.x_fc_default_internal_deadline_days
and not self.internal_deadline):
self.internal_deadline = (
self.planned_start_date
+ timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not self.customer_deadline):
self.customer_deadline = (
self.planned_start_date
+ timedelta(days=partner.x_fc_default_customer_deadline_days)
)
def _apply_strategy_payment_term(self):
"""Mapping rule:
- cod_prepay → Immediate Payment
@@ -435,6 +476,12 @@ class FpDirectOrderWizard(models.Model):
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product:
# Seed the product with the company's default sale tax so the
# customer's fiscal position has something to RE-MAP. Without
# this, lines come out tax-free regardless of how the customer's
# fiscal position is configured (fiscal positions only re-map
# existing taxes; they don't manufacture them).
default_sale_tax = self.env.company.account_sale_tax_id
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
@@ -442,7 +489,14 @@ class FpDirectOrderWizard(models.Model):
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
'taxes_id': [(6, 0, default_sale_tax.ids)] if default_sale_tax else False,
})
elif not product.taxes_id and self.env.company.account_sale_tax_id:
# Self-heal: pre-existing FP-SERVICE without taxes (created in
# an earlier version) silently produced tax-free lines. Top up
# with the company default sale tax so customer fiscal positions
# can re-map correctly.
product.taxes_id = [(6, 0, self.env.company.account_sale_tax_id.ids)]
# 3. Build SO header
so_vals = {

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Invoicing',
'version': '19.0.3.2.0',
'version': '19.0.3.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
'description': """

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backfill plating defaults from fp.invoice.strategy.default → res.partner.
v3.3 merges the per-customer invoice strategy onto the partner record
itself so the new "Plating Defaults" tab is the single source of truth.
Only plain columns are migrated here (invoice_strategy + deposit %).
The legacy `fp.invoice.strategy.default` model is left in place; the
new sale.order onchange falls back to it for any partner whose record
hasn't been migrated, so downstream code keeps working mid-rollout.
property_payment_term_id is intentionally skipped — it lives in
ir_property rather than as a plain column, and the legacy onchange
fallback already reads payment_term from the strategy default record
when the partner doesn't have one set directly.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE res_partner p
SET x_fc_default_invoice_strategy = COALESCE(
p.x_fc_default_invoice_strategy, isd.default_strategy),
x_fc_default_deposit_percent = COALESCE(
NULLIF(p.x_fc_default_deposit_percent, 0),
isd.default_deposit_percent)
FROM fp_invoice_strategy_default isd
WHERE isd.partner_id = p.id
""")
_logger.info(
'fusion_plating_invoicing migration 19.0.3.3.0: backfilled %d '
'partner records from fp.invoice.strategy.default',
cr.rowcount,
)

View File

@@ -9,6 +9,7 @@ from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
# ===== Account hold (existing) ============================================
x_fc_account_hold = fields.Boolean(
string='Account Hold', tracking=True,
help='When active, blocks SO confirmation, invoicing, and shipping.',
@@ -20,3 +21,45 @@ class ResPartner(models.Model):
x_fc_account_hold_by_id = fields.Many2one(
'res.users', string='Hold Placed By',
)
# ===== Plating Defaults (cascade onto every new SO for this customer) =====
# The estimator sets these once on the customer record; they pre-fill
# invoice strategy, delivery method, and deadlines on every new SO so
# repeat customers don't need re-typing the same values each order.
# Tax type lives on `property_account_position_id` (Odoo native fiscal
# position) and payment terms on `property_payment_term_id` — both are
# surfaced on the same Plating Defaults tab in the partner form.
x_fc_default_invoice_strategy = fields.Selection(
[('deposit', 'Deposit'),
('progress', 'Progress Billing'),
('net_terms', 'Net Terms'),
('cod_prepay', 'COD / Prepay')],
string='Default Invoice Strategy',
help='Pre-fills the SO invoice strategy when this customer is selected. '
'The estimator can still override per order.',
)
x_fc_default_deposit_percent = fields.Float(
string='Default Deposit %',
help='Used when invoice strategy is "Deposit". e.g. 50.0 for 50%.',
)
x_fc_default_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Default Delivery Method',
help='Pre-fills the SO delivery method when this customer is selected.',
)
# Lead-time defaults are expressed as offsets FROM the SO's planned-start
# date so they track real production schedules, not just "today + N".
# If planned_start is unset on the SO, the cascade falls back to today.
x_fc_default_internal_deadline_days = fields.Integer(
string='Internal Deadline (+ days from start)',
help='Pre-fills SO internal deadline as planned_start_date + this '
'many days. e.g. 5 means "ship five days after we start".',
)
x_fc_default_customer_deadline_days = fields.Integer(
string='Customer Deadline (+ days from start)',
help='Pre-fills the customer-facing commitment date as '
'planned_start_date + this many days.',
)

View File

@@ -4,6 +4,7 @@
# Part of the Fusion Plating product family.
import logging
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
@@ -16,16 +17,70 @@ class SaleOrder(models.Model):
@api.onchange('partner_id')
def _onchange_partner_id_invoice_strategy(self):
"""Auto-fill invoice strategy from customer defaults."""
if self.partner_id:
default = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
if default:
self.x_fc_invoice_strategy = default.default_strategy
self.x_fc_deposit_percent = default.default_deposit_percent
if default.payment_term_id:
self.payment_term_id = default.payment_term_id
"""Auto-fill plating defaults from customer profile.
Cascade order: partner-level defaults first (the new fast-order
path), then fall back to the legacy fp.invoice.strategy.default
records for customers migrated before that model was retired.
Native Odoo cascades (payment terms, fiscal position) handle
themselves via property_* fields and don't need code here.
"""
if not self.partner_id:
return
partner = self.partner_id
if partner.x_fc_default_invoice_strategy:
self.x_fc_invoice_strategy = partner.x_fc_default_invoice_strategy
if partner.x_fc_default_deposit_percent:
self.x_fc_deposit_percent = partner.x_fc_default_deposit_percent
if partner.x_fc_default_delivery_method:
self.x_fc_delivery_method = partner.x_fc_default_delivery_method
self._fp_recompute_default_deadlines()
# Legacy fallback: invoice strategy default model. Only fills
# gaps left by the partner fields above so a partial migration
# doesn't clobber explicit partner-level values.
legacy = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', partner.id)], limit=1,
)
if legacy:
if not self.x_fc_invoice_strategy:
self.x_fc_invoice_strategy = legacy.default_strategy
if not self.x_fc_deposit_percent:
self.x_fc_deposit_percent = legacy.default_deposit_percent
if legacy.payment_term_id and not self.payment_term_id:
self.payment_term_id = legacy.payment_term_id
@api.onchange('x_fc_planned_start_date')
def _onchange_planned_start_date_deadlines(self):
"""Recompute deadlines when planned start changes — without it
the partner offsets would only fire on partner_id change."""
self._fp_recompute_default_deadlines()
def _fp_recompute_default_deadlines(self):
"""Apply partner deadline offsets relative to planned_start_date.
Falls back to today when planned_start is unset so the estimator
gets a value immediately. Never overwrites a deadline already
set by the user (we honour explicit input over auto-fill).
"""
for order in self:
partner = order.partner_id
if not partner:
continue
anchor = order.x_fc_planned_start_date or fields.Date.context_today(order)
if (partner.x_fc_default_internal_deadline_days
and not order.x_fc_internal_deadline):
order.x_fc_internal_deadline = (
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not order.commitment_date):
order.commitment_date = (
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
)
def action_confirm(self):
"""Override to check account hold + customer PO# and trigger

View File

@@ -23,7 +23,36 @@
</small>
</div>
</xpath>
<!-- Single "Plating Defaults" tab — invoice strategy, delivery,
deadlines, tax type, payment terms. Set once here, cascades
onto every new SO for this customer. -->
<xpath expr="//notebook" position="inside">
<page string="Plating Defaults" name="fp_plating_defaults_tab"
invisible="is_company == False and parent_id"
groups="fusion_plating_invoicing.group_fp_accounting">
<p class="text-muted">
Set defaults once per customer to speed up order entry.
These cascade onto every new sale order; the estimator
can override per order.
</p>
<group>
<group string="Invoicing">
<field name="x_fc_default_invoice_strategy"/>
<field name="x_fc_default_deposit_percent"
invisible="x_fc_default_invoice_strategy != 'deposit'"/>
<field name="property_payment_term_id"/>
<field name="property_account_position_id"
string="Tax Type (Fiscal Position)"/>
</group>
<group string="Fulfilment">
<field name="x_fc_default_delivery_method"/>
<field name="x_fc_default_internal_deadline_days"/>
<field name="x_fc_default_customer_deadline_days"/>
</group>
</group>
</page>
<page string="Account Hold" name="account_hold_tab"
groups="fusion_plating_invoicing.group_fp_accounting">
<group>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.8.0',
'version': '19.0.8.11.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -202,9 +202,9 @@ class FpJob(models.Model):
job.racking_inspection_state = ri.state if ri else False
def action_view_racking_inspection(self):
"""Open the racking inspection. Auto-create if missing (e.g. job
was created before Sub 8 shipped, or auto-create silently failed
at action_confirm time)."""
"""Open the racking inspection. Auto-create if missing, or seed
lines from the SO if it exists but was created before line auto-
seeding shipped (the helper handles both cases idempotently)."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
from odoo.exceptions import UserError
@@ -212,9 +212,12 @@ class FpJob(models.Model):
'Sub 8 racking inspection module not installed. '
'Install fusion_plating_receiving to enable.'
))
if not self.racking_inspection_id:
self._fp_create_racking_inspection()
self.invalidate_recordset(['racking_inspection_ids'])
# Always call the helper — it short-circuits for already-populated
# draft inspections and creates fresh ones when missing. This is
# also the entry point that backfills lines on inspections that
# pre-date the line-seeding feature.
self._fp_create_racking_inspection()
self.invalidate_recordset(['racking_inspection_ids'])
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
if not ri:
from odoo.exceptions import UserError
@@ -239,11 +242,39 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id},
}
def action_finish_current_step(self):
"""Steelhead-style header button: finish whatever's currently
in_progress and auto-start the next pending/ready step. If
nothing is running yet, start the lowest-sequence pending step
instead — operator's first click on a fresh job just begins
the line.
"""
self.ensure_one()
running = self.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
if running:
return running.action_finish_and_advance()
# No running step — kick off the first pending/ready one.
first = self.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'paused')
).sorted('sequence')[:1]
if not first:
raise UserError(_(
'No runnable step found on this job — either every step '
'is done or the job is still in draft.'
))
first.with_context(fp_skip_predecessor_check=True).button_start()
self.message_post(body=_(
'Started first step "%s".'
) % first.name)
return True
def action_open_move_wizard(self):
"""Header button — opens the Move wizard pre-filled with the
currently in-progress (or most recently in-progress) step as the
from-step. Lets the manager move the job forward without first
clicking into a specific step row.
"""Original Move wizard — kept available for cross-station moves
and rework / scrap transfers. The simple "finish current → start
next" flow is now action_finish_current_step (header button).
Opens the wizard pre-filled with the currently in-progress (or
most recently in-progress) step as the from-step.
"""
self.ensure_one()
active_step = self.step_ids.filtered(
@@ -871,6 +902,9 @@ class FpJob(models.Model):
production_id too so legacy reports keep working.
Idempotent — if an inspection already exists for this job, skip.
Either way the inspection's lines are seeded from the SO's
plating order lines so the racker walks into a pre-populated
checklist instead of an empty form.
"""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
@@ -883,17 +917,62 @@ class FpJob(models.Model):
('x_fc_job_id', '=', self.id),
], limit=1)
if existing:
# Self-heal: pre-existing inspections from before line seeding
# was added show up empty. Top them up now if still empty +
# the inspection isn't already finalised (don't rewrite history).
if not existing.line_ids and existing.state == 'draft':
self._fp_seed_racking_lines(existing)
return
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
vals = {'x_fc_job_id': self.id}
try:
Inspection.create(vals)
insp = Inspection.create(vals)
self._fp_seed_racking_lines(insp)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create racking inspection: %s",
self.name, e,
)
def _fp_seed_racking_lines(self, inspection):
"""Populate the inspection with one line per SO plating order line.
Walks sale_order_line_ids (the M2M of SO lines tied to this job),
falling back to the linked SO's order_line. Each line carries the
part_catalog and the quoted qty as the expected count — the
racker confirms or amends on the floor.
"""
self.ensure_one()
if not inspection or inspection.line_ids:
return
Line = self.env['fp.racking.inspection.line'].sudo()
# Source preference: explicit M2M of plating lines bound to this
# job (fast-order multi-part jobs), falling back to the SO header.
so_lines = self.sale_order_line_ids
if not so_lines and self.sale_order_id:
so_lines = self.sale_order_id.order_line
plating_lines = so_lines.filtered(
lambda l: l.x_fc_part_catalog_id and not l.display_type
)
if not plating_lines:
return
seq = 10
for sol in plating_lines:
try:
Line.create({
'inspection_id': inspection.id,
'sequence': seq,
'part_catalog_id': sol.x_fc_part_catalog_id.id,
'qty_expected': int(sol.product_uom_qty or 0),
'condition': 'ok',
})
except Exception as e:
_logger.warning(
"Job %s: failed to seed racking line for SO line %s: %s",
self.name, sol.id, e,
)
seq += 10
def _fp_create_portal_job(self):
"""Create the fusion.plating.portal.job mirror record."""
self.ensure_one()

View File

@@ -326,6 +326,98 @@ class FpJobStep(models.Model):
)) % (step.name, old, new, new - old, self.env.user.name))
return True
def action_finish_and_advance(self):
"""Steelhead-style "Finish & Next" — finish this step then auto-
start the next pending/ready step in sequence. Single click
replaces the prior Finish-then-Move-wizard dance.
If the step has authored step_input prompts AND none have been
captured yet, we route through the simplified Record Inputs
wizard first; saving the wizard re-enters here with the
`fp_after_inputs=True` context flag so we don't loop.
"""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — start it before clicking Finish."
) % (self.name, self.state))
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
# called BACK from the wizard's commit), or when the operator
# already saved values via the Record Inputs button earlier.
if (not self.env.context.get('fp_after_inputs')
and self._fp_has_uncaptured_step_inputs()):
return self._fp_open_input_wizard(advance_after=True)
self.button_finish()
next_step = self._fp_next_runnable_step()
if next_step:
next_step.with_context(
fp_skip_predecessor_check=True,
).button_start()
self.job_id.message_post(body=_(
'Step "%(prev)s" finished — auto-started next step "%(next)s".'
) % {'prev': self.name, 'next': next_step.name})
return True
def _fp_next_runnable_step(self):
"""The lowest-sequence step on this job that isn't terminal yet
and isn't this one. Used by action_finish_and_advance."""
self.ensure_one()
candidates = self.job_id.step_ids.filtered(
lambda s: s.id != self.id
and s.state in ('pending', 'ready', 'paused')
).sorted('sequence')
return candidates[:1] or self.env['fp.job.step']
def _fp_has_uncaptured_step_inputs(self):
"""True when the recipe step defines step_input prompts AND
the user hasn't already saved values for this step's current
run via the Record Inputs wizard.
"""
self.ensure_one()
node = self.recipe_node_id
if not node:
return False
prompts = node.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
if not prompts:
return False
# Has the operator already recorded values during this run?
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
# for this step since date_started.
Move = self.env['fp.job.step.move']
already = Move.search_count([
('from_step_id', '=', self.id),
('transfer_type', '=', 'step'),
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
])
return already == 0
def _fp_open_input_wizard(self, advance_after=False):
"""Open the simplified Record Inputs dialog. When advance_after
is True, the wizard's Save button finishes the step and starts
the next one as a single atomic flow."""
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id(
'fusion_plating_jobs.action_fp_job_step_input_wizard'
)
action['context'] = {
**dict(self.env.context),
'default_step_id': self.id,
'active_id': self.id,
'fp_advance_after_save': advance_after,
}
return action
# NB: action_open_input_wizard is defined further down (line ~829)
# — that one stays as the per-row "Record" button entry-point.
# _fp_open_input_wizard above adds the advance_after pathway used
# only by action_finish_and_advance.
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes

View File

@@ -18,11 +18,16 @@
<field name="name">FP Traveller — A4 landscape narrow margins</field>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">10</field>
<!-- margin_top + header_spacing both reserve room above the body
so the H1 / Item Information table doesn't ride into the
external_layout's company logo band. The screenshot showed
"Work Order / Bon de Travail" overlapping the ENTECH logo
with the prior 10 / 5 values; 28 / 22 buys ~1cm clear gap. -->
<field name="margin_top">28</field>
<field name="margin_bottom">10</field>
<field name="margin_left">8</field>
<field name="margin_right">8</field>
<field name="header_spacing">5</field>
<field name="header_spacing">22</field>
<field name="dpi">90</field>
</record>

View File

@@ -21,11 +21,17 @@
<field name="name">FP Work Order Detail — A4 portrait</field>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<!-- margin_top + header_spacing both reserve room above the body
content. The external_layout puts the company logo + address
in that band; without enough space the header overlaps the
body's first line (the H1 on page 1, the Certified By table
on page 2). 35 / 28 puts a clean ~1cm clear gap below the
logo block. -->
<field name="margin_top">35</field>
<field name="margin_bottom">15</field>
<field name="margin_left">12</field>
<field name="margin_right">12</field>
<field name="header_spacing">8</field>
<field name="header_spacing">28</field>
<field name="dpi">90</field>
</record>
@@ -46,14 +52,42 @@
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<t t-set="company" t-value="job.company_id"/>
<t t-set="moves" t-value="job.move_ids.sorted('move_datetime')"/>
<t t-set="so" t-value="job.sale_order_id"/>
<!-- All datetimes in Postgres are naive UTC. QWeb's
eval scope exposes neither pytz nor format_datetime,
so timestamp formatting happens via job.fp_format_local()
on the record itself — record methods are always
available in templates. The helper resolves user.tz
→ company.x_fc_default_tz → UTC. -->
<!-- First SO line linked to this job — source of truth
for the customer-facing description, serial(s),
and part metadata. -->
<t t-set="primary_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="po_number"
t-value="(so and (so.client_order_ref or (
'x_fc_po_number' in so._fields and so.x_fc_po_number) or ''))
or ''"/>
<t t-set="customer_desc"
t-value="primary_line and primary_line.fp_customer_description() or ''"/>
<t t-set="serial_names"
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
or ''"/>
<!-- Walk EVERY step in sequence, not just moves. The
old report only rendered moves so steps without
recorded measurements (just Finish & Next) never
appeared on the cert. -->
<t t-set="all_steps" t-value="job.step_ids.filtered(
lambda s: s.state not in ('cancelled',)
).sorted('sequence')"/>
<div class="page fp-wo-detail">
<style>
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 14px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
.fp-wo-detail table.bordered,
.fp-wo-detail table.bordered th,
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
@@ -61,15 +95,16 @@
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
.fp-wo-detail .text-center { text-align: center; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 8px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 8px 0 4px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 6px; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 12px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
</style>
<h1>Work Order Detail</h1>
<!-- ===== HEADER — Prepared For + summary table ===== -->
<div style="margin-bottom: 8px;">
<div class="fp-prepared">
<strong>Prepared For:</strong>
<span style="font-size: 11pt;"
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
@@ -77,35 +112,41 @@
<table class="bordered">
<tr>
<th style="width: 20%;">Part Number</th>
<th style="width: 18%;">Part Number</th>
<th style="width: 30%;">Description</th>
<th style="width: 8%;">Quantity</th>
<th style="width: 10%;">Work Order</th>
<th style="width: 14%;">PO Number</th>
<th style="width: 8%;">Packing List No</th>
<th style="width: 7%;">Quantity</th>
<th style="width: 11%;">Work Order</th>
<th style="width: 12%;">PO Number</th>
<th style="width: 12%;">Serial No</th>
<th style="width: 10%;">Date</th>
</tr>
<tr>
<td>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or '—'"/>
<t t-if="'revision' in job.part_catalog_id._fields and job.part_catalog_id.revision">
<br/>
<span style="font-size: 7.5pt;">Rev <span t-esc="job.part_catalog_id.revision"/></span>
</t>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.default_code) or '—'"/>
</t>
</td>
<td style="white-space: pre-wrap;">
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.name) or '—'"/>
</t>
<t t-if="'special_requirements' in job._fields and job.special_requirements">
<br/>
<span style="font-size: 7.5pt;"
t-esc="job.special_requirements"/>
</t>
<td style="vertical-align: top;">
<!-- Customer-facing description. The
pre-line wrapper lives on an
INNER div, not the <td>: keeping
pre-line on the cell rendered
the indentation between <td>
and <t t-if> as literal blank
lines, pushing the description
halfway down the cell. The div
only sees the t-esc'd text, so
pre-line preserves the operator's
intentional \n\n paragraph
breaks but nothing else. -->
<div style="white-space: pre-line;"><t t-if="customer_desc"><span t-esc="customer_desc.strip()"/></t><t t-elif="'part_catalog_id' in job._fields and job.part_catalog_id"><span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/></t><t t-else=""><span t-esc="(job.product_id and job.product_id.name) or '—'"/></t></div>
</td>
<td class="text-center">
<span t-esc="job.qty"/>
@@ -114,11 +155,15 @@
<span t-esc="job.name"/>
</td>
<td>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
<span t-esc="po_number or '—'"/>
</td>
<td/>
<td>
<span t-esc="(job.date_finished or job.date_started or job.create_date) and (job.date_finished or job.date_started or job.create_date).strftime('%Y-%m-%d') or ''"/>
<span t-esc="serial_names or '—'"/>
</td>
<td>
<t t-set="_hdr_dt"
t-value="job.date_finished or job.date_started or job.create_date"/>
<span t-esc="job.fp_format_local(_hdr_dt, '%Y-%m-%d')"/>
</td>
</tr>
</table>
@@ -130,15 +175,39 @@
<hr class="heavy"/>
<!-- ===== CHAIN-OF-CUSTODY WALK ===== -->
<t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<!-- ===== STEPS WALK ===== -->
<t t-foreach="all_steps" t-as="step">
<!-- Aggregate captured input values from any
move that touches this step (incoming or
outgoing — the Record Inputs wizard
creates a self-loop move with from=to=step). -->
<t t-set="step_moves"
t-value="job.move_ids.filtered(
lambda m: m.from_step_id == step or m.to_step_id == step
).sorted('move_datetime')"/>
<t t-set="step_values"
t-value="step_moves.mapped('transition_input_value_ids')"/>
<!-- Pick a representative "Moved By" / Time:
prefer the step's own date_finished, fall
back to first move on the step, fall back
to date_started. Same for the user. -->
<t t-set="display_dt"
t-value="step.date_finished or (step_moves and step_moves[-1].move_datetime) or step.date_started or False"/>
<t t-set="display_user"
t-value="(step.finished_by_user_id and step.finished_by_user_id.name)
or (step_moves and step_moves[-1].moved_by_user_id and step_moves[-1].moved_by_user_id.name)
or (step.started_by_user_id and step.started_by_user_id.name)
or ''"/>
<div class="fp-step-block">
<h3>
<span t-esc="(dest and dest.name) or '—'"/>
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
<span t-esc="step.name or '—'"/>
<t t-if="step.tank_id and step.tank_id.code">
(<span t-esc="step.tank_id.code"/>)
</t>
<t t-if="step.state == 'skipped'">
<span style="font-size: 9pt; color: #888; font-weight: normal;">— SKIPPED</span>
</t>
</h3>
<div class="fp-meta">
<strong>Part Number:</strong>
@@ -151,69 +220,72 @@
<t t-else="">
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
</t>
<br/>
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
<t t-if="display_user or display_dt">
<br/>
<strong>Moved By:</strong>
<span t-esc="display_user or '—'"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="job.fp_format_local(display_dt, '%b %d, %Y %I:%M:%S %p') or '—'"/>
</t>
</div>
<!-- Captured input values for this move -->
<t t-set="captured_values_by_input"
t-value="{v.node_input_id.id: v for v in mv.transition_input_value_ids}"/>
<t t-set="prompts" t-value="False"/>
<t t-if="dest and dest.recipe_node_id">
<t t-set="prompts"
t-value="dest.recipe_node_id.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')"/>
</t>
<t t-if="not prompts and mv.transition_input_value_ids">
<t t-set="prompts"
t-value="mv.transition_input_value_ids.mapped('node_input_id')"/>
</t>
<t t-if="prompts and mv.transition_input_value_ids">
<!-- Captured inputs table — only rendered
when this step has at least one
value recorded across all its moves. -->
<t t-if="step_values">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 32%;">Description</th>
<th style="width: 18%;">Value</th>
<th style="width: 28%;">Recorded By</th>
<th style="width: 26%;">Recorded By</th>
</tr>
</thead>
<tbody>
<t t-foreach="prompts" t-as="inp">
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
<t t-if="cv">
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
<t t-foreach="step_values" t-as="cv">
<t t-set="inp" t-value="cv.node_input_id"/>
<t t-set="prompt_name"
t-value="(inp and inp.name) or (cv.value_text and cv.value_text.split(':')[0]) or 'Measurement'"/>
<t t-set="prompt_hint"
t-value="(inp and 'hint' in inp._fields and inp.hint) or ''"/>
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
<!-- Strip the leading "Prompt:" prefix that
ad-hoc rows store so the Value cell
shows just the value, not the prompt
twice. -->
<t t-if="inp and inp.name and actual_str.startswith(inp.name + ':')">
<t t-set="actual_str" t-value="actual_str[len(inp.name)+1:].strip()"/>
</t>
<t t-elif="cv.value_number">
<t t-set="actual_str"
t-value="('%s %s' % (cv.value_number, (inp.target_unit if 'target_unit' in inp._fields and inp.target_unit else ''))).strip()"/>
</t>
<t t-elif="cv.value_boolean is not False">
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
</t>
<t t-elif="cv.value_date">
<t t-set="actual_str" t-value="cv.value_date.strftime('%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="inp.name"/></td>
<td>
<t t-if="'hint' in inp._fields and inp.hint">
<span t-esc="inp.hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(mv.moved_by_user_id and mv.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
<t t-elif="cv.value_number">
<t t-set="_unit" t-value="(inp and 'target_unit' in inp._fields and inp.target_unit) or ''"/>
<t t-set="actual_str" t-value="('%s %s' % (cv.value_number, _unit)).strip()"/>
</t>
<t t-elif="cv.value_boolean is not False">
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
</t>
<t t-elif="cv.value_date">
<t t-set="actual_str"
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="prompt_name"/></td>
<td>
<t t-if="prompt_hint">
<span t-esc="prompt_hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
</tbody>
</table>
@@ -221,16 +293,22 @@
</div>
</t>
<t t-if="not moves">
<t t-if="not all_steps">
<p style="color: #888; font-style: italic;">
No move log entries yet — this job hasn't progressed
through any steps. Operators move the job forward
via the tablet or the backend Move wizard.
No steps on this job yet — operators progress the
job via Start / Finish &amp; Next on the form, or
via the tablet.
</p>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<p style="page-break-before: always;"/>
<!-- page-break-before is honoured by wkhtmltopdf
but the new page starts flush against the
header_spacing band; the spacer div below
gives the cert table breathing room so it
doesn't sit under the company logo. -->
<div style="page-break-before: always;"/>
<div style="height: 8mm;"/>
<t t-set="owner_sig" t-value="False"/>
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">

View File

@@ -25,8 +25,14 @@
class="btn-secondary"
icon="fa-sitemap"
invisible="state == 'draft'"/>
<button name="action_open_move_wizard" type="object"
string="Move to Next Step"
<!-- Steelhead-style "Finish & Next": one click finishes
whatever's running and auto-starts the next pending
step. Falls back to starting the first step if
nothing is running yet. The classic Move wizard is
still available via the per-row Move button (used
for cross-station moves and rework / scrap). -->
<button name="action_finish_current_step" type="object"
string="Finish &amp; Next"
class="btn-primary"
icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/>
@@ -42,6 +48,28 @@
invisible="state in ('draft', 'cancelled')"/>
</xpath>
<!-- Surface part / coating / recipe on the header so the
floor knows WHAT they're plating without diving into
Source. The "Reference Product" line in core is just
the FP-SERVICE stub from the SO — relabel it so it
doesn't compete with the real part identification. -->
<xpath expr="//field[@name='product_id']" position="attributes">
<attribute name="string">Service Product</attribute>
<attribute name="invisible">part_catalog_id</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/>
<field name="coating_config_id" string="Coating"/>
<field name="recipe_id" string="Process Recipe"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty
picture is visible at a glance without opening Move Log. -->
<xpath expr="//field[@name='qty']" position="after">
<field name="qty_done" string="Qty Done"/>
<field name="qty_scrapped" string="Qty Scrapped"
invisible="not qty_scrapped"/>
</xpath>
<!-- Replace the bare-bones Steps list with the action-rich
manager view. Per-row buttons mirror what an operator
sees on the tablet; Running Min ticks on every refresh
@@ -67,6 +95,14 @@
<field name="duration_expected" optional="show"/>
<field name="duration_running_minutes" string="Running Min" optional="show"/>
<field name="duration_actual" optional="show"/>
<!-- Live qty currently parked at this step. Hits
zero once everything has moved on; >0 means
the floor still has parts to process here. -->
<field name="qty_at_step" string="Qty Here" optional="show"/>
<!-- Primary action: state-aware. Pending/ready → Start,
in_progress → Finish & Next (auto-advance like
Steelhead), paused → Resume. Done / skipped /
cancelled rows show no primary. -->
<button name="button_start" type="object"
string="Start" icon="fa-play"
class="btn-link text-success"
@@ -75,26 +111,32 @@
string="Resume" icon="fa-play-circle"
class="btn-link text-success"
invisible="state != 'paused'"/>
<button name="action_finish_and_advance" type="object"
string="Finish &amp; Next" icon="fa-check-circle"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<!-- Secondary actions — small icons only. Pause is
only relevant on a running step; Record Inputs
stays available so operators can capture
measurements without finishing the step;
Skip + Move (cross-station) tucked together. -->
<button name="button_pause" type="object"
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
<button name="button_finish" type="object"
string="Finish" icon="fa-check"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<button name="action_open_move_wizard" type="object"
string="Move" icon="fa-arrow-right"
class="btn-link"
invisible="state in ('done', 'cancelled', 'skipped')"/>
<button name="action_open_input_wizard" type="object"
string="Record Inputs" icon="fa-pencil-square-o"
string="Record" icon="fa-pencil-square-o"
class="btn-link"
invisible="state in ('cancelled', 'skipped')"/>
<button name="button_skip" type="object"
string="Skip" icon="fa-step-forward"
class="btn-link text-muted"
invisible="state not in ('pending', 'ready')"/>
<button name="action_open_move_wizard" type="object"
string="Move…" icon="fa-exchange"
class="btn-link text-muted"
invisible="state in ('done', 'cancelled', 'skipped', 'pending')"/>
</list>
</field>
</xpath>

View File

@@ -154,6 +154,14 @@ class FpJobStepInputWizard(models.TransientModel):
self.step_id.message_post(body=_(
'%(n)s step input(s) recorded by %(user)s'
) % {'n': captured, 'user': self.env.user.name})
# When the wizard was opened from "Finish & Next" we re-enter
# the step's finish-and-advance flow with a context flag so it
# skips the prompt-for-inputs branch and finishes directly.
if self.env.context.get('fp_advance_after_save'):
return self.step_id.with_context(
fp_after_inputs=True,
).action_finish_and_advance()
return {'type': 'ir.actions.act_window_close'}
@@ -207,6 +215,36 @@ class FpJobStepInputWizardLine(models.TransientModel):
for rec in self:
rec.is_authored = bool(rec.node_input_id)
# ---- Single-column value editor -----------------------------------------
# The previous wizard exposed FOUR value columns (text / number /
# yes-no / date) — operators saw 9 columns wide and got lost. We
# collapse them into one "Value" column whose widget routes to the
# right typed field based on input_type. Booleans and dates get
# their own dedicated field (still per-row) so the widget behaves
# naturally; everything else types into a single value box.
is_boolean_type = fields.Boolean(
compute='_compute_type_flags',
)
is_date_type = fields.Boolean(
compute='_compute_type_flags',
)
is_numeric_type = fields.Boolean(
compute='_compute_type_flags',
)
@api.depends('input_type')
def _compute_type_flags(self):
numeric_types = {
'number', 'temperature', 'thickness',
'time_seconds',
}
for rec in self:
it = rec.input_type or 'text'
rec.is_boolean_type = it in ('boolean', 'pass_fail')
rec.is_date_type = it == 'date'
rec.is_numeric_type = it in numeric_types
def _has_value(self):
self.ensure_one()
return any([

View File

@@ -11,38 +11,58 @@
<field name="step_id" readonly="1"/>
<field name="job_id" readonly="1"/>
</group>
<separator string="Step Inputs"/>
<separator string="Measurements"/>
<p class="text-muted" invisible="line_ids">
No authored prompts on this recipe step. Click
<strong>Add a line</strong> below to record one or
more ad-hoc measurements (operator name + value).
Authored prompts will appear here automatically once
the recipe gets `step_input` rows in the Process
Composer.
Click <strong>Add a line</strong> to record one or
more measurements for this step.
</p>
<field name="line_ids">
<list editable="bottom">
<field name="is_authored" column_invisible="1"/>
<field name="is_boolean_type" column_invisible="1"/>
<field name="is_date_type" column_invisible="1"/>
<field name="is_numeric_type" column_invisible="1"/>
<field name="name"
string="Measurement"
readonly="is_authored"
placeholder="e.g. Oven Temp, Operator Initials, Bath Reading"/>
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"/>
<field name="input_type"
string="Type"
readonly="is_authored"/>
<field name="target_unit"
string="Unit"
readonly="is_authored"
placeholder="number / text / boolean / date"
optional="show"/>
<field name="target_min" readonly="is_authored" optional="hide"/>
<field name="target_max" readonly="is_authored" optional="hide"/>
<field name="target_unit" readonly="is_authored" optional="show"/>
<field name="value_text"/>
<field name="value_number"/>
<field name="value_boolean" widget="boolean_toggle"/>
<field name="value_date"/>
<!-- Distinct column labels so the operator
reads which input matches the row's
type. List-view columns are static in
Odoo — labelling each by its purpose
removes the "four identical Value
columns" guesswork from the previous
layout. Only the cell matching the
row's type stays editable; others sit
blank. -->
<field name="value_number"
string="Number"
invisible="not is_numeric_type"/>
<field name="value_boolean"
string="Yes / No"
widget="boolean_toggle"
invisible="not is_boolean_type"/>
<field name="value_date"
string="Date / Time"
invisible="not is_date_type"/>
<field name="value_text"
string="Text"
invisible="is_numeric_type or is_boolean_type or is_date_type"/>
<field name="target_min" optional="hide"/>
<field name="target_max" optional="hide"/>
</list>
</field>
</sheet>
<footer>
<button name="action_commit" type="object"
string="Record" class="btn-primary"/>
string="Save" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>

View File

@@ -115,7 +115,14 @@ class FpJobStepMoveWizard(models.TransientModel):
if from_step.exists():
defaults['from_step_id'] = from_step.id
defaults['job_id'] = from_step.job_id.id
defaults['qty_moved'] = int(from_step.job_id.qty or 1)
# Default to "qty currently here", not "job total". A job
# already mid-flight may have parts split across steps;
# pre-filling with the full job qty would silently let
# the operator move more than is actually parked here.
# Fall back to job qty when qty_at_step is 0 (e.g.
# opened on a fresh step before any movement).
qty_here = int(from_step.qty_at_step or 0)
defaults['qty_moved'] = qty_here or int(from_step.job_id.qty or 1)
# Next sequenced step that isn't done/cancelled
next_step = self.env['fp.job.step'].search([
('job_id', '=', from_step.job_id.id),
@@ -222,6 +229,29 @@ class FpJobStepMoveWizard(models.TransientModel):
if not self.from_step_id or not self.to_step_id:
raise UserError(_('Pick both From and To steps before moving.'))
# Partial-qty guards. The operator can't move more than is
# parked at the from-step, and zero/negative is meaningless.
# Self-loop moves (input recording) bypass the upper bound
# because they don't move qty.
if self.qty_moved <= 0:
raise UserError(_(
'Qty Moved must be at least 1. Use Skip on the step row '
'instead if no parts are being processed.'
))
is_self_loop = (self.from_step_id == self.to_step_id)
if not is_self_loop:
qty_here = int(self.from_step_id.qty_at_step or 0)
if qty_here > 0 and self.qty_moved > qty_here:
raise UserError(_(
'Cannot move %(req)s parts — only %(here)s currently '
'parked at "%(step)s". Adjust Qty Moved or split '
'across multiple moves.'
) % {
'req': self.qty_moved,
'here': qty_here,
'step': self.from_step_id.name,
})
Move = self.env['fp.job.step.move']
move = Move.create({
'job_id': self.job_id.id,

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.7.0',
'version': '19.0.3.7.1',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -97,7 +97,7 @@
<separator string="Photos"/>
<field name="photo_ids"
widget="many2many_tags"
options="{'no_quick_create': True, 'color_field': 'color'}"
options="{'no_quick_create': True}"
nolabel="1"
help="Attach damage / condition photos for this box. Click + to upload, then click any pill to preview."/>
<separator string="Notes"/>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.10.1.0',
'version': '19.0.10.1.3',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -66,20 +66,34 @@
<!-- ==========================================================
customer_line_description — customer-facing description
plus any populated line metadata (serial, job#, thickness).
Intended for the "Description" td in customer-facing tables.
plus serial + thickness only.
Per client request (2026-04-29): customer-facing reports
show ONLY description, serial, and thickness. Job # was
previously shown here but is internal-only — it lives on
the traveller / WO sticker / packing slip header, not on
what the customer sees. Process variant, treatment names,
and recipe codes deliberately don't render either.
========================================================== -->
<template id="customer_line_description">
<t t-if="line.x_fc_part_catalog_id">
<span t-esc="line.name"/>
<!-- Strip the "[FP-SERVICE] Plating Service" prefix Odoo's
_compute_name keeps re-prepending. fp_customer_description
lives on sale.order.line + account.move.line; for any
other model the macro might be called with we degrade to
raw line.name. QWeb's eval context doesn't expose Python
builtins like hasattr/getattr, so probe the model's method
dict via line._name + env. white-space: pre-line preserves
the estimator's line breaks. -->
<t t-set="_has_helper"
t-value="line._name in ('sale.order.line', 'account.move.line')"/>
<t t-set="_desc"
t-value="line.fp_customer_description() if _has_helper else line.name"/>
<span t-esc="_desc" style="white-space: pre-line;"/>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<br/>
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
</t>
<t t-if="'x_fc_job_number' in line._fields and line.x_fc_job_number">
<br/>
<small>Job #: <span t-esc="line.x_fc_job_number"/></small>
</t>
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
<br/>
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>