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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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