feat(numbering): wire CoC/RCV/DLV/PU into parent-numbered mixin + rename counters

Per-model counter fields on sale.order renamed to x_fc_pn_*_count
to avoid collision with pre-existing compute fields of the same
short name in bridge_mrp / receiving / configurator (silent
compute-override was suppressing the storage). 4 child models
(fp.certificate, fp.receiving, fusion.plating.delivery,
fusion.plating.pickup.request) now derive names as PFX-<parent>
with -NN suffix from the 2nd onward.

fusion.plating.pickup.request gains a sale_order_id field
(optional) so pickups created against an SO get parent-derived
names, while standalone pickups (pre-SO) fall back to PU/YYYY/NNNN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 13:30:37 -04:00
parent 765a0a4c82
commit 0d85063b5e
17 changed files with 489 additions and 64 deletions

View File

@@ -399,7 +399,7 @@ class FpJob(models.Model):
return 'WO'
def _fp_parent_counter_field(self):
return 'x_fc_wo_count'
return 'x_fc_pn_wo_count'
@api.model_create_multi
def create(self, vals_list):

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.5.5.0',
'version': '19.0.5.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -20,7 +20,7 @@ class FpCertificate(models.Model):
"""
_name = 'fp.certificate'
_description = 'Fusion Plating — Certificate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'issue_date desc, id desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
@@ -271,14 +271,22 @@ class FpCertificate(models.Model):
rec.trend_alert = alert
rec.trend_explanation = explanation
# ----- Sequence + spec-limit auto-fill ---------------------------------
# ----- Parent-numbered mixin hooks -------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'CoC'
def _fp_parent_counter_field(self):
return 'x_fc_pn_cert_count'
# ----- Create: parent-derived name (fallback to legacy sequence) -------
@api.model_create_multi
def create(self, vals_list):
SaleOrder = self.env['sale.order']
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
# Pull thickness spec limits from coating config if not set
# Spec-limit auto-fill (existing behaviour, preserved).
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
if not already_set and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id'])
@@ -286,7 +294,23 @@ class FpCertificate(models.Model):
if cfg and cfg.thickness_uom == 'mils':
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
return super().create(vals_list)
# Defer naming: let the record exist so the mixin can write
# name via raw SQL, then fall back to the legacy sequence if
# no parent SO is reachable.
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
self.env.cr.execute(
"UPDATE fp_certificate SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ----- State actions ----------------------------------------------------
def action_issue(self):

View File

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

View File

@@ -88,16 +88,30 @@ class FpRecordInputsController(http.Controller):
if node and node.description:
instructions_html = node.description
# Recipe root id — surfaced so the dialog's "Edit Recipe" shortcut
# opens the Simple Editor on the EXACT recipe variant this job is
# reading from. Avoids the trap where the operator edits a sibling
# variant (e.g. the template, while the job runs the part-specific
# clone) and wonders why their min/max never appears.
recipe_root_id = False
if node:
root = node
while root.parent_id:
root = root.parent_id
recipe_root_id = root.id
return {
'ok': True,
'step': {
'id': step.id,
'name': step.name,
'recipe_node_id': node.id if node else False,
},
'job': {
'id': step.job_id.id,
'name': step.job_id.name,
},
'recipe_root_id': recipe_root_id,
'prompts': prompts,
'user_initials': user_initials or '',
'instructions_html': instructions_html or '',

View File

@@ -51,7 +51,7 @@ class AccountMove(models.Model):
return 'CN' if self.move_type == 'out_refund' else 'IN'
def _fp_parent_counter_field(self):
return 'x_fc_cn_count' if self.move_type == 'out_refund' else 'x_fc_invoice_count'
return 'x_fc_pn_cn_count' if self.move_type == 'out_refund' else 'x_fc_pn_invoice_count'
# =================================================================
# Create override: block off-flow + assign parent-derived name

View File

@@ -17,6 +17,15 @@ class ResUsers(models.Model):
'a different value and saves, it persists here for every '
'future job and step.',
)
x_fc_signature_image = fields.Binary(
string='Plating Signature',
attachment=True,
help='Drawn or uploaded signature image. Used in WO detail and '
'certificate reports for any signature-type prompt this user '
'signed off on; falls back to typed initials when blank. '
'Capture it once in user preferences; it stamps every '
'future sign-off automatically.',
)
@api.model
def _fp_default_initials(self):

View File

@@ -55,17 +55,24 @@ class SaleOrder(models.Model):
# Per-model counters — monotonic, never decrement. Source of truth
# for the next sibling's x_fc_doc_index. Updated via row-locked SQL
# in fp.parent.numbered.mixin so concurrent creates can't drift.
x_fc_wo_count = fields.Integer(string='WO Count', readonly=True, copy=False, default=0)
x_fc_invoice_count = fields.Integer(string='Invoice Count', readonly=True, copy=False, default=0)
x_fc_cn_count = fields.Integer(string='Credit Note Count', readonly=True, copy=False, default=0)
x_fc_cert_count = fields.Integer(string='Certificate Count', readonly=True, copy=False, default=0)
x_fc_delivery_count = fields.Integer(string='Delivery Count', readonly=True, copy=False, default=0)
x_fc_receiving_count = fields.Integer(string='Receiving Count', readonly=True, copy=False, default=0)
x_fc_pickup_count = fields.Integer(string='Pickup Count', readonly=True, copy=False, default=0)
x_fc_ncr_count = fields.Integer(string='NCR Count', readonly=True, copy=False, default=0)
x_fc_capa_count = fields.Integer(string='CAPA Count', readonly=True, copy=False, default=0)
x_fc_hold_count = fields.Integer(string='Hold Count', readonly=True, copy=False, default=0)
x_fc_rma_count = fields.Integer(string='RMA Count', readonly=True, copy=False, default=0)
#
# Naming: `x_fc_pn_*_count` — the `pn_` infix distinguishes our
# storage counters from pre-existing compute fields (e.g. the
# `x_fc_delivery_count` compute in bridge_mrp, `x_fc_ncr_count`
# in configurator, `x_fc_receiving_count` in fp_receiving) which
# are surface counters for smart buttons. Distinct names avoid
# the silent compute-override that made Tasks 3+9 fail until 9.5.
x_fc_pn_wo_count = fields.Integer(string='Parent: WO Count', readonly=True, copy=False, default=0)
x_fc_pn_invoice_count = fields.Integer(string='Parent: Invoice Count', readonly=True, copy=False, default=0)
x_fc_pn_cn_count = fields.Integer(string='Parent: Credit Note Count', readonly=True, copy=False, default=0)
x_fc_pn_cert_count = fields.Integer(string='Parent: Certificate Count', readonly=True, copy=False, default=0)
x_fc_pn_delivery_count = fields.Integer(string='Parent: Delivery Count', readonly=True, copy=False, default=0)
x_fc_pn_receiving_count = fields.Integer(string='Parent: Receiving Count', readonly=True, copy=False, default=0)
x_fc_pn_pickup_count = fields.Integer(string='Parent: Pickup Count', readonly=True, copy=False, default=0)
x_fc_pn_ncr_count = fields.Integer(string='Parent: NCR Count', readonly=True, copy=False, default=0)
x_fc_pn_capa_count = fields.Integer(string='Parent: CAPA Count', readonly=True, copy=False, default=0)
x_fc_pn_hold_count = fields.Integer(string='Parent: Hold Count', readonly=True, copy=False, default=0)
x_fc_pn_rma_count = fields.Integer(string='Parent: RMA Count', readonly=True, copy=False, default=0)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
@@ -507,10 +514,10 @@ class SaleOrder(models.Model):
# WO additions pick up from here via the mixin standard path.
if parent and n_groups:
self.env.cr.execute(
"UPDATE sale_order SET x_fc_wo_count = %s WHERE id = %s",
"UPDATE sale_order SET x_fc_pn_wo_count = %s WHERE id = %s",
(n_groups, self.id),
)
self.invalidate_recordset(['x_fc_wo_count'])
self.invalidate_recordset(['x_fc_pn_wo_count'])
return True
# ------------------------------------------------------------------

View File

@@ -74,6 +74,27 @@
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
or ''"/>
<!-- Customer-facing WO id: strip the sequence prefix
("WH/JOB/01373" → "01373"). Keeps the column / cert
reference compact; full job.name is still used
internally and on the print_report_name. -->
<t t-set="short_wo" t-value="(job.name or '').split('/')[-1]"/>
<!-- Photo evidence — collect every captured-input value
that has an attachment, in step / time order. We
number them globally (Photo 1..N) and use those
numbers both in the per-step measurement tables
(so the customer can see at a glance "this prompt
has a photo, see #3 below") and as the gallery
titles at the end of the report. The dict carries
the cv record, its 1-based index, and pre-computed
caption fields so the gallery loop stays clean. -->
<t t-set="all_photo_values"
t-value="job.move_ids
.sorted('move_datetime')
.mapped('transition_input_value_ids')
.filtered(lambda v: v.value_attachment_id)"/>
<t t-set="photo_index_by_id" t-value="{cv.id: idx + 1 for idx, cv in enumerate(all_photo_values)}"/>
<!-- Walk EVERY step in sequence, not just moves. The
old report only rendered moves so steps without
recorded measurements (just Finish & Next) never
@@ -99,6 +120,52 @@
.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; }
/* Photo gallery — bordered tile per attachment.
flex-wrap so wkhtmltopdf lays out two per row
on A4 portrait; page-break-inside on the tile
keeps captions glued to their image. */
.fp-wo-detail .fp-photo-section { margin-top: 18px; }
.fp-wo-detail .fp-photo-section h2 {
font-size: 13pt; font-weight: bold; color: #1a4d80;
margin: 0 0 8px 0; border-bottom: 2px solid #1a4d80;
padding-bottom: 3px;
}
.fp-wo-detail .fp-photo-grid {
display: flex; flex-wrap: wrap; gap: 8px;
}
.fp-wo-detail .fp-photo-tile {
border: 1px solid #000; padding: 6px;
width: 86mm; box-sizing: border-box;
page-break-inside: avoid; background: #fff;
}
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap {
width: 100%; height: 70mm;
display: flex; align-items: center; justify-content: center;
background: #f5f5f5; border: 1px solid #d0d0d0;
overflow: hidden; margin-bottom: 4px;
}
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap img {
max-width: 100%; max-height: 100%; object-fit: contain;
}
.fp-wo-detail .fp-photo-title {
font-size: 9pt; font-weight: bold; margin: 2px 0;
}
.fp-wo-detail .fp-photo-desc {
font-size: 8pt; color: #444; line-height: 1.25;
}
.fp-wo-detail .fp-photo-ref {
font-size: 8pt; color: #1a4d80; font-style: italic;
white-space: nowrap;
}
/* Inline signature image inside the step
measurement Value cell — rendered when a
`signature` prompt has a recorder with a
Plating Signature on file. Sized to fit the
table row without blowing it up. */
.fp-wo-detail img.fp-sig-inline {
max-height: 14mm; max-width: 50mm;
vertical-align: middle;
}
</style>
<h1>Work Order Detail</h1>
@@ -152,7 +219,7 @@
<span t-esc="job.qty"/>
</td>
<td class="text-center">
<span t-esc="job.name"/>
<span t-esc="short_wo"/>
</td>
<td>
<span t-esc="po_number or '—'"/>
@@ -272,6 +339,17 @@
<t t-set="actual_str"
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
</t>
<!-- Signature-type prompts: show the
recorder's Plating Signature image in
the Value cell when available, with
typed initials as caption beneath.
Falls back to plain initials when the
user hasn't uploaded a signature yet. -->
<t t-set="is_sig_prompt"
t-value="inp and 'input_type' in inp._fields and inp.input_type == 'signature'"/>
<t t-set="sig_recorder" t-value="cv.move_id.moved_by_user_id"/>
<t t-set="sig_img"
t-value="(is_sig_prompt and sig_recorder and 'x_fc_signature_image' in sig_recorder._fields and sig_recorder.x_fc_signature_image) or False"/>
<tr>
<td><span t-esc="prompt_name"/></td>
<td>
@@ -280,7 +358,30 @@
</t>
</td>
<td>
<strong t-esc="actual_str"/>
<t t-if="sig_img">
<img class="fp-sig-inline"
t-att-src="'data:image/png;base64,%s' % sig_img.decode()"
t-att-alt="actual_str"/>
<t t-if="actual_str">
<br/>
<span style="font-size: 7.5pt; color: #555;" t-esc="actual_str"/>
</t>
</t>
<t t-else="">
<strong t-esc="actual_str"/>
</t>
<!-- Photo cross-reference. Operators
attached a photo for this prompt;
point the reader to the gallery
at the end of the doc. -->
<t t-if="cv.value_attachment_id">
<t t-set="_pidx" t-value="photo_index_by_id.get(cv.id)"/>
<br t-if="actual_str or sig_img"/>
<span class="fp-photo-ref">
<i class="fa fa-camera"/>
See Photo #<span t-esc="_pidx"/>
</span>
</t>
</td>
<td>
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
@@ -301,6 +402,68 @@
</p>
</t>
<!-- ===== PHOTO EVIDENCE GALLERY ===== -->
<!-- Renders every photo-type captured value as a
bordered tile with title (prompt + step) and
description (operator + timestamp + any
text caption they typed alongside the photo).
Numbered to match the "See Photo #N" inline
references above. Forced to its own page so
the tiles don't get split mid-step. -->
<t t-if="all_photo_values">
<div style="page-break-before: always;"/>
<div style="height: 6mm;"/>
<div class="fp-photo-section">
<h2>Photo Evidence (<span t-esc="len(all_photo_values)"/>)</h2>
<div class="fp-photo-grid">
<t t-foreach="all_photo_values" t-as="pv">
<t t-set="pidx" t-value="photo_index_by_id.get(pv.id)"/>
<t t-set="att" t-value="pv.value_attachment_id"/>
<t t-set="ptitle"
t-value="(pv.node_input_id and pv.node_input_id.name) or (pv.value_text and pv.value_text.split(':')[0]) or att.name or 'Photo'"/>
<t t-set="pstep"
t-value="(pv.move_id and ((pv.move_id.to_step_id and pv.move_id.to_step_id.name) or (pv.move_id.from_step_id and pv.move_id.from_step_id.name))) or ''"/>
<t t-set="puser"
t-value="(pv.move_id and pv.move_id.moved_by_user_id and pv.move_id.moved_by_user_id.name) or ''"/>
<t t-set="pdt"
t-value="pv.move_id and pv.move_id.move_datetime"/>
<!-- Caption: strip the leading "Prompt:"
prefix that ad-hoc rows store so we
don't print the prompt name twice. -->
<t t-set="pcaption" t-value="pv.value_text or ''"/>
<t t-if="pv.node_input_id and pv.node_input_id.name and pcaption.startswith(pv.node_input_id.name + ':')">
<t t-set="pcaption" t-value="pcaption[len(pv.node_input_id.name)+1:].strip()"/>
</t>
<div class="fp-photo-tile">
<div class="fp-photo-imgwrap">
<img t-att-src="'/web/image/%s' % att.id"
t-att-alt="att.name"/>
</div>
<div class="fp-photo-title">
Photo #<span t-esc="pidx"/><span t-esc="ptitle"/>
</div>
<div class="fp-photo-desc">
<t t-if="pstep">
<strong>Step:</strong> <span t-esc="pstep"/><br/>
</t>
<t t-if="puser or pdt">
<strong>Captured by:</strong>
<span t-esc="puser or '—'"/>
<t t-if="pdt">
on <span t-esc="job.fp_format_local(pdt, '%b %d, %Y %I:%M %p')"/>
</t>
<br/>
</t>
<t t-if="pcaption">
<strong>Note:</strong> <span t-esc="pcaption"/>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<!-- page-break-before is honoured by wkhtmltopdf
but the new page starts flush against the
@@ -310,21 +473,35 @@
<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">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
<!-- Certifier = the job's plating manager. Pulls
their Plating Signature (`x_fc_signature_image`)
from Preferences → My Profile. Falls back to
the company owner's signature, then to the
settings override only if no user has one. -->
<t t-set="certifier_user" t-value="job.manager_id or (('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id) or False)"/>
<t t-set="signature_img" t-value="False"/>
<t t-if="certifier_user and 'x_fc_signature_image' in certifier_user._fields and certifier_user.x_fc_signature_image">
<t t-set="signature_img" t-value="certifier_user.x_fc_signature_image"/>
</t>
<t t-set="sig_override" t-value="('x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override) or False"/>
<t t-set="signature_img" t-value="sig_override or owner_sig"/>
<t t-set="signer_name" t-value="(job.manager_id and job.manager_id.name) or ('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<!-- Final fallback: company-level override for sites
whose certifier hasn't uploaded their signature yet. -->
<t t-if="not signature_img and 'x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override">
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override"/>
</t>
<t t-set="signer_name" t-value="(certifier_user and certifier_user.name) or ''"/>
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>
<t t-set="_co_stmt" t-value="('x_fc_default_cert_statement' in company._fields and company.x_fc_default_cert_statement) or False"/>
<t t-set="cert_statement" t-value="_cust_stmt or _co_stmt or 'We certify that the parts listed above have been processed in accordance with the specifications referenced and that all required tests have been performed. Records on file at our facility per AS9100 / ISO 9001 retention policy.'"/>
<!-- External note auto-fills the Other Comments box so
anything the manager typed on the job ("subbed
out for fluoride dip", "customer pickup at 4pm")
prints on the customer-facing cert. Manager can
still scribble on the printed copy if nothing
was typed. -->
<t t-set="other_comments" t-value="('x_fc_external_note' in job._fields and job.x_fc_external_note) or ''"/>
<table class="bordered">
<tr>
<td style="width: 50%; vertical-align: top; height: 40mm;">
@@ -338,7 +515,7 @@
<td style="width: 50%; vertical-align: top;">
<strong>Certification Statement:</strong>
<span style="font-size: 8.5pt;">
Ref. WO# <span t-esc="job.name"/>
Ref. WO# <span t-esc="short_wo"/>
</span>
<p style="font-size: 8pt; margin-top: 4px; white-space: pre-wrap;"
t-esc="cert_statement"/>
@@ -347,6 +524,9 @@
<tr>
<td colspan="2" style="height: 25mm;">
<strong>Other Comments:</strong>
<p t-if="other_comments"
style="font-size: 9pt; margin-top: 4px; white-space: pre-wrap;"
t-esc="other_comments"/>
</td>
</tr>
</table>

View File

@@ -69,6 +69,7 @@ export class FpRecordInputsDialog extends Component {
saving: false,
stepName: "",
jobName: "",
recipeRootId: false,
rows: [],
// Operator's persisted initials — pre-filled into signature
// / "Reviewer Initials" prompts on load. When the operator
@@ -103,6 +104,7 @@ export class FpRecordInputsDialog extends Component {
}
this.state.stepName = data.step.name;
this.state.jobName = data.job.name;
this.state.recipeRootId = data.recipe_root_id || false;
this.state.userInitials = data.user_initials || "";
this.state.instructionsHtml = data.instructions_html || "";
this.state.instructionImages = data.instruction_images || [];
@@ -193,13 +195,14 @@ export class FpRecordInputsDialog extends Component {
isSelection(row) { return row.input_type === "selection"; }
isPassFail(row) { return row.input_type === "pass_fail"; }
isSignature(row) { return row.input_type === "signature"; }
// Fallback to text for anything else (text, time_hms, ...)
isTimeHms(row) { return row.input_type === "time_hms"; }
// Fallback to text for anything else
isText(row) {
return !this.isNumeric(row) && !this.isBoolean(row)
&& !this.isDate(row) && !this.isPhoto(row)
&& !this.isMulti(row) && !this.isPanel(row)
&& !this.isSelection(row) && !this.isPassFail(row)
&& !this.isSignature(row);
&& !this.isSignature(row) && !this.isTimeHms(row);
}
// Friendly label for the type pill — defaults to the raw key when no
@@ -208,6 +211,60 @@ export class FpRecordInputsDialog extends Component {
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
}
// Step granularity for <input type="number"> — drives the up/down
// arrow increment AND the typed-decimal validity. Defaults of step=1
// make tablet entry painful when the spec is 0.03 0.05 mil because
// every arrow press jumps a full unit. Derive from the recipe-author's
// target_min / target_max precision so operator arrow-taps move in the
// same decimal magnitude the spec was written in. Falls back to
// input-type defaults when no targets are set.
stepFor(row) {
const decimals = Math.max(
this._fpCountDecimals(row.target_min),
this._fpCountDecimals(row.target_max),
);
if (decimals > 0) {
return Math.pow(10, -decimals).toFixed(decimals);
}
const t = row.input_type || "";
if (t === "thickness" || t === "multi_point_thickness") return "0.0001";
if (t === "ph") return "0.01";
if (t === "temperature" || t === "time_seconds") return "1";
return "any";
}
_fpCountDecimals(n) {
if (n === null || n === undefined || n === "" || n === 0) return 0;
const s = String(n);
const idx = s.indexOf(".");
if (idx < 0) return 0;
// Trim trailing zeros so "0.0500" doesn't look like 4-decimals
// when the author actually wrote 2-decimal precision.
return s.slice(idx + 1).replace(/0+$/, "").length;
}
// Jump from the runtime dialog into the Simple Recipe Editor on the
// EXACT recipe variant this job step is bound to. Closes the dialog
// (operator returns by re-opening Record Inputs after editing). The
// intent is to remove the "I edited the recipe but nothing changed"
// confusion — they were editing a sibling variant.
async openSimpleEditor() {
if (!this.state.recipeRootId) {
this.notification.add(
_t("No recipe linked to this step yet."),
{ type: "warning" },
);
return;
}
this.props.close();
await this.action.doAction({
type: "ir.actions.client",
tag: "fp_simple_recipe_editor",
name: _t("Edit Recipe"),
context: { recipe_id: this.state.recipeRootId },
});
}
// True when the recipe author defined BOTH target_min and target_max
// on the prompt — the signal that the operator is expected to capture
// a range (multiple readings → record their min and max observation).

View File

@@ -11,6 +11,12 @@
Job <t t-esc="state.jobName"/>
</span>
</div>
<button t-if="state.recipeRootId"
class="btn btn-link o_fp_ri_edit_recipe"
title="Edit this step's prompts (target ranges, type, options) in the Simple Recipe Editor."
t-on-click="openSimpleEditor">
<i class="fa fa-pencil me-1"/> Edit Recipe
</button>
</div>
</t>
@@ -116,7 +122,7 @@
class="o_fp_ri_numeric">
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_number"
t-att-placeholder="row.target_min or '0.00'"/>
<t t-set="hint" t-value="rangeHint(row)"/>
@@ -136,7 +142,7 @@
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
@@ -144,7 +150,7 @@
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
@@ -167,7 +173,7 @@
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
@@ -175,7 +181,7 @@
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
@@ -301,19 +307,19 @@
<div t-if="isMulti(row)" class="o_fp_ri_multi">
<div class="o_fp_ri_multi_grid">
<label>R1
<input type="number" step="any" t-model.number="row.point_1"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_1"/>
</label>
<label>R2
<input type="number" step="any" t-model.number="row.point_2"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_2"/>
</label>
<label>R3
<input type="number" step="any" t-model.number="row.point_3"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_3"/>
</label>
<label>R4
<input type="number" step="any" t-model.number="row.point_4"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_4"/>
</label>
<label>R5
<input type="number" step="any" t-model.number="row.point_5"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_5"/>
</label>
<div class="o_fp_ri_multi_avg">
<span class="text-muted">Avg</span>
@@ -325,20 +331,28 @@
<!-- Bath chemistry panel — pH / conc / temp / bath -->
<div t-if="isPanel(row)" class="o_fp_ri_panel">
<label>pH
<input type="number" step="any" t-model.number="row.panel_ph"/>
<input type="number" step="0.01" t-model.number="row.panel_ph"/>
</label>
<label>Concentration
<input type="number" step="any" t-model.number="row.panel_concentration"/>
<input type="number" step="0.1" t-model.number="row.panel_concentration"/>
</label>
<label>Temperature
<input type="number" step="any" t-model.number="row.panel_temperature"/>
<input type="number" step="1" t-model.number="row.panel_temperature"/>
</label>
<label>Bath ID
<input type="text" t-model="row.panel_bath_id"/>
</label>
</div>
<!-- Text fallback (text, signature, time_hms, anything else) -->
<!-- Time (HH:MM:SS) — native time picker with seconds.
Mobile/tablet browsers surface the OS time wheel. -->
<input t-if="isTimeHms(row)"
type="time"
step="1"
class="o_fp_ri_input o_fp_ri_input_text"
t-model="row.value_text"/>
<!-- Text fallback (text, signature, anything else) -->
<input t-if="isText(row)"
type="text"
class="o_fp_ri_input o_fp_ri_input_text"

View File

@@ -288,6 +288,25 @@
<field name="x_fc_ship_via"/>
<field name="x_fc_invoice_strategy"/>
</xpath>
<xpath expr="//group[@name='x_fc_customer_refs']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- Notes group sits awkwardly above the main fields in core; relocate
to a notebook tab so the form opens on the operationally relevant
fields (customer / part / steps) instead of empty note placeholders. -->
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//page[@name='costs']" position="before">
<page string="Notes" name="notes">
<group>
<field name="x_fc_internal_note" nolabel="1"
placeholder="Internal note (not shown to customer)…"/>
<field name="x_fc_external_note" nolabel="1"
placeholder="External note (printed on customer paperwork)…"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.5.0',
'version': '19.0.3.6.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -24,14 +24,14 @@ class FpDelivery(models.Model):
"""
_name = 'fusion.plating.delivery'
_description = 'Fusion Plating — Delivery'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'scheduled_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
default=lambda self: self._default_name(),
default='New',
tracking=True,
)
partner_id = fields.Many2one(
@@ -159,8 +159,49 @@ class FpDelivery(models.Model):
compute='_compute_custody_count',
)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
"""No direct sale_order_id on this model — resolve via
job_ref → fp.job.name → job.sale_order_id."""
if not self.job_ref or 'fp.job' not in self.env:
return self.env['sale.order']
job = self.env['fp.job'].sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
return job.sale_order_id if job else self.env['sale.order']
def _fp_name_prefix(self):
return 'DLV'
def _fp_parent_counter_field(self):
return 'x_fc_pn_delivery_count'
@api.model_create_multi
def create(self, vals_list):
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence
fallback for deliveries that don't link back to an SO."""
for vals in vals_list:
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_delivery SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.model
def _default_name(self):
"""Retained for any legacy caller. New code should rely on
create() — the parent-numbered mixin sets the name there."""
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery')
return seq or '/'

View File

@@ -21,16 +21,26 @@ class FpPickupRequest(models.Model):
"""
_name = 'fusion.plating.pickup.request'
_description = 'Fusion Plating — Pickup Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'requested_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
default=lambda self: self._default_name(),
default='New',
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='set null',
index=True,
help='Sale order this pickup is associated with. Pickup may be '
'created BEFORE the SO exists; in that case the '
'parent-number naming falls back to the standalone '
'PU/YYYY/NNNN sequence and the link can be set later.',
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
@@ -126,8 +136,39 @@ class FpPickupRequest(models.Model):
compute='_compute_custody_count',
)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'PU'
def _fp_parent_counter_field(self):
return 'x_fc_pn_pickup_count'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.pickup.request') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_pickup_request SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.model
def _default_name(self):
"""Retained for legacy callers; new flow uses the create() override."""
seq = self.env['ir.sequence'].next_by_code('fusion.plating.pickup.request')
return seq or '/'

View File

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

View File

@@ -16,7 +16,7 @@ class FpReceiving(models.Model):
"""
_name = 'fp.receiving'
_description = 'Fusion Plating — Receiving'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'received_date desc, id desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
@@ -97,19 +97,38 @@ class FpReceiving(models.Model):
rec.unresolved_damage_count = len(rec.damage_ids.filtered(lambda d: not d.resolved))
# -------------------------------------------------------------------------
# Sequence
# Sequence + parent-derived naming
# -------------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'RCV'
def _fp_parent_counter_field(self):
return 'x_fc_pn_receiving_count'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
# Prefill received_qty from expected_qty so the operator only
# types when the count is wrong (the common case is "all
# arrived"). Saves a step on every routine receipt.
# types when the count is wrong.
if vals.get('expected_qty') and not vals.get('received_qty'):
vals['received_qty'] = vals['expected_qty']
return super().create(vals_list)
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
self.env.cr.execute(
"UPDATE fp_receiving SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# -------------------------------------------------------------------------
# Sub 8 — box-count-only actions (new primary flow)