changes
This commit is contained in:
@@ -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': """
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & 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' : ''">
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -100,11 +100,39 @@
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
<group>
|
||||
<group string="Part & 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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user