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' : ''">
|
||||
|
||||
Reference in New Issue
Block a user