Compare commits
21 Commits
13fd0712d9
...
191a9c82be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
191a9c82be | ||
|
|
00981a502a | ||
|
|
d75198be9f | ||
|
|
d009a1ef50 | ||
|
|
9001b6fc51 | ||
|
|
a24ef15a02 | ||
|
|
7fdab094fc | ||
|
|
c2646f59c4 | ||
|
|
152ed86c3a | ||
|
|
21754c1660 | ||
|
|
145b424760 | ||
|
|
a68bf2eae7 | ||
|
|
bc7c771f20 | ||
|
|
1ed414c6fb | ||
|
|
7d27db69c6 | ||
|
|
d891002c84 | ||
|
|
e0eacc2530 | ||
|
|
c637f82ae2 | ||
|
|
7cafab1b9f | ||
|
|
c96f27b96c | ||
|
|
406cac1362 |
@@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
from . import wizard
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.0.0',
|
'version': '19.0.3.3.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -70,6 +70,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'views/clock_correction_views.xml',
|
'views/clock_correction_views.xml',
|
||||||
'views/clock_dashboard_views.xml',
|
'views/clock_dashboard_views.xml',
|
||||||
'views/hr_employee_views.xml',
|
'views/hr_employee_views.xml',
|
||||||
|
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||||
|
'wizard/clock_nfc_enrollment_views.xml',
|
||||||
'views/clock_menus.xml',
|
'views/clock_menus.xml',
|
||||||
# Views - Portal
|
# Views - Portal
|
||||||
'views/portal_clock_templates.xml',
|
'views/portal_clock_templates.xml',
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
|
|||||||
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
||||||
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
||||||
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
||||||
|
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -414,6 +414,127 @@
|
|||||||
debugLog("startNfcReader: listeners attached, nfcReady=true");
|
debugLog("startNfcReader: listeners attached, nfcReady=true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// USB HID keyboard-wedge listener (works alongside Web NFC).
|
||||||
|
// Most USB NFC readers in HID mode type the UID as keystrokes and
|
||||||
|
// end with Enter. We buffer chars until Enter arrives (or 500ms
|
||||||
|
// pause), then route the UID through the same flow Web NFC uses.
|
||||||
|
//
|
||||||
|
// Critical: this listener fires the same handleTap()/_onEnrollTap()
|
||||||
|
// codepath as Web NFC, so penalty + photo + activity log all work
|
||||||
|
// identically regardless of which reader produced the UID.
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
let _hidBuffer = "";
|
||||||
|
let _hidLastKeyAt = 0;
|
||||||
|
let _hidFlushTimer = null;
|
||||||
|
const HID_RESET_MS = 500; // pause longer than this resets the buffer
|
||||||
|
const HID_FLUSH_MS = 600; // if no Enter arrives, flush this long after last char
|
||||||
|
const HID_MIN_LEN = 4; // shortest plausible UID
|
||||||
|
const HID_CHAR_RE = /^[0-9A-Fa-f:\-]$/; // hex digits + common separators
|
||||||
|
|
||||||
|
function _flushHidBuffer() {
|
||||||
|
const uid = _hidBuffer.trim().toUpperCase();
|
||||||
|
_hidBuffer = "";
|
||||||
|
if (_hidFlushTimer) { clearTimeout(_hidFlushTimer); _hidFlushTimer = null; }
|
||||||
|
if (uid.length < HID_MIN_LEN) {
|
||||||
|
debugLog("HID flush: too short, ignored (" + JSON.stringify(uid) + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugLog("HID flush: uid=" + uid + " state=" + currentState);
|
||||||
|
if (currentState === STATE.ENROLL) {
|
||||||
|
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
|
||||||
|
} else if (currentState === STATE.IDLE) {
|
||||||
|
handleTap(uid);
|
||||||
|
} else {
|
||||||
|
debugLog(" → IGNORED: state=" + currentState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Local wedge daemon SSE listener.
|
||||||
|
//
|
||||||
|
// If a `wedge.py` daemon is running on this machine (used for
|
||||||
|
// ACR122U / PC/SC readers that can't emit keystrokes themselves),
|
||||||
|
// it exposes a Server-Sent Events stream at
|
||||||
|
// http://localhost:8765/events that pushes each detected UID.
|
||||||
|
//
|
||||||
|
// Chrome treats http://localhost as a secure origin, so an HTTPS
|
||||||
|
// kiosk page can connect to it without mixed-content blocking.
|
||||||
|
// No keystroke injection, no Accessibility permission needed,
|
||||||
|
// no focused-window dependency.
|
||||||
|
//
|
||||||
|
// Routes the UID through the same handleTap()/_onEnrollTap() flow
|
||||||
|
// as Web NFC and USB HID — so photo, penalty, activity log all
|
||||||
|
// fire identically.
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
const WEDGE_SSE_URL = "http://localhost:8765/events";
|
||||||
|
let _wedgeEs = null;
|
||||||
|
|
||||||
|
function startWedgeSseListener() {
|
||||||
|
try {
|
||||||
|
_wedgeEs = new EventSource(WEDGE_SSE_URL);
|
||||||
|
_wedgeEs.addEventListener("message", (ev) => {
|
||||||
|
const uid = (ev.data || "").trim().toUpperCase();
|
||||||
|
if (!uid) return;
|
||||||
|
debugLog("wedge SSE: " + uid + " state=" + currentState);
|
||||||
|
if (currentState === STATE.ENROLL) {
|
||||||
|
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap &&
|
||||||
|
window.__nfcKiosk._onEnrollTap(uid);
|
||||||
|
} else if (currentState === STATE.IDLE) {
|
||||||
|
handleTap(uid);
|
||||||
|
} else {
|
||||||
|
debugLog(" → IGNORED: state=" + currentState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_wedgeEs.addEventListener("open", () => {
|
||||||
|
debugLog("wedge SSE: connected to " + WEDGE_SSE_URL);
|
||||||
|
});
|
||||||
|
_wedgeEs.addEventListener("error", () => {
|
||||||
|
// EventSource auto-reconnects; this fires on every
|
||||||
|
// dropped connection. Log first occurrence only.
|
||||||
|
if (!_wedgeEs._loggedError) {
|
||||||
|
debugLog("wedge SSE: connection error (daemon may not be running) — will auto-retry");
|
||||||
|
_wedgeEs._loggedError = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
debugLog("startWedgeSseListener: subscribed to " + WEDGE_SSE_URL);
|
||||||
|
} catch (e) {
|
||||||
|
debugLog("startWedgeSseListener: failed to start — " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUsbHidListener() {
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
// Don't capture keystrokes inside form inputs — preserves
|
||||||
|
// typing in enroll-mode search box, etc.
|
||||||
|
const t = e.target;
|
||||||
|
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't fight the existing Ctrl+Shift+T mock-tap shortcut.
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _hidLastKeyAt > HID_RESET_MS) {
|
||||||
|
_hidBuffer = "";
|
||||||
|
}
|
||||||
|
_hidLastKeyAt = now;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
_flushHidBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (HID_CHAR_RE.test(e.key)) {
|
||||||
|
_hidBuffer += e.key;
|
||||||
|
// Fallback flush if the reader doesn't emit Enter
|
||||||
|
if (_hidFlushTimer) clearTimeout(_hidFlushTimer);
|
||||||
|
_hidFlushTimer = setTimeout(_flushHidBuffer, HID_FLUSH_MS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
debugLog("startUsbHidListener: listening for HID keystrokes ✓");
|
||||||
|
}
|
||||||
|
|
||||||
function onNfcReading(event) {
|
function onNfcReading(event) {
|
||||||
// event.serialNumber is the card UID — works for raw MIFARE access cards
|
// event.serialNumber is the card UID — works for raw MIFARE access cards
|
||||||
const rawSerial = event.serialNumber || "";
|
const rawSerial = event.serialNumber || "";
|
||||||
@@ -547,27 +668,44 @@
|
|||||||
if (setupBtn) {
|
if (setupBtn) {
|
||||||
setupBtn.addEventListener("click", async () => {
|
setupBtn.addEventListener("click", async () => {
|
||||||
debugLog("setup button clicked");
|
debugLog("setup button clicked");
|
||||||
|
// Try Web NFC, but don't fail if absent — USB HID reader is a
|
||||||
|
// first-class alternative (works on desktops/iOS too).
|
||||||
|
let webNfcOk = false;
|
||||||
try {
|
try {
|
||||||
await startNfcReader();
|
await startNfcReader();
|
||||||
debugLog("setup: NFC ready, starting camera...");
|
webNfcOk = true;
|
||||||
try {
|
debugLog("setup: Web NFC ready ✓");
|
||||||
await startCamera();
|
} catch (webNfcErr) {
|
||||||
debugLog("setup: camera ready ✓");
|
debugLog("setup: Web NFC unavailable, continuing with USB HID — " + webNfcErr.message);
|
||||||
} catch (camErr) {
|
|
||||||
debugLog("setup: camera failed: " + camErr.message);
|
|
||||||
if (photoRequired) throw camErr;
|
|
||||||
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
|
||||||
}
|
|
||||||
await acquireWakeLock();
|
|
||||||
setState(STATE.IDLE);
|
|
||||||
} catch (e) {
|
|
||||||
stateContainer.innerHTML = `
|
|
||||||
<div class="nfc-kiosk__setup">
|
|
||||||
<h2 style="color:#d9374e">Setup failed</h2>
|
|
||||||
<p>${escapeHtml(e.message)}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
// USB HID listener: no permission needed, works on any platform.
|
||||||
|
startUsbHidListener();
|
||||||
|
// Local wedge daemon SSE listener (for ACR122U / PC/SC readers).
|
||||||
|
startWedgeSseListener();
|
||||||
|
// Camera: best-effort unless photoRequired forces it.
|
||||||
|
try {
|
||||||
|
await startCamera();
|
||||||
|
debugLog("setup: camera ready ✓");
|
||||||
|
} catch (camErr) {
|
||||||
|
debugLog("setup: camera failed: " + camErr.message);
|
||||||
|
if (photoRequired) {
|
||||||
|
// Only THIS path is a hard fail. Use the existing error
|
||||||
|
// render to keep DOM patterns consistent with the rest
|
||||||
|
// of this file.
|
||||||
|
stateContainer.innerHTML = `
|
||||||
|
<div class="nfc-kiosk__setup">
|
||||||
|
<h2 style="color:#d9374e">Setup failed</h2>
|
||||||
|
<p>${escapeHtml(camErr.message)}</p>
|
||||||
|
<p style="opacity:.7;font-size:.9em">Camera is required but unavailable. Either plug in a webcam, or disable "Photo Required" in Fusion Clock settings.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
||||||
|
}
|
||||||
|
await acquireWakeLock();
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,14 @@
|
|||||||
sequence="50"
|
sequence="50"
|
||||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||||
|
|
||||||
|
<!-- NFC Card Enrollment Wizard -->
|
||||||
|
<menuitem id="menu_fusion_clock_nfc_enrollment"
|
||||||
|
name="Enroll NFC Card"
|
||||||
|
parent="menu_fusion_clock_root"
|
||||||
|
action="action_fusion_clock_nfc_enrollment_wizard"
|
||||||
|
sequence="55"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
<!-- Configuration Sub-Menu -->
|
<!-- Configuration Sub-Menu -->
|
||||||
<menuitem id="menu_fusion_clock_config"
|
<menuitem id="menu_fusion_clock_config"
|
||||||
name="Configuration"
|
name="Configuration"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
<field name="x_fclk_break_minutes"/>
|
<field name="x_fclk_break_minutes"/>
|
||||||
<field name="x_fclk_kiosk_pin" password="True"
|
<field name="x_fclk_kiosk_pin" password="True"
|
||||||
groups="fusion_clock.group_fusion_clock_manager"/>
|
groups="fusion_clock.group_fusion_clock_manager"/>
|
||||||
|
<field name="x_fclk_nfc_card_uid"
|
||||||
|
placeholder="Tap card on USB reader, or paste UID"
|
||||||
|
groups="fusion_clock.group_fusion_clock_manager"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Status">
|
<group string="Status">
|
||||||
<field name="x_fclk_ontime_streak"/>
|
<field name="x_fclk_ontime_streak"/>
|
||||||
|
|||||||
5
fusion_clock/wizard/__init__.py
Normal file
5
fusion_clock/wizard/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import clock_nfc_enrollment_wizard
|
||||||
61
fusion_clock/wizard/clock_nfc_enrollment_views.xml
Normal file
61
fusion_clock/wizard/clock_nfc_enrollment_views.xml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Enrollment Wizard Form -->
|
||||||
|
<record id="view_fusion_clock_nfc_enrollment_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.nfc.enrollment.wizard.form</field>
|
||||||
|
<field name="model">fusion.clock.nfc.enrollment.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Enroll NFC Card">
|
||||||
|
<sheet>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<strong>How to enroll:</strong> Tap an NFC card on the USB reader connected
|
||||||
|
to this computer. The reader will type the UID into the field below.
|
||||||
|
Then select the employee and click <b>Enroll Card</b> (or
|
||||||
|
<b>Enroll & Next</b> to keep enrolling).
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="card_uid"
|
||||||
|
placeholder="Tap card on reader, or paste UID manually"/>
|
||||||
|
<field name="normalized_uid"
|
||||||
|
invisible="not normalized_uid"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="warning_message"
|
||||||
|
invisible="not warning_message"
|
||||||
|
readonly="1"
|
||||||
|
nolabel="1"
|
||||||
|
colspan="2"/>
|
||||||
|
<field name="existing_employee_id" invisible="1"/>
|
||||||
|
<field name="employee_id"
|
||||||
|
options="{'no_create': True, 'no_create_edit': True}"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_enroll"
|
||||||
|
string="Enroll Card"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="not normalized_uid or not employee_id"/>
|
||||||
|
<button name="action_enroll_and_next"
|
||||||
|
string="Enroll & Next"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
invisible="not normalized_uid or not employee_id"/>
|
||||||
|
<button special="cancel"
|
||||||
|
string="Cancel"
|
||||||
|
class="btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action to open the wizard -->
|
||||||
|
<record id="action_fusion_clock_nfc_enrollment_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Enroll NFC Card</field>
|
||||||
|
<field name="res_model">fusion.clock.nfc.enrollment.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="view_id" ref="view_fusion_clock_nfc_enrollment_wizard_form"/>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
156
fusion_clock/wizard/clock_nfc_enrollment_wizard.py
Normal file
156
fusion_clock/wizard/clock_nfc_enrollment_wizard.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
from ..controllers.clock_nfc_kiosk import FusionClockNfcKiosk
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockNfcEnrollmentWizard(models.TransientModel):
|
||||||
|
"""Tap-driven NFC card enrollment for the fusion_clock kiosk.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Manager opens this wizard.
|
||||||
|
2. Card UID field is auto-focused.
|
||||||
|
3. Manager taps an NFC card on the USB HID reader; the reader types the
|
||||||
|
UID into the focused field and hits Enter, which advances focus to
|
||||||
|
the Employee picker.
|
||||||
|
4. Manager selects the employee.
|
||||||
|
5. Manager clicks "Enroll Card" (closes wizard) or "Enroll & Next"
|
||||||
|
(resets wizard for the next card).
|
||||||
|
|
||||||
|
The wizard reuses ``FusionClockNfcKiosk._normalize_uid`` so the stored
|
||||||
|
format matches whatever the kiosk's ``/tap`` endpoint will look up later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = 'fusion.clock.nfc.enrollment.wizard'
|
||||||
|
_description = 'NFC Card Enrollment Wizard'
|
||||||
|
|
||||||
|
card_uid = fields.Char(
|
||||||
|
string='Card UID',
|
||||||
|
required=True,
|
||||||
|
help='Tap an NFC card on the USB reader. Most HID readers type the '
|
||||||
|
'UID followed by Enter, which advances focus to the Employee '
|
||||||
|
'field below. You can also paste a UID manually.',
|
||||||
|
)
|
||||||
|
normalized_uid = fields.Char(
|
||||||
|
string='Normalized UID',
|
||||||
|
compute='_compute_normalized_uid',
|
||||||
|
store=False,
|
||||||
|
help='UID after format normalization (uppercase, colon-separated hex). '
|
||||||
|
'This is what gets stored on the employee record.',
|
||||||
|
)
|
||||||
|
employee_id = fields.Many2one(
|
||||||
|
'hr.employee',
|
||||||
|
string='Employee',
|
||||||
|
domain=[('x_fclk_enable_clock', '=', True)],
|
||||||
|
)
|
||||||
|
existing_employee_id = fields.Many2one(
|
||||||
|
'hr.employee',
|
||||||
|
string='Currently Assigned To',
|
||||||
|
compute='_compute_existing_employee',
|
||||||
|
store=False,
|
||||||
|
)
|
||||||
|
warning_message = fields.Char(
|
||||||
|
compute='_compute_existing_employee',
|
||||||
|
store=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('card_uid')
|
||||||
|
def _compute_normalized_uid(self):
|
||||||
|
for wiz in self:
|
||||||
|
wiz.normalized_uid = FusionClockNfcKiosk._normalize_uid(wiz.card_uid) or ''
|
||||||
|
|
||||||
|
@api.depends('normalized_uid', 'employee_id')
|
||||||
|
def _compute_existing_employee(self):
|
||||||
|
for wiz in self:
|
||||||
|
if not wiz.normalized_uid:
|
||||||
|
wiz.existing_employee_id = False
|
||||||
|
wiz.warning_message = ''
|
||||||
|
continue
|
||||||
|
existing = self.env['hr.employee'].sudo().search([
|
||||||
|
('x_fclk_nfc_card_uid', '=', wiz.normalized_uid),
|
||||||
|
], limit=1)
|
||||||
|
if existing and existing != wiz.employee_id:
|
||||||
|
wiz.existing_employee_id = existing
|
||||||
|
wiz.warning_message = _(
|
||||||
|
"⚠ This card is currently assigned to %(name)s. "
|
||||||
|
"Enrolling will reassign it.",
|
||||||
|
name=existing.name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wiz.existing_employee_id = False
|
||||||
|
wiz.warning_message = ''
|
||||||
|
|
||||||
|
def action_enroll(self):
|
||||||
|
"""Enroll the card to the selected employee and close the wizard."""
|
||||||
|
self.ensure_one()
|
||||||
|
self._do_enroll()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Card Enrolled'),
|
||||||
|
'message': _("%(uid)s assigned to %(name)s.",
|
||||||
|
uid=self.normalized_uid, name=self.employee_id.name),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
'next': {'type': 'ir.actions.act_window_close'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_enroll_and_next(self):
|
||||||
|
"""Enroll the card, then reopen the wizard cleared for the next card."""
|
||||||
|
self.ensure_one()
|
||||||
|
self._do_enroll()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Enroll NFC Card'),
|
||||||
|
'res_model': self._name,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {}, # explicitly empty so no defaults persist
|
||||||
|
}
|
||||||
|
|
||||||
|
def _do_enroll(self):
|
||||||
|
"""Validate, then write the normalized UID to the employee record.
|
||||||
|
|
||||||
|
Reassigns the card from any existing holder. Logs the event in the
|
||||||
|
activity log for audit.
|
||||||
|
"""
|
||||||
|
if not self.normalized_uid:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Card UID is empty or not valid hex. Tap the card again on "
|
||||||
|
"the reader."
|
||||||
|
))
|
||||||
|
if not self.employee_id:
|
||||||
|
raise ValidationError(_("Please select an employee."))
|
||||||
|
|
||||||
|
# Reassignment: clear the UID from whoever currently holds it
|
||||||
|
if self.existing_employee_id and self.existing_employee_id != self.employee_id:
|
||||||
|
self.existing_employee_id.sudo().x_fclk_nfc_card_uid = False
|
||||||
|
self.env['fusion.clock.activity.log'].sudo().create({
|
||||||
|
'employee_id': self.existing_employee_id.id,
|
||||||
|
'log_type': 'card_enrollment',
|
||||||
|
'description': _(
|
||||||
|
"NFC card %(uid)s unassigned by %(user)s (reassigning to %(new)s)",
|
||||||
|
uid=self.normalized_uid,
|
||||||
|
user=self.env.user.name,
|
||||||
|
new=self.employee_id.name,
|
||||||
|
),
|
||||||
|
'source': 'nfc_kiosk',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.employee_id.sudo().x_fclk_nfc_card_uid = self.normalized_uid
|
||||||
|
|
||||||
|
self.env['fusion.clock.activity.log'].sudo().create({
|
||||||
|
'employee_id': self.employee_id.id,
|
||||||
|
'log_type': 'card_enrollment',
|
||||||
|
'description': _(
|
||||||
|
"NFC card %(uid)s enrolled by %(user)s",
|
||||||
|
uid=self.normalized_uid, user=self.env.user.name,
|
||||||
|
),
|
||||||
|
'source': 'nfc_kiosk',
|
||||||
|
})
|
||||||
@@ -136,7 +136,20 @@ Rejected alternatives:
|
|||||||
- "Customer Spec" — fine but slightly off when the spec is a public industry standard
|
- "Customer Spec" — fine but slightly off when the spec is a public industry standard
|
||||||
- "Treatment" / "Coating Configuration" — what we're explicitly removing
|
- "Treatment" / "Coating Configuration" — what we're explicitly removing
|
||||||
|
|
||||||
### Decision 6 — Recipe ↔ Specification relationship
|
### Decision 6.5 — NADCAP recipe lock (added 2026-05-15 from client review)
|
||||||
|
|
||||||
|
After client validation of the design, ENPlating raised: "For NADCAP recipes, once it's in the system and we check it, only a manager profile should be able to change." Added to scope.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- New field `fusion.plating.process.node.is_locked` Boolean (recipe root, but checked on all descendants via `recipe_root_id`)
|
||||||
|
- `write()` override blocks modifications by non-manager users when the recipe root has `is_locked=True`
|
||||||
|
- Manager bypass via `env.user.has_group('fusion_plating.group_fusion_plating_manager')` so the lock can be toggled off + edits made
|
||||||
|
- `env.su` (sudo) also bypasses (for migrations / system jobs)
|
||||||
|
- View: amber "LOCKED — Manager Edit Only" ribbon at top of recipe form when locked; `is_locked` toggle on the Specification & Bake page under "Change Control (NADCAP)" group
|
||||||
|
|
||||||
|
The Word-doc external approval workflow (REV 0, REV 1 in filenames on Engineering Drive) lives outside the ERP. The lock is the ERP-side enforcement point that prevents accidental in-app edits between approval cycles.
|
||||||
|
|
||||||
|
### Decision 7 — Recipe ↔ Specification relationship
|
||||||
|
|
||||||
Many-to-many. One spec applies to multiple recipes; one recipe can satisfy multiple specs.
|
Many-to-many. One spec applies to multiple recipes; one recipe can satisfy multiple specs.
|
||||||
|
|
||||||
@@ -434,6 +447,20 @@ These are nice-to-haves; the design proceeds without their answers but the answe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Backlog from client review (2026-05-15) — separate sub-projects
|
||||||
|
|
||||||
|
These surfaced from the client's scenario walkthrough but are NOT part of this refactor. Tracked here so they aren't forgotten.
|
||||||
|
|
||||||
|
1. **Customer Approvals List** (Compliance → Aerospace → Approvals List menu) — small new model `fp.customer.approval` tracking which customer specs the shop is source-approved for, with approval letter PDF, effective date, expiry date. Filterable by prime (Boeing/Lockheed/etc.). Driven by client S4 answer: "Can we maintain a list of approvals under Compliance > Aerospace (AS9100/NADCAP) > APPROVALS LIST?"
|
||||||
|
|
||||||
|
2. **Document Control auto-sync** — every customer-facing artifact (PO, packing slip, invoice, certificate, photos) auto-saves to a doc control folder (Engineering Drive / SharePoint / OneDrive). Major Documents-integration project. Driven by client S6: "I need the ERP to download all the files... to our DOC control folder."
|
||||||
|
|
||||||
|
3. **Oven recorder data sync** — pull chart-recorder data from the bake oven into the ERP and attach to the relevant job. IoT / hardware-integration project, lives in `fusion_iot` family. Driven by client S6: "How can we sync the oven recorders with the ERP?"
|
||||||
|
|
||||||
|
4. **Recipe SOP Word-doc workflow polish** — recipes already accept attachments via `mail.thread`. Add a prominent "Current Approved SOP" attachment slot on the recipe form, with revision history visible. Driven by client S3 + S6: "submit the steps in Word format to the customer for approval... First submission will be REV 0. If we make changes the file will be saved REV 1."
|
||||||
|
|
||||||
|
5. **Final inspection signoff captured on certificate** — already partially exists (signoff workflow on jobs); ensure the "who did final inspection" name lands on the cert PDF body. Driven by client S7.
|
||||||
|
|
||||||
## Out of scope (explicitly NOT doing)
|
## Out of scope (explicitly NOT doing)
|
||||||
|
|
||||||
- Data migration of existing coating config records (per user direction: dev-stage, no historical data to preserve)
|
- Data migration of existing coating config records (per user direction: dev-stage, no historical data to preserve)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.15.16',
|
'version': '19.0.20.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
De-Masking (operation, customer-visible)
|
De-Masking (operation, customer-visible)
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
|
|
||||||
<!-- ========================= ROOT ========================= -->
|
<!-- ========================= ROOT ========================= -->
|
||||||
<record id="recipe_anodize" model="fusion.plating.process.node">
|
<record id="recipe_anodize" model="fusion.plating.process.node">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
Post Stripping Inspection (customer-visible)
|
Post Stripping Inspection (customer-visible)
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
|
|
||||||
<!-- ========================= ROOT ========================= -->
|
<!-- ========================= ROOT ========================= -->
|
||||||
<record id="recipe_chem_conversion" model="fusion.plating.process.node">
|
<record id="recipe_chem_conversion" model="fusion.plating.process.node">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Source: Client's Steelhead export
|
Source: Client's Steelhead export
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
|
|
||||||
<!-- ===== ROOT ===== -->
|
<!-- ===== ROOT ===== -->
|
||||||
<record id="recipe_enp_alum_basic" model="fusion.plating.process.node">
|
<record id="recipe_enp_alum_basic" model="fusion.plating.process.node">
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
└── Lab Testing Results
|
└── Lab Testing Results
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
|
|
||||||
<!-- ========================= ROOT ========================= -->
|
<!-- ========================= ROOT ========================= -->
|
||||||
<record id="recipe_enp_sp" model="fusion.plating.process.node">
|
<record id="recipe_enp_sp" model="fusion.plating.process.node">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
└── Post Plate Inspection (customer-visible)
|
└── Post Plate Inspection (customer-visible)
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
|
|
||||||
<!-- ========================= ROOT ========================= -->
|
<!-- ========================= ROOT ========================= -->
|
||||||
<record id="recipe_enp_steel_basic" model="fusion.plating.process.node">
|
<record id="recipe_enp_steel_basic" model="fusion.plating.process.node">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
from .fp_tz import fp_isoformat_utc
|
from .fp_tz import fp_isoformat_utc
|
||||||
from ._fp_uom_selection import FP_UOM_SELECTION
|
from ._fp_uom_selection import FP_UOM_SELECTION
|
||||||
@@ -336,6 +336,79 @@ class FpProcessNode(models.Model):
|
|||||||
# NB. `pricing_rule_ids` lives in fusion_plating_configurator
|
# NB. `pricing_rule_ids` lives in fusion_plating_configurator
|
||||||
# (added there so this core module doesn't depend on the configurator).
|
# (added there so this core module doesn't depend on the configurator).
|
||||||
|
|
||||||
|
# ---- Spec-derived metadata (recipe-root only — Promote Customer Spec) ----
|
||||||
|
# These were on fp.coating.config (since retired). They describe the
|
||||||
|
# PROCESS the recipe runs, not the customer-facing specification —
|
||||||
|
# specs live on fusion.plating.customer.spec.
|
||||||
|
phosphorus_level = fields.Selection(
|
||||||
|
[('low_phos', 'Low Phosphorus (2-5%)'),
|
||||||
|
('mid_phos', 'Mid Phosphorus (6-9%)'),
|
||||||
|
('high_phos', 'High Phosphorus (10-13%)'),
|
||||||
|
('na', 'N/A')],
|
||||||
|
string='Phosphorus Level',
|
||||||
|
default='na',
|
||||||
|
help='EN-specific. Set to N/A for non-EN processes (chrome, '
|
||||||
|
'anodize, black oxide). Drives certificate annotation and '
|
||||||
|
'hydrogen-embrittlement risk assessment for bake-relief.',
|
||||||
|
)
|
||||||
|
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
|
||||||
|
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
|
||||||
|
thickness_uom = fields.Selection(
|
||||||
|
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
|
||||||
|
string='Thickness UoM', default='mils',
|
||||||
|
)
|
||||||
|
# thickness_option_ids removed — fp.recipe.thickness model deleted.
|
||||||
|
# Thickness on the SO line is now a free-text Char range (e.g.
|
||||||
|
# "0.0005-0.0008 mils") that auto-fills from last-used per
|
||||||
|
# (part, customer) or the part's x_fc_default_thickness_range.
|
||||||
|
|
||||||
|
# ---- Bake relief — AMS 2759/9 hydrogen embrittlement (recipe root) ----
|
||||||
|
requires_bake_relief = fields.Boolean(
|
||||||
|
string='Requires Bake Relief',
|
||||||
|
help='Hydrogen embrittlement relief bake required (high-strength '
|
||||||
|
'steel ≥ HRC 31 in conjunction with this chemistry). When '
|
||||||
|
'set, finishing the job auto-creates a bake-window record '
|
||||||
|
'and blocks shipment until bake is complete.',
|
||||||
|
)
|
||||||
|
bake_window_hours = fields.Float(
|
||||||
|
string='Bake Window (hours)', default=4.0,
|
||||||
|
help='Maximum time between plate exit and bake start. Typical 4h '
|
||||||
|
'per AMS 2759/9.',
|
||||||
|
)
|
||||||
|
bake_temperature = fields.Float(
|
||||||
|
string='Bake Temperature', default=375.0,
|
||||||
|
help='Relief bake temperature. Default 375 (°F per AMS 2759/9 for '
|
||||||
|
'steel ≥ HRC 40).',
|
||||||
|
)
|
||||||
|
bake_temperature_uom = fields.Selection(
|
||||||
|
[('F', '°F'), ('C', '°C')],
|
||||||
|
string='Bake Temp Unit',
|
||||||
|
default='F',
|
||||||
|
)
|
||||||
|
bake_duration_hours = fields.Float(
|
||||||
|
string='Bake Duration (hours)', default=23.0,
|
||||||
|
help='Minimum bake hold time at temperature. Typical 23h.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- NADCAP / change-control lock (recipe root) ----
|
||||||
|
# Per client direction: NADCAP-qualified recipes need manager-only
|
||||||
|
# edit permission once they're checked into the system. The Word-doc
|
||||||
|
# change-control workflow lives outside the ERP; this flag is the
|
||||||
|
# ERP-side enforcement point.
|
||||||
|
is_locked = fields.Boolean(
|
||||||
|
string='Locked (Manager-Edit Only)',
|
||||||
|
help='When True, only users in the Manager group can modify '
|
||||||
|
'this recipe (or any of its child operations / steps). '
|
||||||
|
'Use for NADCAP-qualified processes that need '
|
||||||
|
'change-control sign-off before any edit. The flag itself '
|
||||||
|
'can only be toggled by a manager.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# NB. `applicable_spec_ids` (reverse of customer.spec.recipe_ids) is
|
||||||
|
# defined as an inherit in fusion_plating_quality (the module that
|
||||||
|
# owns fusion.plating.customer.spec). Core can't reference it
|
||||||
|
# directly without a dependency inversion.
|
||||||
|
|
||||||
# ---- Computed fields -----------------------------------------------------
|
# ---- Computed fields -----------------------------------------------------
|
||||||
|
|
||||||
display_name = fields.Char(
|
display_name = fields.Char(
|
||||||
@@ -529,6 +602,22 @@ class FpProcessNode(models.Model):
|
|||||||
return records
|
return records
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
|
# NADCAP / change-control lock — block writes on locked recipes
|
||||||
|
# (and their descendants) for non-manager users. Manager bypass
|
||||||
|
# so the lock can be toggled off.
|
||||||
|
if (self
|
||||||
|
and not self.env.su
|
||||||
|
and not self.env.user.has_group(
|
||||||
|
'fusion_plating.group_fusion_plating_manager')):
|
||||||
|
for rec in self:
|
||||||
|
root = (rec if (rec.node_type == 'recipe' and not rec.parent_id)
|
||||||
|
else rec.recipe_root_id)
|
||||||
|
if root and root.is_locked:
|
||||||
|
raise UserError(_(
|
||||||
|
"Recipe '%s' is locked (NADCAP / change-control). "
|
||||||
|
"Only managers can edit it. Ask a manager to "
|
||||||
|
"unlock the recipe first."
|
||||||
|
) % (root.display_name or root.name or '?'))
|
||||||
meaningful = bool(set(vals.keys()) - self._FP_NON_VERSIONED_FIELDS)
|
meaningful = bool(set(vals.keys()) - self._FP_NON_VERSIONED_FIELDS)
|
||||||
res = super().write(vals)
|
res = super().write(vals)
|
||||||
if meaningful and self:
|
if meaningful and self:
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
Phase 3 — supervisor+ only. Operators see their own moves on
|
Phase 3 — supervisor+ only. Operators see their own moves on
|
||||||
the tablet; this is an audit view of every move. -->
|
the tablet; this is an audit view of every move. -->
|
||||||
<menuitem id="menu_fp_job_step_move"
|
<menuitem id="menu_fp_job_step_move"
|
||||||
name="Move Log"
|
name="Parts & Rack Move Log"
|
||||||
parent="menu_fp_operations"
|
parent="menu_fp_operations"
|
||||||
action="action_fp_job_step_move"
|
action="action_fp_job_step_move"
|
||||||
sequence="90"
|
sequence="90"
|
||||||
|
|||||||
@@ -45,6 +45,9 @@
|
|||||||
icon="fa-list-ol"
|
icon="fa-list-ol"
|
||||||
invisible="node_type != 'recipe'"/>
|
invisible="node_type != 'recipe'"/>
|
||||||
</header>
|
</header>
|
||||||
|
<widget name="web_ribbon" title="LOCKED — Manager Edit Only"
|
||||||
|
bg_color="text-bg-warning"
|
||||||
|
invisible="not is_locked"/>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="oe_button_box" name="button_box">
|
<div class="oe_button_box" name="button_box">
|
||||||
<button name="action_open_tree_editor" type="object"
|
<button name="action_open_tree_editor" type="object"
|
||||||
@@ -226,6 +229,44 @@
|
|||||||
<page string="Notes" name="notes">
|
<page string="Notes" name="notes">
|
||||||
<field name="notes" placeholder="Internal notes..."/>
|
<field name="notes" placeholder="Internal notes..."/>
|
||||||
</page>
|
</page>
|
||||||
|
<page string="Specification & Bake"
|
||||||
|
name="spec_metadata"
|
||||||
|
invisible="node_type != 'recipe' or parent_id">
|
||||||
|
<group>
|
||||||
|
<group string="Spec Metadata">
|
||||||
|
<field name="phosphorus_level"/>
|
||||||
|
<field name="thickness_min"/>
|
||||||
|
<field name="thickness_max"/>
|
||||||
|
<field name="thickness_uom"/>
|
||||||
|
</group>
|
||||||
|
<group string="Bake Relief (AMS 2759/9)">
|
||||||
|
<field name="requires_bake_relief"/>
|
||||||
|
<field name="bake_window_hours"
|
||||||
|
invisible="not requires_bake_relief"/>
|
||||||
|
<field name="bake_temperature"
|
||||||
|
invisible="not requires_bake_relief"/>
|
||||||
|
<field name="bake_temperature_uom"
|
||||||
|
invisible="not requires_bake_relief"/>
|
||||||
|
<field name="bake_duration_hours"
|
||||||
|
invisible="not requires_bake_relief"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Change Control (NADCAP)">
|
||||||
|
<field name="is_locked" widget="boolean_toggle"
|
||||||
|
help="When ON, only managers can edit this recipe and its child operations / steps. Use for NADCAP-qualified processes."/>
|
||||||
|
</group>
|
||||||
|
<!-- Thickness Options group removed. The
|
||||||
|
fp.recipe.thickness picker model was
|
||||||
|
retired in favour of a single free-text
|
||||||
|
thickness range field on the SO line.
|
||||||
|
Recipe still carries thickness_min /
|
||||||
|
thickness_max above as documentation
|
||||||
|
of the recipe's capability range. -->
|
||||||
|
|
||||||
|
<!-- Applicable Specifications group is added
|
||||||
|
by fusion_plating_quality via an inherit
|
||||||
|
view (the field lives there too). -->
|
||||||
|
</page>
|
||||||
</notebook>
|
</notebook>
|
||||||
</sheet>
|
</sheet>
|
||||||
<chatter/>
|
<chatter/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Maintenance Bridge',
|
'name': 'Fusion Plating — Maintenance Bridge',
|
||||||
'version': '19.0.1.1.0',
|
'version': '19.0.1.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Bridge standard Odoo Maintenance with Fusion Plating equipment, '
|
'summary': 'Bridge standard Odoo Maintenance with Fusion Plating equipment, '
|
||||||
'plans, checklists, and sensor integration.',
|
'plans, checklists, and sensor integration.',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
|
|
||||||
<!-- Override standard stages to match Steelhead lifecycle -->
|
<!-- Override standard stages to match Steelhead lifecycle -->
|
||||||
<record id="maintenance.stage_0" model="maintenance.stage">
|
<record id="maintenance.stage_0" model="maintenance.stage">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.5.6.0',
|
'version': '19.0.6.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -286,14 +286,27 @@ class FpCertificate(models.Model):
|
|||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
SaleOrder = self.env['sale.order']
|
SaleOrder = self.env['sale.order']
|
||||||
for vals in vals_list:
|
for vals in vals_list:
|
||||||
# Spec-limit auto-fill (existing behaviour, preserved).
|
# Spec-limit auto-fill — sources thickness range from the
|
||||||
|
# recipe (Phase A moved the thickness fields onto the
|
||||||
|
# recipe root). Falls back gracefully when the SO has no
|
||||||
|
# recipe-bearing line.
|
||||||
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
|
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
|
||||||
if not already_set and vals.get('sale_order_id'):
|
if not already_set and vals.get('sale_order_id'):
|
||||||
so = SaleOrder.browse(vals['sale_order_id'])
|
so = SaleOrder.browse(vals['sale_order_id'])
|
||||||
cfg = getattr(so, 'x_fc_coating_config_id', False)
|
# Look across order_line for the first recipe with a
|
||||||
if cfg and cfg.thickness_uom == 'mils':
|
# populated thickness range.
|
||||||
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
|
first_line = so.order_line[:1] if so.order_line else False
|
||||||
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
|
recipe = (
|
||||||
|
first_line.x_fc_process_variant_id
|
||||||
|
if (first_line
|
||||||
|
and 'x_fc_process_variant_id' in first_line._fields)
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
if (recipe
|
||||||
|
and 'thickness_uom' in recipe._fields
|
||||||
|
and recipe.thickness_uom == 'mils'):
|
||||||
|
vals.setdefault('spec_min_mils', recipe.thickness_min or 0.0)
|
||||||
|
vals.setdefault('spec_max_mils', recipe.thickness_max or 0.0)
|
||||||
# Defer naming: let the record exist so the mixin can write
|
# Defer naming: let the record exist so the mixin can write
|
||||||
# name via raw SQL, then fall back to the legacy sequence if
|
# name via raw SQL, then fall back to the legacy sequence if
|
||||||
# no parent SO is reachable.
|
# no parent SO is reachable.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating - Compliance (Framework)',
|
'name': 'Fusion Plating - Compliance (Framework)',
|
||||||
'version': '19.0.1.2.0',
|
'version': '19.0.1.3.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
||||||
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<menuitem id="menu_fp_compliance_calendar" name="Compliance Calendar" parent="menu_fp_compliance_root" action="action_fp_compliance_event" sequence="40"/>
|
<menuitem id="menu_fp_compliance_calendar" name="Compliance Calendar" parent="menu_fp_compliance_root" action="action_fp_compliance_event" sequence="40"/>
|
||||||
<menuitem id="menu_fp_compliance_spill_register" name="Spill Register" parent="menu_fp_compliance_root" action="action_fp_spill_register" sequence="50"/>
|
<menuitem id="menu_fp_compliance_spill_register" name="Spill Register" parent="menu_fp_compliance_root" action="action_fp_spill_register" sequence="50"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_compliance_config" name="Configuration" parent="menu_fp_compliance_root" sequence="100"/>
|
<menuitem id="menu_fp_compliance_config" name="Reference Data" parent="menu_fp_compliance_root" sequence="100"/>
|
||||||
<menuitem id="menu_fp_compliance_jurisdiction" name="Jurisdictions" parent="menu_fp_compliance_config" action="action_fp_jurisdiction" sequence="10"/>
|
<menuitem id="menu_fp_compliance_jurisdiction" name="Jurisdictions" parent="menu_fp_compliance_config" action="action_fp_jurisdiction" sequence="10"/>
|
||||||
<menuitem id="menu_fp_compliance_regulator" name="Regulators" parent="menu_fp_compliance_config" action="action_fp_regulator" sequence="20"/>
|
<menuitem id="menu_fp_compliance_regulator" name="Regulators" parent="menu_fp_compliance_config" action="action_fp_regulator" sequence="20"/>
|
||||||
<menuitem id="menu_fp_compliance_discharge_limit" name="Discharge Limits" parent="menu_fp_compliance_config" action="action_fp_discharge_limit" sequence="30"/>
|
<menuitem id="menu_fp_compliance_discharge_limit" name="Discharge Limits" parent="menu_fp_compliance_config" action="action_fp_discharge_limit" sequence="30"/>
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ def _backfill_currency(env):
|
|||||||
return
|
return
|
||||||
for model_name in (
|
for model_name in (
|
||||||
'fp.pricing.rule',
|
'fp.pricing.rule',
|
||||||
'fp.treatment',
|
|
||||||
'fp.customer.price.list',
|
|
||||||
'fp.quote.configurator',
|
'fp.quote.configurator',
|
||||||
):
|
):
|
||||||
Model = env.get(model_name)
|
Model = env.get(model_name)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.18.10.4',
|
'version': '19.0.21.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -39,16 +39,11 @@ Provides:
|
|||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_configurator_sequence_data.xml',
|
'data/fp_configurator_sequence_data.xml',
|
||||||
'data/fp_sub5_sequence_data.xml',
|
'data/fp_sub5_sequence_data.xml',
|
||||||
'data/fp_treatment_data.xml',
|
|
||||||
'data/fp_part_material_data.xml',
|
'data/fp_part_material_data.xml',
|
||||||
'views/fp_treatment_views.xml',
|
|
||||||
'views/fp_part_material_views.xml',
|
'views/fp_part_material_views.xml',
|
||||||
'views/fp_coating_thickness_views.xml',
|
|
||||||
'views/fp_part_catalog_views.xml',
|
'views/fp_part_catalog_views.xml',
|
||||||
'views/fp_process_node_part_scoped_views.xml',
|
'views/fp_process_node_part_scoped_views.xml',
|
||||||
'views/fp_coating_config_views.xml',
|
|
||||||
'views/fp_pricing_rule_views.xml',
|
'views/fp_pricing_rule_views.xml',
|
||||||
'views/fp_customer_price_list_views.xml',
|
|
||||||
'views/fp_quote_configurator_views.xml',
|
'views/fp_quote_configurator_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2026 Nexa Systems Inc.
|
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
Part of the Fusion Plating product family.
|
|
||||||
-->
|
|
||||||
<odoo noupdate="1">
|
|
||||||
|
|
||||||
<!-- Pre-treatments -->
|
|
||||||
<record id="treatment_alkaline_clean" model="fp.treatment">
|
|
||||||
<field name="name">Alkaline Clean</field>
|
|
||||||
<field name="treatment_type">pre</field>
|
|
||||||
<field name="sequence">10</field>
|
|
||||||
<field name="default_duration_minutes">15</field>
|
|
||||||
</record>
|
|
||||||
<record id="treatment_acid_etch" model="fp.treatment">
|
|
||||||
<field name="name">Acid Etch</field>
|
|
||||||
<field name="treatment_type">pre</field>
|
|
||||||
<field name="sequence">20</field>
|
|
||||||
<field name="default_duration_minutes">10</field>
|
|
||||||
</record>
|
|
||||||
<record id="treatment_zincate" model="fp.treatment">
|
|
||||||
<field name="name">Zincate (Aluminium)</field>
|
|
||||||
<field name="treatment_type">pre</field>
|
|
||||||
<field name="sequence">30</field>
|
|
||||||
<field name="default_duration_minutes">5</field>
|
|
||||||
</record>
|
|
||||||
<record id="treatment_bead_blast" model="fp.treatment">
|
|
||||||
<field name="name">Bead Blast</field>
|
|
||||||
<field name="treatment_type">pre</field>
|
|
||||||
<field name="sequence">40</field>
|
|
||||||
<field name="default_duration_minutes">20</field>
|
|
||||||
</record>
|
|
||||||
<record id="treatment_degrease" model="fp.treatment">
|
|
||||||
<field name="name">Solvent Degrease</field>
|
|
||||||
<field name="treatment_type">pre</field>
|
|
||||||
<field name="sequence">50</field>
|
|
||||||
<field name="default_duration_minutes">10</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Post-treatments -->
|
|
||||||
<record id="treatment_bake" model="fp.treatment">
|
|
||||||
<field name="name">Hydrogen Embrittlement Bake</field>
|
|
||||||
<field name="treatment_type">post</field>
|
|
||||||
<field name="sequence">10</field>
|
|
||||||
<field name="default_duration_minutes">240</field>
|
|
||||||
</record>
|
|
||||||
<record id="treatment_passivate" model="fp.treatment">
|
|
||||||
<field name="name">Passivate</field>
|
|
||||||
<field name="treatment_type">post</field>
|
|
||||||
<field name="sequence">20</field>
|
|
||||||
<field name="default_duration_minutes">30</field>
|
|
||||||
</record>
|
|
||||||
<record id="treatment_chromate_seal" model="fp.treatment">
|
|
||||||
<field name="name">Chromate Seal</field>
|
|
||||||
<field name="treatment_type">post</field>
|
|
||||||
<field name="sequence">30</field>
|
|
||||||
<field name="default_duration_minutes">15</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -3,14 +3,10 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from . import fp_treatment
|
|
||||||
from . import fp_part_material
|
from . import fp_part_material
|
||||||
from . import fp_part_catalog
|
from . import fp_part_catalog
|
||||||
from . import fp_coating_thickness
|
|
||||||
from . import fp_coating_config
|
|
||||||
from . import fp_pricing_complexity_surcharge
|
from . import fp_pricing_complexity_surcharge
|
||||||
from . import fp_pricing_rule
|
from . import fp_pricing_rule
|
||||||
from . import fp_customer_price_list
|
|
||||||
from . import fp_sale_description_template
|
from . import fp_sale_description_template
|
||||||
from . import fp_quote_configurator
|
from . import fp_quote_configurator
|
||||||
from . import fp_serial
|
from . import fp_serial
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ class AccountMoveLine(models.Model):
|
|||||||
string='Job #', index=True,
|
string='Job #', index=True,
|
||||||
help='Copied from sale.order.line.',
|
help='Copied from sale.order.line.',
|
||||||
)
|
)
|
||||||
x_fc_thickness_id = fields.Many2one(
|
x_fc_thickness_range = fields.Char(
|
||||||
'fp.coating.thickness',
|
|
||||||
string='Thickness',
|
string='Thickness',
|
||||||
help='Copied from sale.order.line for customer-facing invoice PDFs.',
|
help='Carried from the SO line — prints on the invoice PDF.',
|
||||||
)
|
)
|
||||||
|
# x_fc_customer_spec_id added by fusion_plating_quality.
|
||||||
x_fc_revision_snapshot = fields.Char(
|
x_fc_revision_snapshot = fields.Char(
|
||||||
string='Revision (snapshot)',
|
string='Revision (snapshot)',
|
||||||
help='Revision letter from the source SO line.',
|
help='Revision letter from the source SO line.',
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
# Part of the Fusion Plating product family.
|
|
||||||
|
|
||||||
from odoo import fields, models
|
|
||||||
|
|
||||||
|
|
||||||
class FpCoatingConfig(models.Model):
|
|
||||||
"""Coating configuration template.
|
|
||||||
|
|
||||||
Defines a specific coating setup: process type, phosphorus level,
|
|
||||||
thickness range, spec reference, and required pre/post treatments.
|
|
||||||
Used by the configurator to drive pricing and recipe selection.
|
|
||||||
"""
|
|
||||||
_name = 'fp.coating.config'
|
|
||||||
_description = 'Fusion Plating — Coating Configuration'
|
|
||||||
_order = 'sequence, name'
|
|
||||||
|
|
||||||
name = fields.Char(string='Configuration', required=True, help='e.g. "EN Mid-Phos AMS 2404"')
|
|
||||||
process_type_id = fields.Many2one(
|
|
||||||
'fusion.plating.process.type', string='Process Type', required=True, ondelete='restrict',
|
|
||||||
)
|
|
||||||
recipe_id = fields.Many2one(
|
|
||||||
'fusion.plating.process.node', string='Default Recipe',
|
|
||||||
domain="[('node_type', '=', 'recipe')]",
|
|
||||||
help='Default recipe template for this coating configuration.',
|
|
||||||
)
|
|
||||||
phosphorus_level = fields.Selection(
|
|
||||||
[('low_phos', 'Low Phosphorus (2-5%)'), ('mid_phos', 'Mid Phosphorus (6-9%)'),
|
|
||||||
('high_phos', 'High Phosphorus (10-13%)'), ('na', 'N/A')],
|
|
||||||
string='Phosphorus Level', default='na', help='EN-specific. Set to N/A for non-EN processes.',
|
|
||||||
)
|
|
||||||
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
|
|
||||||
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
|
|
||||||
thickness_uom = fields.Selection(
|
|
||||||
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
|
|
||||||
string='Thickness UoM', default='mils',
|
|
||||||
)
|
|
||||||
thickness_option_ids = fields.One2many(
|
|
||||||
'fp.coating.thickness',
|
|
||||||
'coating_config_id',
|
|
||||||
string='Thickness Options',
|
|
||||||
help='Discrete thickness values the estimator can pick from when '
|
|
||||||
'this coating appears on a sale order line. Each value is '
|
|
||||||
'driven by the spec the coating is built against. Sub 5.',
|
|
||||||
)
|
|
||||||
spec_reference = fields.Char(string='Spec Reference', help='e.g. "AMS 2404", "E499-303-00-005"')
|
|
||||||
certification_level = fields.Selection(
|
|
||||||
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
|
|
||||||
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
|
|
||||||
string='Certification Level', default='commercial',
|
|
||||||
)
|
|
||||||
pre_treatment_ids = fields.Many2many(
|
|
||||||
'fp.treatment', 'fp_coating_config_pre_treatment_rel', 'config_id', 'treatment_id',
|
|
||||||
string='Pre-Treatments', domain="[('treatment_type', '=', 'pre')]",
|
|
||||||
)
|
|
||||||
post_treatment_ids = fields.Many2many(
|
|
||||||
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
|
|
||||||
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Hydrogen embrittlement relief (AMS 2759/9) ----
|
|
||||||
requires_bake_relief = fields.Boolean(
|
|
||||||
string='Requires Bake Relief',
|
|
||||||
help='Hydrogen embrittlement relief bake required (high-strength steel, '
|
|
||||||
'Rockwell C ≥ 31). When set, finishing the plating WO auto-creates '
|
|
||||||
'a bake window record and blocks shipment until bake is complete.',
|
|
||||||
)
|
|
||||||
bake_window_hours = fields.Float(
|
|
||||||
string='Bake Window (hours)', default=4.0,
|
|
||||||
help='Maximum time between plate exit and bake start. Typically 4h per AMS 2759/9.',
|
|
||||||
)
|
|
||||||
bake_temperature = fields.Float(
|
|
||||||
string='Bake Temperature', default=375.0,
|
|
||||||
help='Relief bake temperature. Default 375 (°F per AMS 2759/9 for '
|
|
||||||
'steel ≥ HRC 40). Unit follows bake_temperature_uom.',
|
|
||||||
)
|
|
||||||
bake_temperature_uom = fields.Selection(
|
|
||||||
[('F', '°F'), ('C', '°C')],
|
|
||||||
string='Temp Unit',
|
|
||||||
default=lambda self: self.env.company.x_fc_default_temp_uom or 'F',
|
|
||||||
)
|
|
||||||
bake_duration_hours = fields.Float(
|
|
||||||
string='Bake Duration (hours)', default=23.0,
|
|
||||||
help='Minimum bake hold time at temperature. Typical: 23h.',
|
|
||||||
)
|
|
||||||
|
|
||||||
sequence = fields.Integer(string='Sequence', default=10)
|
|
||||||
description = fields.Text(string='Description')
|
|
||||||
active = fields.Boolean(string='Active', default=True)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
# Part of the Fusion Plating product family.
|
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
|
||||||
|
|
||||||
|
|
||||||
class FpCoatingThickness(models.Model):
|
|
||||||
"""Allowed thickness option for a coating configuration.
|
|
||||||
|
|
||||||
Each plating process (ENP Class 4, hard chrome 0.001", Type III
|
|
||||||
anodize, etc.) has its own set of valid thicknesses driven by the
|
|
||||||
spec it's built from. This child of `fp.coating.config` holds the
|
|
||||||
discrete options so the SO-line thickness dropdown can filter to
|
|
||||||
only what's actually achievable for the line's coating.
|
|
||||||
"""
|
|
||||||
_name = 'fp.coating.thickness'
|
|
||||||
_description = 'Coating Thickness Option'
|
|
||||||
_order = 'coating_config_id, sequence, value'
|
|
||||||
|
|
||||||
coating_config_id = fields.Many2one(
|
|
||||||
'fp.coating.config',
|
|
||||||
required=True,
|
|
||||||
ondelete='cascade',
|
|
||||||
)
|
|
||||||
value = fields.Float(
|
|
||||||
string='Nominal',
|
|
||||||
digits=(10, 4),
|
|
||||||
required=True,
|
|
||||||
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)'),
|
|
||||||
('microns', 'microns (µm)'),
|
|
||||||
('inches', 'inches'),
|
|
||||||
('mm', 'mm')],
|
|
||||||
required=True,
|
|
||||||
default='mils',
|
|
||||||
)
|
|
||||||
sequence = fields.Integer(default=10)
|
|
||||||
active = fields.Boolean(default=True)
|
|
||||||
display_name = fields.Char(
|
|
||||||
compute='_compute_display_name',
|
|
||||||
store=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.depends('value', 'value_min', 'value_max', 'uom')
|
|
||||||
def _compute_display_name(self):
|
|
||||||
uom_labels = dict(self._fields['uom'].selection)
|
|
||||||
for rec in self:
|
|
||||||
label = uom_labels.get(rec.uom, rec.uom or '')
|
|
||||||
# Strip the bracketed clarification for a tighter dropdown row.
|
|
||||||
if ' (' in label:
|
|
||||||
label = label.split(' (')[0]
|
|
||||||
# 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})
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
# Part of the Fusion Plating product family.
|
|
||||||
|
|
||||||
from odoo import api, fields, models
|
|
||||||
|
|
||||||
|
|
||||||
class FpCustomerPriceList(models.Model):
|
|
||||||
"""Standing price per (customer, coating config).
|
|
||||||
|
|
||||||
Repeat customers accept a negotiated price per coating — the configurator
|
|
||||||
and Direct Order wizard auto-fill `unit_price` from here before falling
|
|
||||||
back to the formula-based pricing engine.
|
|
||||||
|
|
||||||
Optional effective_from / effective_to support annual contracts.
|
|
||||||
"""
|
|
||||||
_name = 'fp.customer.price.list'
|
|
||||||
_description = 'Fusion Plating — Customer Price List'
|
|
||||||
_inherit = ['mail.thread']
|
|
||||||
_order = 'partner_id, coating_config_id, effective_from desc'
|
|
||||||
|
|
||||||
name = fields.Char(
|
|
||||||
string='Reference', compute='_compute_name', store=True,
|
|
||||||
)
|
|
||||||
partner_id = fields.Many2one(
|
|
||||||
'res.partner', string='Customer', required=True, ondelete='cascade',
|
|
||||||
tracking=True, domain="[('customer_rank', '>', 0)]",
|
|
||||||
)
|
|
||||||
coating_config_id = fields.Many2one(
|
|
||||||
'fp.coating.config', string='Coating', required=True, ondelete='restrict',
|
|
||||||
tracking=True,
|
|
||||||
)
|
|
||||||
unit_price = fields.Monetary(
|
|
||||||
string='Unit Price', required=True, currency_field='currency_id',
|
|
||||||
tracking=True,
|
|
||||||
)
|
|
||||||
price_uom = fields.Selection(
|
|
||||||
[('per_part', 'per Part'),
|
|
||||||
('per_sqin', 'per sq in'),
|
|
||||||
('per_sqft', 'per sq ft'),
|
|
||||||
('per_lb', 'per lb')],
|
|
||||||
string='Price Basis', default='per_part', required=True,
|
|
||||||
)
|
|
||||||
currency_id = fields.Many2one(
|
|
||||||
'res.currency', string='Currency',
|
|
||||||
required=True, default=lambda self: self.env.company.currency_id,
|
|
||||||
)
|
|
||||||
effective_from = fields.Date(
|
|
||||||
string='Effective From', default=fields.Date.today, required=True, tracking=True,
|
|
||||||
)
|
|
||||||
effective_to = fields.Date(
|
|
||||||
string='Effective To',
|
|
||||||
help='Blank = no expiry. Set for annual contract pricing.',
|
|
||||||
tracking=True,
|
|
||||||
)
|
|
||||||
min_quantity = fields.Integer(
|
|
||||||
string='Minimum Qty', default=1,
|
|
||||||
help='Volume break — this price applies for orders of this size or larger.',
|
|
||||||
)
|
|
||||||
notes = fields.Html(string='Notes')
|
|
||||||
active = fields.Boolean(default=True)
|
|
||||||
|
|
||||||
_sql_constraints = [
|
|
||||||
('fp_price_list_unique',
|
|
||||||
'unique(partner_id, coating_config_id, effective_from, min_quantity)',
|
|
||||||
'A price entry already exists for this customer + coating + '
|
|
||||||
'effective date + quantity tier.'),
|
|
||||||
]
|
|
||||||
|
|
||||||
@api.depends('partner_id', 'coating_config_id', 'min_quantity', 'effective_from')
|
|
||||||
def _compute_name(self):
|
|
||||||
for rec in self:
|
|
||||||
parts = []
|
|
||||||
if rec.partner_id:
|
|
||||||
parts.append(rec.partner_id.name)
|
|
||||||
if rec.coating_config_id:
|
|
||||||
parts.append(rec.coating_config_id.name)
|
|
||||||
if rec.min_quantity > 1:
|
|
||||||
parts.append(f'≥{rec.min_quantity}')
|
|
||||||
rec.name = ' / '.join(parts) if parts else ''
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def _find_price(self, partner_id, coating_config_id, quantity=1, on_date=None):
|
|
||||||
"""Return the best-matching active price list entry for this request."""
|
|
||||||
if not (partner_id and coating_config_id):
|
|
||||||
return False
|
|
||||||
on_date = on_date or fields.Date.today()
|
|
||||||
candidates = self.search([
|
|
||||||
('partner_id', '=', partner_id),
|
|
||||||
('coating_config_id', '=', coating_config_id),
|
|
||||||
('active', '=', True),
|
|
||||||
('effective_from', '<=', on_date),
|
|
||||||
'|', ('effective_to', '=', False), ('effective_to', '>=', on_date),
|
|
||||||
('min_quantity', '<=', quantity),
|
|
||||||
], order='min_quantity desc, effective_from desc')
|
|
||||||
return candidates[:1]
|
|
||||||
@@ -277,18 +277,15 @@ class FpPartCatalog(models.Model):
|
|||||||
rec.process_variant_count = len(variants)
|
rec.process_variant_count = len(variants)
|
||||||
|
|
||||||
# ---- Direct-order defaults (Phase C — C4) ----
|
# ---- Direct-order defaults (Phase C — C4) ----
|
||||||
x_fc_default_coating_config_id = fields.Many2one(
|
# x_fc_default_customer_spec_id added by fusion_plating_quality.
|
||||||
'fp.coating.config',
|
# Legacy default_coating_config_id + default_treatment_ids removed.
|
||||||
string='Default Treatment',
|
x_fc_default_thickness_range = fields.Char(
|
||||||
help='Default coating applied when this part is dropped onto a '
|
string='Default Thickness',
|
||||||
'direct order line. Updated when "Save as Default" is ticked.',
|
help='Default thickness range as free text (e.g. "0.0005-0.0008 mils" '
|
||||||
)
|
'or "5-10 mils"). Pre-fills the thickness on new sale order '
|
||||||
x_fc_default_treatment_ids = fields.Many2many(
|
'lines for this part — falls back when no recent order for '
|
||||||
'fp.treatment',
|
'the same (part, customer) pair exists. Updated when the '
|
||||||
relation='fp_part_catalog_default_treatment_rel',
|
'wizard\'s "Save as Default" toggle is ticked.',
|
||||||
string='Default Additional Treatments',
|
|
||||||
help='Default additional treatments. Seeded when "Save as Default" '
|
|
||||||
'is ticked on a direct order line.',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Substrate density mapping (g/cm³) for material weight calculation
|
# Substrate density mapping (g/cm³) for material weight calculation
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ class FpPricingRule(models.Model):
|
|||||||
_order = 'sequence, id'
|
_order = 'sequence, id'
|
||||||
|
|
||||||
name = fields.Char(string='Rule Name', required=True)
|
name = fields.Char(string='Rule Name', required=True)
|
||||||
coating_config_id = fields.Many2one('fp.coating.config', string='Coating Config',
|
# coating_config_id removed. Spec + recipe match keys live on
|
||||||
help='Leave blank for a global rule.')
|
# fusion_plating_quality.fp_pricing_rule_inherit. Material +
|
||||||
|
# cert_level (below) remain as generic filters.
|
||||||
substrate_material = fields.Selection(
|
substrate_material = fields.Selection(
|
||||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||||
|
|||||||
@@ -243,8 +243,15 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||||
upload_po_filename = fields.Char(string='PO Filename')
|
upload_po_filename = fields.Char(string='PO Filename')
|
||||||
|
|
||||||
coating_config_id = fields.Many2one(
|
# Renamed from coating_config_id (Phase E — Promote Customer Spec).
|
||||||
'fp.coating.config', string='Coating Configuration', required=True,
|
# Now points at the recipe directly. The quote's specification
|
||||||
|
# (customer-facing audit ref) is added by quality inherit as
|
||||||
|
# customer_spec_id.
|
||||||
|
recipe_id = fields.Many2one(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
string='Recipe',
|
||||||
|
required=True,
|
||||||
|
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
|
||||||
)
|
)
|
||||||
quantity = fields.Integer(string='Quantity', default=1, required=True)
|
quantity = fields.Integer(string='Quantity', default=1, required=True)
|
||||||
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
|
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
|
||||||
@@ -345,10 +352,10 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
# Copy masking area too (for effective-area calculation)
|
# Copy masking area too (for effective-area calculation)
|
||||||
self.masking_area_sqin = cat.masking_area_sqin
|
self.masking_area_sqin = cat.masking_area_sqin
|
||||||
|
|
||||||
@api.onchange('coating_config_id')
|
@api.onchange('recipe_id')
|
||||||
def _onchange_coating_config_id(self):
|
def _onchange_recipe_id(self):
|
||||||
if self.coating_config_id:
|
if self.recipe_id and self.recipe_id.thickness_min:
|
||||||
self.thickness_requested = self.coating_config_id.thickness_min
|
self.thickness_requested = self.recipe_id.thickness_min
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Price calculation
|
# Price calculation
|
||||||
@@ -358,11 +365,11 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'masking_zones', 'complexity', 'substrate_material',
|
'masking_zones', 'complexity', 'substrate_material',
|
||||||
'quantity', 'batch_size', 'rush_order',
|
'quantity', 'batch_size', 'rush_order',
|
||||||
'shipping_fee', 'delivery_fee',
|
'shipping_fee', 'delivery_fee',
|
||||||
'coating_config_id', 'coating_config_id.certification_level',
|
'recipe_id',
|
||||||
)
|
)
|
||||||
def _compute_price(self):
|
def _compute_price(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.coating_config_id or not rec.surface_area:
|
if not rec.recipe_id or not rec.surface_area:
|
||||||
rec.calculated_price = 0
|
rec.calculated_price = 0
|
||||||
rec.price_breakdown_html = ''
|
rec.price_breakdown_html = ''
|
||||||
continue
|
continue
|
||||||
@@ -476,19 +483,17 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
def _find_matching_rule(self):
|
def _find_matching_rule(self):
|
||||||
"""Find the best pricing rule matching this configurator's filters.
|
"""Find the best pricing rule matching this configurator's filters.
|
||||||
|
|
||||||
Scores rules by specificity -- most specific match wins.
|
Scores rules by specificity — most specific match wins.
|
||||||
If no rule matches filters, returns None.
|
If no rule matches filters, returns None.
|
||||||
|
|
||||||
When the chosen coating config points at a recipe and that recipe
|
When the chosen recipe has `pricing_rule_ids` configured, the
|
||||||
has `pricing_rule_ids` configured, the search is constrained to
|
search is constrained to those rules ("Use Price Builders"
|
||||||
those rules ("Use Price Builders" semantics). Otherwise the
|
semantics). Otherwise the whole active rule set is considered.
|
||||||
whole active rule set is considered as before.
|
|
||||||
|
Spec-tier scoring is added by an inherit in
|
||||||
|
fusion_plating_quality (where customer.spec lives).
|
||||||
"""
|
"""
|
||||||
recipe = (
|
recipe = self.recipe_id or False
|
||||||
self.coating_config_id.recipe_id
|
|
||||||
if self.coating_config_id and self.coating_config_id.recipe_id
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
builder_rules = (
|
builder_rules = (
|
||||||
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
|
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
|
||||||
)
|
)
|
||||||
@@ -500,27 +505,15 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
rules = self.env['fp.pricing.rule'].search(
|
rules = self.env['fp.pricing.rule'].search(
|
||||||
[('active', '=', True)], order='sequence, id'
|
[('active', '=', True)], order='sequence, id'
|
||||||
)
|
)
|
||||||
cert_level = (
|
|
||||||
self.coating_config_id.certification_level
|
|
||||||
if self.coating_config_id else False
|
|
||||||
)
|
|
||||||
|
|
||||||
best = None
|
best = None
|
||||||
best_score = -1
|
best_score = -1
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
score = 0
|
score = 0
|
||||||
if rule.coating_config_id:
|
|
||||||
if rule.coating_config_id != self.coating_config_id:
|
|
||||||
continue
|
|
||||||
score += 4
|
|
||||||
if rule.substrate_material:
|
if rule.substrate_material:
|
||||||
if rule.substrate_material != self.substrate_material:
|
if rule.substrate_material != self.substrate_material:
|
||||||
continue
|
continue
|
||||||
score += 2
|
score += 2
|
||||||
if rule.certification_level:
|
|
||||||
if rule.certification_level != cert_level:
|
|
||||||
continue
|
|
||||||
score += 1
|
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best = rule
|
best = rule
|
||||||
@@ -569,9 +562,9 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Pick a part catalog entry before promoting this quote.'
|
'Pick a part catalog entry before promoting this quote.'
|
||||||
))
|
))
|
||||||
if not self.coating_config_id:
|
if not self.recipe_id:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Pick a coating configuration before promoting this quote.'
|
'Pick a recipe before promoting this quote.'
|
||||||
))
|
))
|
||||||
existing_line = self.env['fp.direct.order.line'].search([
|
existing_line = self.env['fp.direct.order.line'].search([
|
||||||
('quote_id', '=', self.id),
|
('quote_id', '=', self.id),
|
||||||
@@ -618,14 +611,13 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'purchase_ok': False,
|
'purchase_ok': False,
|
||||||
})
|
})
|
||||||
|
|
||||||
coating_name = self.coating_config_id.name if self.coating_config_id else ''
|
recipe_name = self.recipe_id.name if self.recipe_id else ''
|
||||||
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
|
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
|
||||||
|
|
||||||
so_vals = {
|
so_vals = {
|
||||||
'partner_id': self.partner_id.id,
|
'partner_id': self.partner_id.id,
|
||||||
'x_fc_configurator_id': self.id,
|
'x_fc_configurator_id': self.id,
|
||||||
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
|
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
|
||||||
'x_fc_coating_config_id': self.coating_config_id.id,
|
|
||||||
'x_fc_rush_order': self.rush_order,
|
'x_fc_rush_order': self.rush_order,
|
||||||
'x_fc_delivery_method': self.delivery_method,
|
'x_fc_delivery_method': self.delivery_method,
|
||||||
# Transfer RFQ / PO documents from configurator (if any)
|
# Transfer RFQ / PO documents from configurator (if any)
|
||||||
@@ -641,17 +633,19 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'origin': self.name,
|
'origin': self.name,
|
||||||
'order_line': [(0, 0, {
|
'order_line': [(0, 0, {
|
||||||
'product_id': product.id,
|
'product_id': product.id,
|
||||||
'name': '%s — %s (x%d)' % (coating_name, part_name, self.quantity),
|
'name': '%s — %s (x%d)' % (recipe_name, part_name, self.quantity),
|
||||||
'product_uom_qty': self.quantity,
|
'product_uom_qty': self.quantity,
|
||||||
'price_unit': price / self.quantity if self.quantity else price,
|
'price_unit': price / self.quantity if self.quantity else price,
|
||||||
# Sub 11 fix — propagate part + coating to the LINE too.
|
# Propagate part + recipe to the LINE.
|
||||||
# fusion_plating_jobs._fp_auto_create_job filters lines
|
# fusion_plating_jobs._fp_auto_create_job filters lines
|
||||||
# by x_fc_part_catalog_id; without it, no fp.job spawns.
|
# by x_fc_part_catalog_id; without it, no fp.job spawns.
|
||||||
|
# Spec carry-over to SO line is handled by the quality
|
||||||
|
# inherit (sale_order_line_inherit.create override).
|
||||||
'x_fc_part_catalog_id': (
|
'x_fc_part_catalog_id': (
|
||||||
self.part_catalog_id.id if self.part_catalog_id else False
|
self.part_catalog_id.id if self.part_catalog_id else False
|
||||||
),
|
),
|
||||||
'x_fc_coating_config_id': (
|
'x_fc_process_variant_id': (
|
||||||
self.coating_config_id.id if self.coating_config_id else False
|
self.recipe_id.id if self.recipe_id else False
|
||||||
),
|
),
|
||||||
})],
|
})],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,19 +52,14 @@ class FpSaleDescriptionTemplate(models.Model):
|
|||||||
'part — it only appears in the picker when this part is on '
|
'part — it only appears in the picker when this part is on '
|
||||||
'the order. Leave blank for generic fallback templates.',
|
'the order. Leave blank for generic fallback templates.',
|
||||||
)
|
)
|
||||||
# Related fields — surface the part's partner/coating for search &
|
# Related fields — surface the part's partner for search & grouping
|
||||||
# grouping without writing them twice.
|
# without writing it twice.
|
||||||
partner_id = fields.Many2one(
|
partner_id = fields.Many2one(
|
||||||
'res.partner', string='Customer',
|
'res.partner', string='Customer',
|
||||||
related='part_catalog_id.partner_id', store=True, readonly=True,
|
related='part_catalog_id.partner_id', store=True, readonly=True,
|
||||||
)
|
)
|
||||||
# Keep the explicit coating slot for global templates that aren't
|
# coating_config_id removed; templates can be customer- or part-
|
||||||
# part-specific but are still coating-specific.
|
# scoped. Spec-scoped templates are a future enhancement.
|
||||||
coating_config_id = fields.Many2one(
|
|
||||||
'fp.coating.config', string='Associated Coating',
|
|
||||||
ondelete='set null',
|
|
||||||
help='For generic (no-part) templates, restrict to one coating.',
|
|
||||||
)
|
|
||||||
tag = fields.Selection(
|
tag = fields.Selection(
|
||||||
[('standard', 'Standard'),
|
[('standard', 'Standard'),
|
||||||
('masking', 'Masking / Selective'),
|
('masking', 'Masking / Selective'),
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
# Part of the Fusion Plating product family.
|
|
||||||
|
|
||||||
from odoo import fields, models
|
|
||||||
|
|
||||||
|
|
||||||
class FpTreatment(models.Model):
|
|
||||||
"""Pre- or post-treatment step (bead blast, zincate, bake, passivate, etc.).
|
|
||||||
|
|
||||||
Used by coating configurations to specify which preparation and
|
|
||||||
finishing steps are required for a given process.
|
|
||||||
"""
|
|
||||||
_name = 'fp.treatment'
|
|
||||||
_description = 'Fusion Plating — Treatment'
|
|
||||||
_order = 'treatment_type, sequence, name'
|
|
||||||
|
|
||||||
name = fields.Char(
|
|
||||||
string='Treatment',
|
|
||||||
required=True,
|
|
||||||
help='e.g. "Bead Blast", "Zincate", "Hydrogen Embrittlement Bake"',
|
|
||||||
)
|
|
||||||
treatment_type = fields.Selection(
|
|
||||||
[('pre', 'Pre-Treatment'), ('post', 'Post-Treatment')],
|
|
||||||
string='Type',
|
|
||||||
required=True,
|
|
||||||
default='pre',
|
|
||||||
)
|
|
||||||
sequence = fields.Integer(string='Sequence', default=10)
|
|
||||||
default_duration_minutes = fields.Float(
|
|
||||||
string='Default Duration (min)',
|
|
||||||
help='Estimated duration per application in minutes.',
|
|
||||||
)
|
|
||||||
currency_id = fields.Many2one(
|
|
||||||
'res.currency',
|
|
||||||
string='Currency',
|
|
||||||
required=True,
|
|
||||||
default=lambda self: self.env.company.currency_id,
|
|
||||||
)
|
|
||||||
default_cost = fields.Monetary(
|
|
||||||
string='Default Cost',
|
|
||||||
currency_field='currency_id',
|
|
||||||
help='Default cost per application. Can be overridden on pricing rules.',
|
|
||||||
)
|
|
||||||
description = fields.Text(string='Description')
|
|
||||||
active = fields.Boolean(string='Active', default=True)
|
|
||||||
|
|
||||||
_sql_constraints = [
|
|
||||||
('fp_treatment_name_type_uniq', 'unique(name, treatment_type)',
|
|
||||||
'Treatment name must be unique per type.'),
|
|
||||||
]
|
|
||||||
@@ -11,7 +11,8 @@ class SaleOrder(models.Model):
|
|||||||
|
|
||||||
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
|
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
|
||||||
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
|
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
|
||||||
x_fc_coating_config_id = fields.Many2one('fp.coating.config', string='Coating Configuration')
|
# x_fc_coating_config_id removed; specs live on customer.spec via
|
||||||
|
# the line-level x_fc_customer_spec_id (added by quality inherit).
|
||||||
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
|
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
|
||||||
x_fc_po_attachment_id = fields.Many2one(
|
x_fc_po_attachment_id = fields.Many2one(
|
||||||
'ir.attachment', string='PO Document', tracking=True,
|
'ir.attachment', string='PO Document', tracking=True,
|
||||||
@@ -209,7 +210,7 @@ class SaleOrder(models.Model):
|
|||||||
for so in self:
|
for so in self:
|
||||||
variants = []
|
variants = []
|
||||||
for line in so.order_line:
|
for line in so.order_line:
|
||||||
if not (line.x_fc_part_catalog_id or line.x_fc_coating_config_id):
|
if not line.x_fc_part_catalog_id:
|
||||||
continue # non-plating line
|
continue # non-plating line
|
||||||
variant = (line.x_fc_process_variant_id
|
variant = (line.x_fc_process_variant_id
|
||||||
or line.x_fc_part_catalog_id.default_process_id)
|
or line.x_fc_part_catalog_id.default_process_id)
|
||||||
@@ -553,35 +554,17 @@ class SaleOrder(models.Model):
|
|||||||
|
|
||||||
@api.depends('order_line.price_subtotal', 'amount_untaxed')
|
@api.depends('order_line.price_subtotal', 'amount_untaxed')
|
||||||
def _compute_margin(self):
|
def _compute_margin(self):
|
||||||
"""Margin = untaxed total − rolled-up cost from coating configs.
|
"""Margin computation — stub.
|
||||||
|
|
||||||
x_fc_margin_percent is stored as a fraction (0.0 - 1.0) so the
|
Pre-promote-customer-spec, this rolled up cost from
|
||||||
widget='percentage' formats 100% as 100%, not 10000%.
|
fp.coating.config.unit_cost. Coating Config is retired; cost
|
||||||
|
data on the recipe is a future enhancement (backlog). Until
|
||||||
x_fc_margin_available is False when NO line has a costed coating
|
then, margin is "not available" and the UI hides the fields.
|
||||||
(i.e. fp.coating.config.unit_cost isn't populated anywhere). The
|
|
||||||
UI should render margin fields as "n/a" in that case rather than
|
|
||||||
showing a misleading 100%.
|
|
||||||
"""
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
has_cost_data = False
|
rec.x_fc_margin_available = False
|
||||||
cost = 0.0
|
rec.x_fc_margin_amount = 0.0
|
||||||
for line in rec.order_line:
|
rec.x_fc_margin_percent = 0.0
|
||||||
cc = line.x_fc_coating_config_id
|
|
||||||
if not cc:
|
|
||||||
continue
|
|
||||||
if 'unit_cost' not in cc._fields:
|
|
||||||
continue
|
|
||||||
if cc.unit_cost:
|
|
||||||
has_cost_data = True
|
|
||||||
cost_per_unit = cc.unit_cost or 0.0
|
|
||||||
cost += cost_per_unit * (line.product_uom_qty or 0)
|
|
||||||
rec.x_fc_margin_available = has_cost_data
|
|
||||||
rec.x_fc_margin_amount = (rec.amount_untaxed or 0) - cost
|
|
||||||
rec.x_fc_margin_percent = (
|
|
||||||
(rec.x_fc_margin_amount / rec.amount_untaxed)
|
|
||||||
if (rec.amount_untaxed and has_cost_data) else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.onchange('upload_rfq_file')
|
@api.onchange('upload_rfq_file')
|
||||||
def _onchange_upload_rfq_file(self):
|
def _onchange_upload_rfq_file(self):
|
||||||
|
|||||||
@@ -59,12 +59,9 @@ class SaleOrderLine(models.Model):
|
|||||||
string='Description Template',
|
string='Description Template',
|
||||||
help='Which template row populated this line. Informational.',
|
help='Which template row populated this line. Informational.',
|
||||||
)
|
)
|
||||||
x_fc_coating_config_id = fields.Many2one(
|
# Specification picker (x_fc_customer_spec_id) is added by
|
||||||
'fp.coating.config', string='Primary Treatment',
|
# fusion_plating_quality. Legacy x_fc_coating_config_id +
|
||||||
)
|
# x_fc_treatment_ids removed.
|
||||||
x_fc_treatment_ids = fields.Many2many(
|
|
||||||
'fp.treatment', string='Additional Treatments',
|
|
||||||
)
|
|
||||||
x_fc_part_deadline = fields.Date(
|
x_fc_part_deadline = fields.Date(
|
||||||
string='Part Deadline Override',
|
string='Part Deadline Override',
|
||||||
help='Absolute-date manual override. When set, beats the days-offset '
|
help='Absolute-date manual override. When set, beats the days-offset '
|
||||||
@@ -307,13 +304,13 @@ class SaleOrderLine(models.Model):
|
|||||||
help='Shop-floor reference for this line. Auto-sequenced on sale '
|
help='Shop-floor reference for this line. Auto-sequenced on sale '
|
||||||
'order confirmation; editable. Blank is allowed.',
|
'order confirmation; editable. Blank is allowed.',
|
||||||
)
|
)
|
||||||
x_fc_thickness_id = fields.Many2one(
|
x_fc_thickness_range = fields.Char(
|
||||||
'fp.coating.thickness',
|
|
||||||
string='Thickness',
|
string='Thickness',
|
||||||
ondelete='set null',
|
help='Target thickness range as the operator types it, e.g. '
|
||||||
domain="[('coating_config_id', '=', x_fc_coating_config_id)]",
|
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text — '
|
||||||
help="Target coating thickness. Options come from the line's "
|
'auto-fills from the last order for this (part, customer) '
|
||||||
'coating configuration.',
|
'pair, falling back to the part\'s default range. Prints '
|
||||||
|
'verbatim on the cert, packing slip, and invoice.',
|
||||||
)
|
)
|
||||||
x_fc_revision_snapshot = fields.Char(
|
x_fc_revision_snapshot = fields.Char(
|
||||||
string='Revision (snapshot)',
|
string='Revision (snapshot)',
|
||||||
@@ -403,6 +400,32 @@ class SaleOrderLine(models.Model):
|
|||||||
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||||
if part and part.revision:
|
if part and part.revision:
|
||||||
vals['x_fc_revision_snapshot'] = part.revision
|
vals['x_fc_revision_snapshot'] = part.revision
|
||||||
|
|
||||||
|
# Auto-fill thickness range — same logic as the onchange but
|
||||||
|
# for programmatic creators (wizard, sale_mrp, imports).
|
||||||
|
# Resolution: explicit > last-used (part, partner) > part default.
|
||||||
|
if (not vals.get('x_fc_thickness_range')
|
||||||
|
and vals.get('x_fc_part_catalog_id')):
|
||||||
|
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||||
|
if part:
|
||||||
|
# Need partner_id from the parent order
|
||||||
|
partner_id = False
|
||||||
|
if vals.get('order_id'):
|
||||||
|
order = self.env['sale.order'].browse(vals['order_id']).exists()
|
||||||
|
if order:
|
||||||
|
partner_id = order.partner_id.id
|
||||||
|
if partner_id:
|
||||||
|
recent = self.search([
|
||||||
|
('x_fc_part_catalog_id', '=', part.id),
|
||||||
|
('order_id.partner_id', '=', partner_id),
|
||||||
|
('x_fc_thickness_range', '!=', False),
|
||||||
|
('x_fc_thickness_range', '!=', ''),
|
||||||
|
], order='create_date desc', limit=1)
|
||||||
|
if recent:
|
||||||
|
vals['x_fc_thickness_range'] = recent.x_fc_thickness_range
|
||||||
|
if (not vals.get('x_fc_thickness_range')
|
||||||
|
and getattr(part, 'x_fc_default_thickness_range', None)):
|
||||||
|
vals['x_fc_thickness_range'] = part.x_fc_default_thickness_range
|
||||||
lines = super().create(vals_list)
|
lines = super().create(vals_list)
|
||||||
lines._fp_apply_recipe_polish()
|
lines._fp_apply_recipe_polish()
|
||||||
return lines
|
return lines
|
||||||
@@ -477,10 +500,12 @@ class SaleOrderLine(models.Model):
|
|||||||
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
|
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
|
||||||
if self.x_fc_job_number:
|
if self.x_fc_job_number:
|
||||||
vals['x_fc_job_number'] = self.x_fc_job_number
|
vals['x_fc_job_number'] = self.x_fc_job_number
|
||||||
if self.x_fc_thickness_id:
|
if self.x_fc_thickness_range:
|
||||||
vals['x_fc_thickness_id'] = self.x_fc_thickness_id.id
|
vals['x_fc_thickness_range'] = self.x_fc_thickness_range
|
||||||
if self.x_fc_revision_snapshot:
|
if self.x_fc_revision_snapshot:
|
||||||
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
|
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
|
||||||
|
# x_fc_customer_spec_id carry-over is handled by an
|
||||||
|
# extension in fusion_plating_quality (the field lives there).
|
||||||
return vals
|
return vals
|
||||||
|
|
||||||
@api.onchange('x_fc_part_catalog_id')
|
@api.onchange('x_fc_part_catalog_id')
|
||||||
@@ -498,6 +523,9 @@ class SaleOrderLine(models.Model):
|
|||||||
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
|
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
|
||||||
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
|
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
|
||||||
|
|
||||||
|
# Spec auto-fill onchange lives in fusion_plating_quality
|
||||||
|
# (the customer.spec model lives there, so the inherit must too).
|
||||||
|
|
||||||
def _fp_clone_recipe_to_part(self):
|
def _fp_clone_recipe_to_part(self):
|
||||||
"""Deep-copy the picked recipe onto this line's part if it isn't
|
"""Deep-copy the picked recipe onto this line's part if it isn't
|
||||||
already scoped there. Returns the cloned (or unchanged) variant.
|
already scoped there. Returns the cloned (or unchanged) variant.
|
||||||
@@ -575,18 +603,41 @@ class SaleOrderLine(models.Model):
|
|||||||
'target': 'current',
|
'target': 'current',
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.onchange('x_fc_coating_config_id')
|
@api.onchange('x_fc_part_catalog_id')
|
||||||
def _onchange_coating_clears_thickness(self):
|
def _onchange_part_default_thickness(self):
|
||||||
"""Clear the thickness picker when coating config changes.
|
"""Auto-fill thickness range from last-used or part default.
|
||||||
|
|
||||||
The thickness options are scoped to the coating config; a value
|
Resolution order (first match wins):
|
||||||
carried over from a previous coating would fail its domain.
|
1. Operator already typed a value → keep
|
||||||
|
2. Most recent SO line for (this part, this customer) with a
|
||||||
|
non-empty thickness_range → copy that
|
||||||
|
3. Part's x_fc_default_thickness_range → copy
|
||||||
|
4. Blank — operator types
|
||||||
"""
|
"""
|
||||||
for line in self:
|
for line in self:
|
||||||
if (line.x_fc_thickness_id
|
if line.x_fc_thickness_range:
|
||||||
and line.x_fc_thickness_id.coating_config_id
|
continue
|
||||||
!= line.x_fc_coating_config_id):
|
if not line.x_fc_part_catalog_id:
|
||||||
line.x_fc_thickness_id = False
|
continue
|
||||||
|
partner = line.order_id.partner_id
|
||||||
|
# 2. Last-used for (part, customer)
|
||||||
|
if partner:
|
||||||
|
recent = self.env['sale.order.line'].search([
|
||||||
|
('x_fc_part_catalog_id', '=', line.x_fc_part_catalog_id.id),
|
||||||
|
('order_id.partner_id', '=', partner.id),
|
||||||
|
('x_fc_thickness_range', '!=', False),
|
||||||
|
('x_fc_thickness_range', '!=', ''),
|
||||||
|
('id', '!=', line.id or 0),
|
||||||
|
], order='create_date desc', limit=1)
|
||||||
|
if recent:
|
||||||
|
line.x_fc_thickness_range = recent.x_fc_thickness_range
|
||||||
|
continue
|
||||||
|
# 3. Part default
|
||||||
|
part_default = getattr(
|
||||||
|
line.x_fc_part_catalog_id, 'x_fc_default_thickness_range', None,
|
||||||
|
)
|
||||||
|
if part_default:
|
||||||
|
line.x_fc_thickness_range = part_default
|
||||||
|
|
||||||
def action_generate_serial(self):
|
def action_generate_serial(self):
|
||||||
"""Generate one new auto-sequenced serial and append it to the M2M.
|
"""Generate one new auto-sequenced serial and append it to the M2M.
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_fp_treatment_operator,fp.treatment.operator,model_fp_treatment,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_treatment_supervisor,fp.treatment.supervisor,model_fp_treatment,fusion_plating.group_fusion_plating_supervisor,1,1,0,0
|
|
||||||
access_fp_treatment_manager,fp.treatment.manager,model_fp_treatment,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_coating_config_operator,fp.coating.config.operator,model_fp_coating_config,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_coating_config_estimator,fp.coating.config.estimator,model_fp_coating_config,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
|
||||||
access_fp_coating_config_manager,fp.coating.config.manager,model_fp_coating_config,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
@@ -35,9 +29,6 @@ access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_
|
|||||||
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_customer_price_list_operator,fp.customer.price.list.operator,model_fp_customer_price_list,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_customer_price_list_estimator,fp.customer.price.list.estimator,model_fp_customer_price_list,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
|
||||||
access_fp_customer_price_list_manager,fp.customer.price.list.manager,model_fp_customer_price_list,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0
|
access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0
|
||||||
access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
@@ -48,9 +39,6 @@ access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial
|
|||||||
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
|
||||||
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
|
||||||
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -1,143 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2026 Nexa Systems Inc.
|
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
Part of the Fusion Plating product family.
|
|
||||||
-->
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<!-- ===== Coating Configuration List View ===== -->
|
|
||||||
<record id="view_fp_coating_config_list" model="ir.ui.view">
|
|
||||||
<field name="name">fp.coating.config.list</field>
|
|
||||||
<field name="model">fp.coating.config</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list string="Coating Configurations" decoration-muted="not active">
|
|
||||||
<field name="sequence" widget="handle"/>
|
|
||||||
<field name="name"/>
|
|
||||||
<field name="process_type_id"/>
|
|
||||||
<field name="phosphorus_level"/>
|
|
||||||
<field name="thickness_min"/>
|
|
||||||
<field name="thickness_max"/>
|
|
||||||
<field name="spec_reference"/>
|
|
||||||
<field name="certification_level"/>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ===== Coating Configuration Form View ===== -->
|
|
||||||
<record id="view_fp_coating_config_form" model="ir.ui.view">
|
|
||||||
<field name="name">fp.coating.config.form</field>
|
|
||||||
<field name="model">fp.coating.config</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<form string="Coating Configuration">
|
|
||||||
<sheet>
|
|
||||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
|
||||||
<div class="oe_title">
|
|
||||||
<label for="name"/>
|
|
||||||
<h1><field name="name" placeholder="e.g. EN Mid-Phos AMS 2404"/></h1>
|
|
||||||
</div>
|
|
||||||
<group>
|
|
||||||
<group>
|
|
||||||
<field name="process_type_id"/>
|
|
||||||
<field name="recipe_id"/>
|
|
||||||
<field name="phosphorus_level"/>
|
|
||||||
<field name="certification_level"/>
|
|
||||||
<field name="sequence"/>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<field name="thickness_min"/>
|
|
||||||
<field name="thickness_max"/>
|
|
||||||
<field name="thickness_uom"/>
|
|
||||||
<field name="spec_reference"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<notebook>
|
|
||||||
<page string="Treatments" name="treatments">
|
|
||||||
<group>
|
|
||||||
<group string="Pre-Treatments">
|
|
||||||
<field name="pre_treatment_ids" widget="many2many_tags" nolabel="1"/>
|
|
||||||
</group>
|
|
||||||
<group string="Post-Treatments">
|
|
||||||
<field name="post_treatment_ids" widget="many2many_tags" nolabel="1"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
</page>
|
|
||||||
<page string="Description" name="description">
|
|
||||||
<field name="description" placeholder="Detailed description of this coating configuration..."/>
|
|
||||||
</page>
|
|
||||||
<page string="Thickness Options" name="thickness_options">
|
|
||||||
<p class="text-muted">
|
|
||||||
Discrete thickness values the estimator can pick when
|
|
||||||
this coating appears on a sale order line. Each value
|
|
||||||
is driven by the spec this coating is built against
|
|
||||||
(e.g. AMS-2404 Class 4 → 0.0005″ / 0.001″ / 0.0015″).
|
|
||||||
Leave empty if no dropdown is needed for this coating.
|
|
||||||
</p>
|
|
||||||
<field name="thickness_option_ids">
|
|
||||||
<list editable="bottom">
|
|
||||||
<field name="sequence" widget="handle"/>
|
|
||||||
<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"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</page>
|
|
||||||
</notebook>
|
|
||||||
<group>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</group>
|
|
||||||
</sheet>
|
|
||||||
</form>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ===== Coating Configuration Search View ===== -->
|
|
||||||
<record id="view_fp_coating_config_search" model="ir.ui.view">
|
|
||||||
<field name="name">fp.coating.config.search</field>
|
|
||||||
<field name="model">fp.coating.config</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<search>
|
|
||||||
<field name="name"/>
|
|
||||||
<field name="process_type_id"/>
|
|
||||||
<field name="spec_reference"/>
|
|
||||||
<separator/>
|
|
||||||
<filter string="Commercial" name="commercial" domain="[('certification_level','=','commercial')]"/>
|
|
||||||
<filter string="Mil-Spec" name="mil_spec" domain="[('certification_level','=','mil_spec')]"/>
|
|
||||||
<filter string="Nadcap" name="nadcap" domain="[('certification_level','=','nadcap')]"/>
|
|
||||||
<filter string="Nuclear" name="nuclear" domain="[('certification_level','=','nuclear')]"/>
|
|
||||||
<separator/>
|
|
||||||
<filter string="Low Phosphorus" name="low_phos" domain="[('phosphorus_level','=','low_phos')]"/>
|
|
||||||
<filter string="Mid Phosphorus" name="mid_phos" domain="[('phosphorus_level','=','mid_phos')]"/>
|
|
||||||
<filter string="High Phosphorus" name="high_phos" domain="[('phosphorus_level','=','high_phos')]"/>
|
|
||||||
<separator/>
|
|
||||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
|
||||||
<group>
|
|
||||||
<filter string="Process Type" name="group_process_type" context="{'group_by':'process_type_id'}"/>
|
|
||||||
<filter string="Certification Level" name="group_cert_level" context="{'group_by':'certification_level'}"/>
|
|
||||||
</group>
|
|
||||||
</search>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ===== Window Action ===== -->
|
|
||||||
<record id="action_fp_coating_config" model="ir.actions.act_window">
|
|
||||||
<field name="name">Coating Configurations</field>
|
|
||||||
<field name="res_model">fp.coating.config</field>
|
|
||||||
<field name="view_mode">list,form</field>
|
|
||||||
<field name="search_view_id" ref="view_fp_coating_config_search"/>
|
|
||||||
<field name="help" type="html">
|
|
||||||
<p class="o_view_nocontent_smiling_face">
|
|
||||||
No coating configurations defined yet
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Define coating setups with process type, phosphorus level,
|
|
||||||
thickness range, spec reference, and required treatments.
|
|
||||||
</p>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2026 Nexa Systems Inc.
|
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
Part of the Fusion Plating product family.
|
|
||||||
|
|
||||||
Standalone views for fp.coating.thickness so SO-line m2o pickers
|
|
||||||
can offer "Create and edit..." — the inline-on-coating-config
|
|
||||||
editor was the only way to add thicknesses pre-Sub-12d.
|
|
||||||
-->
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<record id="view_fp_coating_thickness_list" model="ir.ui.view">
|
|
||||||
<field name="name">fp.coating.thickness.list</field>
|
|
||||||
<field name="model">fp.coating.thickness</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list string="Coating Thicknesses" decoration-muted="not active">
|
|
||||||
<field name="sequence" widget="handle"/>
|
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="value" string="Nominal"/>
|
|
||||||
<field name="value_min" string="Min" optional="show"/>
|
|
||||||
<field name="value_max" string="Max" optional="show"/>
|
|
||||||
<field name="uom"/>
|
|
||||||
<field name="display_name" string="Label"/>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="view_fp_coating_thickness_form" model="ir.ui.view">
|
|
||||||
<field name="name">fp.coating.thickness.form</field>
|
|
||||||
<field name="model">fp.coating.thickness</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<form string="Coating Thickness">
|
|
||||||
<sheet>
|
|
||||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
|
||||||
<div class="oe_title">
|
|
||||||
<label for="display_name" string="Thickness"/>
|
|
||||||
<h2><field name="display_name" readonly="1" placeholder="Auto-generated from value + UoM"/></h2>
|
|
||||||
</div>
|
|
||||||
<group>
|
|
||||||
<group string="Spec">
|
|
||||||
<field name="coating_config_id"
|
|
||||||
options="{'no_create_edit': True}"/>
|
|
||||||
<field name="value" string="Nominal"/>
|
|
||||||
<field name="uom"/>
|
|
||||||
</group>
|
|
||||||
<group string="Acceptance Band (optional)">
|
|
||||||
<field name="value_min" string="Min"/>
|
|
||||||
<field name="value_max" string="Max"/>
|
|
||||||
<div colspan="2" class="text-muted">
|
|
||||||
Set Min/Max when the customer spec is a
|
|
||||||
range (e.g. AMS-2404 Class 4 = 0.001"–0.0015").
|
|
||||||
QC readings outside the band fail.
|
|
||||||
</div>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<field name="sequence"/>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
</sheet>
|
|
||||||
</form>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="view_fp_coating_thickness_search" model="ir.ui.view">
|
|
||||||
<field name="name">fp.coating.thickness.search</field>
|
|
||||||
<field name="model">fp.coating.thickness</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<search>
|
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="display_name"/>
|
|
||||||
<field name="uom"/>
|
|
||||||
<separator/>
|
|
||||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
|
||||||
<group>
|
|
||||||
<filter string="Coating" name="group_coating"
|
|
||||||
context="{'group_by':'coating_config_id'}"/>
|
|
||||||
<filter string="UoM" name="group_uom"
|
|
||||||
context="{'group_by':'uom'}"/>
|
|
||||||
</group>
|
|
||||||
</search>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="action_fp_coating_thickness" model="ir.actions.act_window">
|
|
||||||
<field name="name">Coating Thicknesses</field>
|
|
||||||
<field name="res_model">fp.coating.thickness</field>
|
|
||||||
<field name="view_mode">list,form</field>
|
|
||||||
<field name="search_view_id" ref="view_fp_coating_thickness_search"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -80,41 +80,27 @@
|
|||||||
action="action_fp_part_catalog_import_wizard"
|
action="action_fp_part_catalog_import_wizard"
|
||||||
sequence="45"/>
|
sequence="45"/>
|
||||||
|
|
||||||
<!-- ===== CONFIGURATOR submenu (admin-only: coating/pricing/treatments) ===== -->
|
<!-- The Configurator top-level menu was retired in Phase F (2026-05-15)
|
||||||
<menuitem id="menu_fp_configurator"
|
after the Promote Customer Spec refactor left only 3 admin items
|
||||||
name="Configurator"
|
under it. They've been re-homed into the Configuration hub's
|
||||||
parent="fusion_plating.menu_fp_root"
|
themed folders, where managers expect to find admin records:
|
||||||
sequence="8"
|
Pricing Rules → Configuration → Pricing & Billing
|
||||||
groups="group_fp_estimator"/>
|
Materials → Configuration → Materials & Tanks
|
||||||
|
Line Desc Tpl → Configuration → Quality & Documents (in
|
||||||
<menuitem id="menu_fp_coating_configs"
|
fp_sale_description_template_views.xml)
|
||||||
name="Coating Configurations"
|
-->
|
||||||
parent="menu_fp_configurator"
|
|
||||||
action="action_fp_coating_config"
|
|
||||||
sequence="20"/>
|
|
||||||
|
|
||||||
<menuitem id="menu_fp_pricing_rules"
|
<menuitem id="menu_fp_pricing_rules"
|
||||||
name="Pricing Rules"
|
name="Pricing Rules"
|
||||||
parent="menu_fp_configurator"
|
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||||
action="action_fp_pricing_rule"
|
action="action_fp_pricing_rule"
|
||||||
sequence="30"/>
|
sequence="40"
|
||||||
|
groups="group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
|
||||||
<menuitem id="menu_fp_customer_price_lists"
|
|
||||||
name="Customer Price Lists"
|
|
||||||
parent="menu_fp_configurator"
|
|
||||||
action="action_fp_customer_price_list"
|
|
||||||
sequence="35"/>
|
|
||||||
|
|
||||||
<menuitem id="menu_fp_treatments"
|
|
||||||
name="Treatments"
|
|
||||||
parent="menu_fp_configurator"
|
|
||||||
action="action_fp_treatment"
|
|
||||||
sequence="40"/>
|
|
||||||
|
|
||||||
<menuitem id="menu_fp_part_materials"
|
<menuitem id="menu_fp_part_materials"
|
||||||
name="Materials"
|
name="Materials"
|
||||||
parent="menu_fp_configurator"
|
parent="fusion_plating.menu_fp_config_materials_tanks"
|
||||||
action="action_fp_part_material"
|
action="action_fp_part_material"
|
||||||
sequence="50"/>
|
sequence="40"
|
||||||
|
groups="group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<record id="view_fp_customer_price_list_list" model="ir.ui.view">
|
|
||||||
<field name="name">fp.customer.price.list.list</field>
|
|
||||||
<field name="model">fp.customer.price.list</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list editable="bottom">
|
|
||||||
<field name="partner_id"/>
|
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="currency_id" column_invisible="True"/>
|
|
||||||
<field name="unit_price" widget="monetary"
|
|
||||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
|
||||||
<field name="price_uom"/>
|
|
||||||
<field name="min_quantity"/>
|
|
||||||
<field name="effective_from"/>
|
|
||||||
<field name="effective_to"/>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="view_fp_customer_price_list_form" model="ir.ui.view">
|
|
||||||
<field name="name">fp.customer.price.list.form</field>
|
|
||||||
<field name="model">fp.customer.price.list</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<form>
|
|
||||||
<sheet>
|
|
||||||
<div class="oe_title">
|
|
||||||
<h2><field name="name" readonly="1"/></h2>
|
|
||||||
</div>
|
|
||||||
<group>
|
|
||||||
<group>
|
|
||||||
<field name="partner_id"/>
|
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="currency_id"/>
|
|
||||||
<field name="unit_price" widget="monetary"
|
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
|
||||||
<field name="price_uom"/>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<field name="effective_from"/>
|
|
||||||
<field name="effective_to"/>
|
|
||||||
<field name="min_quantity"/>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<separator string="Notes"/>
|
|
||||||
<field name="notes" colspan="2"/>
|
|
||||||
</sheet>
|
|
||||||
<chatter/>
|
|
||||||
</form>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="view_fp_customer_price_list_search" model="ir.ui.view">
|
|
||||||
<field name="name">fp.customer.price.list.search</field>
|
|
||||||
<field name="model">fp.customer.price.list</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<search>
|
|
||||||
<field name="partner_id"/>
|
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<filter name="active" string="Active"
|
|
||||||
domain="[('active', '=', True)]"/>
|
|
||||||
<filter name="expired" string="Expired"
|
|
||||||
domain="[('effective_to', '<', context_today().strftime('%Y-%m-%d'))]"/>
|
|
||||||
<separator/>
|
|
||||||
<group>
|
|
||||||
<filter name="group_customer" string="Customer"
|
|
||||||
context="{'group_by': 'partner_id'}"/>
|
|
||||||
<filter name="group_coating" string="Coating"
|
|
||||||
context="{'group_by': 'coating_config_id'}"/>
|
|
||||||
</group>
|
|
||||||
</search>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="action_fp_customer_price_list" model="ir.actions.act_window">
|
|
||||||
<field name="name">Customer Price Lists</field>
|
|
||||||
<field name="res_model">fp.customer.price.list</field>
|
|
||||||
<field name="view_mode">list,form</field>
|
|
||||||
<field name="search_view_id" ref="view_fp_customer_price_list_search"/>
|
|
||||||
<field name="context">{'search_default_active': 1}</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -201,20 +201,18 @@
|
|||||||
class="btn-link"/>
|
class="btn-link"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
<separator string="Default Treatments" class="mt-4"/>
|
<!-- Default Specification picker added by
|
||||||
|
fusion_plating_quality view inherit. -->
|
||||||
|
<separator string="Default Thickness" class="mt-4"/>
|
||||||
<group>
|
<group>
|
||||||
<field name="x_fc_default_coating_config_id"
|
<field name="x_fc_default_thickness_range"
|
||||||
string="Default Treatment"
|
placeholder="e.g. 0.0005-0.0008 mils"/>
|
||||||
options="{'no_create_edit': True}"/>
|
|
||||||
<field name="x_fc_default_treatment_ids"
|
|
||||||
string="Default Additional Treatments"
|
|
||||||
widget="many2many_tags"
|
|
||||||
options="{'no_create_edit': True}"/>
|
|
||||||
</group>
|
</group>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Seeds the treatment fields on new direct-order
|
Defaults pre-fill new direct-order lines
|
||||||
lines for this part. Updated whenever "Save as
|
for this part. Thickness also auto-fills
|
||||||
Default" is ticked while placing an order.
|
from the most recent order for the same
|
||||||
|
(part, customer) pair when one exists.
|
||||||
</p>
|
</p>
|
||||||
</page>
|
</page>
|
||||||
<page string="Dimensions & Complexity" name="dimensions">
|
<page string="Dimensions & Complexity" name="dimensions">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<list string="Pricing Rules" decoration-muted="not active">
|
<list string="Pricing Rules" decoration-muted="not active">
|
||||||
<field name="sequence" widget="handle"/>
|
<field name="sequence" widget="handle"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="substrate_material"/>
|
<field name="substrate_material"/>
|
||||||
<field name="certification_level"/>
|
<field name="certification_level"/>
|
||||||
<field name="pricing_method"/>
|
<field name="pricing_method"/>
|
||||||
@@ -42,7 +41,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<group string="Filters">
|
<group string="Filters">
|
||||||
<group>
|
<group>
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="substrate_material"/>
|
<field name="substrate_material"/>
|
||||||
<field name="certification_level"/>
|
<field name="certification_level"/>
|
||||||
</group>
|
</group>
|
||||||
@@ -104,7 +102,6 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<search>
|
<search>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/>
|
<filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/>
|
||||||
<filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/>
|
<filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/>
|
||||||
@@ -113,7 +110,6 @@
|
|||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||||
<group>
|
<group>
|
||||||
<filter string="Coating Config" name="group_coating_config" context="{'group_by':'coating_config_id'}"/>
|
|
||||||
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
|
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
<group string="Customer & Part">
|
<group string="Customer & Part">
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="part_catalog_id"/>
|
<field name="part_catalog_id"/>
|
||||||
<field name="coating_config_id"/>
|
<field name="recipe_id"/>
|
||||||
<!-- 3D File: upload before, filename + clear button after -->
|
<!-- 3D File: upload before, filename + clear button after -->
|
||||||
<field name="upload_3d_file" filename="upload_3d_filename"
|
<field name="upload_3d_file" filename="upload_3d_filename"
|
||||||
invisible="state != 'draft' or model_attachment_id"
|
invisible="state != 'draft' or model_attachment_id"
|
||||||
@@ -325,7 +325,7 @@
|
|||||||
<field name="create_date" string="Date"/>
|
<field name="create_date" string="Date"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="coating_config_id"/>
|
<field name="recipe_id"/>
|
||||||
<field name="surface_area"/>
|
<field name="surface_area"/>
|
||||||
<field name="quantity"/>
|
<field name="quantity"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
@@ -350,14 +350,14 @@
|
|||||||
<search>
|
<search>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="coating_config_id"/>
|
<field name="recipe_id"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
||||||
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||||
<group>
|
<group>
|
||||||
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
|
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
|
||||||
<filter string="Coating Config" name="group_coating" context="{'group_by': 'coating_config_id'}"/>
|
<filter string="Recipe" name="group_recipe" context="{'group_by': 'recipe_id'}"/>
|
||||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
decoration-danger="tag == 'rework'"
|
decoration-danger="tag == 'rework'"
|
||||||
decoration-success="tag in ('aerospace','nuclear')"/>
|
decoration-success="tag in ('aerospace','nuclear')"/>
|
||||||
<field name="partner_id" optional="show"/>
|
<field name="partner_id" optional="show"/>
|
||||||
<field name="coating_config_id" optional="hide"/>
|
|
||||||
<field name="usage_count" string="Used"/>
|
<field name="usage_count" string="Used"/>
|
||||||
<field name="active" widget="boolean_toggle"/>
|
<field name="active" widget="boolean_toggle"/>
|
||||||
</list>
|
</list>
|
||||||
@@ -46,9 +45,6 @@
|
|||||||
<field name="tag"/>
|
<field name="tag"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="coating_config_id"
|
|
||||||
help="Only used for generic (no-part) templates."
|
|
||||||
invisible="part_catalog_id"/>
|
|
||||||
<field name="sequence"/>
|
<field name="sequence"/>
|
||||||
<field name="usage_count" readonly="1"/>
|
<field name="usage_count" readonly="1"/>
|
||||||
<field name="active" widget="boolean_toggle"/>
|
<field name="active" widget="boolean_toggle"/>
|
||||||
@@ -75,7 +71,6 @@
|
|||||||
<field name="internal_description"/>
|
<field name="internal_description"/>
|
||||||
<field name="customer_facing_description"/>
|
<field name="customer_facing_description"/>
|
||||||
<field name="part_catalog_id"/>
|
<field name="part_catalog_id"/>
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="tag"/>
|
<field name="tag"/>
|
||||||
<filter name="active" string="Active" domain="[('active','=',True)]"/>
|
<filter name="active" string="Active" domain="[('active','=',True)]"/>
|
||||||
@@ -114,9 +109,10 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<menuitem id="menu_fp_sale_description_templates"
|
<menuitem id="menu_fp_sale_description_templates"
|
||||||
name="Line Descriptions"
|
name="Line Description Templates"
|
||||||
parent="menu_fp_configurator"
|
parent="fusion_plating.menu_fp_config_quality_docs"
|
||||||
action="action_fp_sale_description_template"
|
action="action_fp_sale_description_template"
|
||||||
sequence="45"/>
|
sequence="90"
|
||||||
|
groups="fusion_plating_configurator.group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2026 Nexa Systems Inc.
|
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
|
||||||
Part of the Fusion Plating product family.
|
|
||||||
-->
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<!-- ===== Treatment List View ===== -->
|
|
||||||
<record id="view_fp_treatment_list" model="ir.ui.view">
|
|
||||||
<field name="name">fp.treatment.list</field>
|
|
||||||
<field name="model">fp.treatment</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list string="Treatments">
|
|
||||||
<field name="sequence" widget="handle"/>
|
|
||||||
<field name="name"/>
|
|
||||||
<field name="treatment_type"/>
|
|
||||||
<field name="default_duration_minutes" string="Duration (min)"/>
|
|
||||||
<field name="currency_id" column_invisible="1"/>
|
|
||||||
<field name="default_cost" widget="monetary"
|
|
||||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ===== Treatment Form View ===== -->
|
|
||||||
<record id="view_fp_treatment_form" model="ir.ui.view">
|
|
||||||
<field name="name">fp.treatment.form</field>
|
|
||||||
<field name="model">fp.treatment</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<form string="Treatment">
|
|
||||||
<sheet>
|
|
||||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
|
||||||
<div class="oe_title">
|
|
||||||
<label for="name"/>
|
|
||||||
<h1><field name="name" placeholder="e.g. Bead Blast"/></h1>
|
|
||||||
</div>
|
|
||||||
<group>
|
|
||||||
<group>
|
|
||||||
<field name="treatment_type"/>
|
|
||||||
<field name="sequence"/>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<label for="default_duration_minutes"/>
|
|
||||||
<div class="o_row">
|
|
||||||
<field name="default_duration_minutes" nolabel="1" class="oe_inline"/>
|
|
||||||
<span class="ms-1">min</span>
|
|
||||||
</div>
|
|
||||||
<field name="currency_id"/>
|
|
||||||
<field name="default_cost" widget="monetary"
|
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<field name="description" placeholder="Description of this treatment step..."/>
|
|
||||||
<group>
|
|
||||||
<field name="active" widget="boolean_toggle"/>
|
|
||||||
</group>
|
|
||||||
</sheet>
|
|
||||||
</form>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ===== Treatment Search View ===== -->
|
|
||||||
<record id="view_fp_treatment_search" model="ir.ui.view">
|
|
||||||
<field name="name">fp.treatment.search</field>
|
|
||||||
<field name="model">fp.treatment</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<search>
|
|
||||||
<field name="name"/>
|
|
||||||
<separator/>
|
|
||||||
<filter string="Pre-Treatment" name="pre" domain="[('treatment_type','=','pre')]"/>
|
|
||||||
<filter string="Post-Treatment" name="post" domain="[('treatment_type','=','post')]"/>
|
|
||||||
<separator/>
|
|
||||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
|
||||||
<group>
|
|
||||||
<filter string="Type" name="group_type" context="{'group_by':'treatment_type'}"/>
|
|
||||||
</group>
|
|
||||||
</search>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ===== Window Action ===== -->
|
|
||||||
<record id="action_fp_treatment" model="ir.actions.act_window">
|
|
||||||
<field name="name">Treatments</field>
|
|
||||||
<field name="res_model">fp.treatment</field>
|
|
||||||
<field name="view_mode">list,form</field>
|
|
||||||
<field name="search_view_id" ref="view_fp_treatment_search"/>
|
|
||||||
<field name="help" type="html">
|
|
||||||
<p class="o_view_nocontent_smiling_face">
|
|
||||||
No treatments defined yet
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Add pre-treatment steps (bead blast, zincate, acid etch) and
|
|
||||||
post-treatment steps (bake, passivate, chromate seal).
|
|
||||||
</p>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
so you can confirm an order has the right parts/coatings
|
so you can confirm an order has the right parts/coatings
|
||||||
without scrolling pricing columns. The pre-Sub-12 SO-
|
without scrolling pricing columns. The pre-Sub-12 SO-
|
||||||
header singletons (x_fc_part_catalog_id /
|
header singletons (x_fc_part_catalog_id /
|
||||||
x_fc_coating_config_id) only ever populated when the
|
x_fc_customer_spec_id) only ever populated when the
|
||||||
order was built via the quote configurator — they're
|
order was built via the quote configurator — they're
|
||||||
silent on direct orders, which is why they appeared
|
silent on direct orders, which is why they appeared
|
||||||
empty after confirm. They still exist on the model
|
empty after confirm. They still exist on the model
|
||||||
@@ -118,8 +118,7 @@
|
|||||||
readonly="1">
|
readonly="1">
|
||||||
<list create="false" delete="false" edit="false">
|
<list create="false" delete="false" edit="false">
|
||||||
<field name="x_fc_part_catalog_id"/>
|
<field name="x_fc_part_catalog_id"/>
|
||||||
<field name="x_fc_coating_config_id"/>
|
<field name="x_fc_thickness_range" optional="show"/>
|
||||||
<field name="x_fc_thickness_id" optional="show"/>
|
|
||||||
<field name="x_fc_process_variant_id" optional="show"
|
<field name="x_fc_process_variant_id" optional="show"
|
||||||
string="Process"/>
|
string="Process"/>
|
||||||
<field name="product_uom_qty" string="Qty"/>
|
<field name="product_uom_qty" string="Qty"/>
|
||||||
@@ -251,7 +250,6 @@
|
|||||||
<field name="x_fc_internal_description"
|
<field name="x_fc_internal_description"
|
||||||
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
|
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="x_fc_coating_config_id" optional="show"/>
|
|
||||||
<field name="x_fc_process_variant_id"
|
<field name="x_fc_process_variant_id"
|
||||||
string="Process / Recipe"
|
string="Process / Recipe"
|
||||||
options="{'no_quick_create': True}"
|
options="{'no_quick_create': True}"
|
||||||
@@ -262,11 +260,8 @@
|
|||||||
widget="boolean_toggle"
|
widget="boolean_toggle"
|
||||||
invisible="not x_fc_process_variant_id"
|
invisible="not x_fc_process_variant_id"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="x_fc_thickness_id"
|
<field name="x_fc_thickness_range"
|
||||||
options="{'no_quick_create': True}"
|
placeholder="e.g. 0.0005-0.0008 mils"
|
||||||
context="{'default_coating_config_id': x_fc_coating_config_id}"
|
|
||||||
domain="[('coating_config_id', '=', x_fc_coating_config_id)]"
|
|
||||||
invisible="not x_fc_coating_config_id"
|
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="x_fc_serial_ids"
|
<field name="x_fc_serial_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
@@ -290,7 +285,6 @@
|
|||||||
<field name="x_fc_revision_snapshot"
|
<field name="x_fc_revision_snapshot"
|
||||||
readonly="1"
|
readonly="1"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
|
|
||||||
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
|
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
|
||||||
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
|
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
|
||||||
<field name="x_fc_effective_part_deadline" string="Effective Deadline"
|
<field name="x_fc_effective_part_deadline" string="Effective Deadline"
|
||||||
@@ -335,7 +329,6 @@
|
|||||||
<field name="x_fc_wo_completion" optional="show"/>
|
<field name="x_fc_wo_completion" optional="show"/>
|
||||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||||
<field name="x_fc_part_catalog_id" optional="hide"/>
|
<field name="x_fc_part_catalog_id" optional="hide"/>
|
||||||
<field name="x_fc_coating_config_id" optional="hide"/>
|
|
||||||
<field name="amount_total" sum="Total"/>
|
<field name="amount_total" sum="Total"/>
|
||||||
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
@@ -363,7 +356,6 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<kanban default_group_by="x_fc_part_catalog_id" records_draggable="0">
|
<kanban default_group_by="x_fc_part_catalog_id" records_draggable="0">
|
||||||
<field name="x_fc_part_catalog_id"/>
|
<field name="x_fc_part_catalog_id"/>
|
||||||
<field name="x_fc_coating_config_id"/>
|
|
||||||
<field name="product_uom_qty"/>
|
<field name="product_uom_qty"/>
|
||||||
<field name="qty_delivered"/>
|
<field name="qty_delivered"/>
|
||||||
<field name="x_fc_wo_group_tag"/>
|
<field name="x_fc_wo_group_tag"/>
|
||||||
@@ -373,7 +365,7 @@
|
|||||||
<t t-name="card">
|
<t t-name="card">
|
||||||
<div class="o_kanban_card_content">
|
<div class="o_kanban_card_content">
|
||||||
<div class="o_kanban_record_title">
|
<div class="o_kanban_record_title">
|
||||||
<strong><field name="x_fc_coating_config_id"/></strong>
|
<strong><field name="x_fc_part_catalog_id"/></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
Qty: <field name="product_uom_qty"/>
|
Qty: <field name="product_uom_qty"/>
|
||||||
@@ -399,7 +391,6 @@
|
|||||||
<kanban default_group_by="x_fc_wo_group_tag" records_draggable="0">
|
<kanban default_group_by="x_fc_wo_group_tag" records_draggable="0">
|
||||||
<field name="x_fc_wo_group_tag"/>
|
<field name="x_fc_wo_group_tag"/>
|
||||||
<field name="x_fc_part_catalog_id"/>
|
<field name="x_fc_part_catalog_id"/>
|
||||||
<field name="x_fc_coating_config_id"/>
|
|
||||||
<field name="product_uom_qty"/>
|
<field name="product_uom_qty"/>
|
||||||
<templates>
|
<templates>
|
||||||
<t t-name="card">
|
<t t-name="card">
|
||||||
@@ -407,9 +398,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong><field name="x_fc_part_catalog_id"/></strong>
|
<strong><field name="x_fc_part_catalog_id"/></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted">
|
|
||||||
<field name="x_fc_coating_config_id"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
Qty: <field name="product_uom_qty"/>
|
Qty: <field name="product_uom_qty"/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ class FpAddFromQuoteWizard(models.TransientModel):
|
|||||||
wizard = self.direct_order_wizard_id
|
wizard = self.direct_order_wizard_id
|
||||||
copied = 0
|
copied = 0
|
||||||
for q in self.quote_ids:
|
for q in self.quote_ids:
|
||||||
if not q.part_catalog_id or not q.coating_config_id:
|
if not q.part_catalog_id or not q.recipe_id:
|
||||||
continue
|
continue
|
||||||
Line._create_from_quote(q, wizard)
|
Line._create_from_quote(q, wizard)
|
||||||
copied += 1
|
copied += 1
|
||||||
|
|
||||||
if not copied:
|
if not copied:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'The selected quotes do not have both part and coating set, '
|
'The selected quotes do not have both part and recipe set, '
|
||||||
'so nothing could be copied.'
|
'so nothing could be copied.'
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<list>
|
<list>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="part_catalog_id"/>
|
<field name="part_catalog_id"/>
|
||||||
<field name="coating_config_id"/>
|
<field name="recipe_id"/>
|
||||||
<field name="quantity"/>
|
<field name="quantity"/>
|
||||||
<field name="calculated_price" widget="monetary"/>
|
<field name="calculated_price" widget="monetary"/>
|
||||||
<field name="estimator_override_price" widget="monetary"/>
|
<field name="estimator_override_price" widget="monetary"/>
|
||||||
|
|||||||
@@ -53,14 +53,12 @@ class FpAddFromSoWizard(models.TransientModel):
|
|||||||
wizard = self.direct_order_wizard_id
|
wizard = self.direct_order_wizard_id
|
||||||
copied = 0
|
copied = 0
|
||||||
for src in self.source_line_ids:
|
for src in self.source_line_ids:
|
||||||
if not src.x_fc_part_catalog_id or not src.x_fc_coating_config_id:
|
if not src.x_fc_part_catalog_id:
|
||||||
# Skip SO lines that predate the plating fields
|
# Skip non-plating SO lines
|
||||||
continue
|
continue
|
||||||
Line.create({
|
Line.create({
|
||||||
'wizard_id': wizard.id,
|
'wizard_id': wizard.id,
|
||||||
'part_catalog_id': src.x_fc_part_catalog_id.id,
|
'part_catalog_id': src.x_fc_part_catalog_id.id,
|
||||||
'coating_config_id': src.x_fc_coating_config_id.id,
|
|
||||||
'treatment_ids': [(6, 0, src.x_fc_treatment_ids.ids)],
|
|
||||||
'quantity': int(src.product_uom_qty) or 1,
|
'quantity': int(src.product_uom_qty) or 1,
|
||||||
'unit_price': src.price_unit or 0.0,
|
'unit_price': src.price_unit or 0.0,
|
||||||
'part_deadline': src.x_fc_part_deadline,
|
'part_deadline': src.x_fc_part_deadline,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<list>
|
<list>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="x_fc_part_catalog_id"/>
|
<field name="x_fc_part_catalog_id"/>
|
||||||
<field name="x_fc_coating_config_id"/>
|
<field name="x_fc_part_deadline" optional="hide"/>
|
||||||
<field name="product_uom_qty"/>
|
<field name="product_uom_qty"/>
|
||||||
<field name="price_unit"/>
|
<field name="price_unit"/>
|
||||||
<field name="x_fc_part_deadline"/>
|
<field name="x_fc_part_deadline"/>
|
||||||
|
|||||||
@@ -51,20 +51,9 @@ class FpDirectOrderLine(models.Model):
|
|||||||
new_drawing_filename = fields.Char(string='Filename')
|
new_drawing_filename = fields.Char(string='Filename')
|
||||||
revision_note = fields.Char(string='Revision Note')
|
revision_note = fields.Char(string='Revision Note')
|
||||||
|
|
||||||
# ---- Treatments ----
|
# Specification picker (customer_spec_id) added by
|
||||||
coating_config_id = fields.Many2one(
|
# fusion_plating_quality. Legacy coating_config_id +
|
||||||
'fp.coating.config',
|
# treatment_ids removed.
|
||||||
string='Primary Treatment',
|
|
||||||
help='Optional. Some orders are non-coating work (re-inspection, '
|
|
||||||
'rework, masking-only, etc.) and the operator picks the '
|
|
||||||
'workflow downstream — leaving this blank lets that path '
|
|
||||||
'through.',
|
|
||||||
)
|
|
||||||
treatment_ids = fields.Many2many(
|
|
||||||
'fp.treatment',
|
|
||||||
string='Additional Treatments',
|
|
||||||
help='Extra pre/post treatments applied to this line.',
|
|
||||||
)
|
|
||||||
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
|
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
|
||||||
# now lets the estimator pick ANY root recipe in the system: the
|
# now lets the estimator pick ANY root recipe in the system: the
|
||||||
# part's own variants, another customer's variants, or a template
|
# part's own variants, another customer's variants, or a template
|
||||||
@@ -105,8 +94,7 @@ class FpDirectOrderLine(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('process_variant_id',
|
@api.depends('process_variant_id',
|
||||||
'part_catalog_id.default_process_id',
|
'part_catalog_id.default_process_id')
|
||||||
'coating_config_id.recipe_id')
|
|
||||||
def _compute_effective_process(self):
|
def _compute_effective_process(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.process_variant_id:
|
if rec.process_variant_id:
|
||||||
@@ -120,12 +108,6 @@ class FpDirectOrderLine(models.Model):
|
|||||||
rec.effective_process_id = part_proc
|
rec.effective_process_id = part_proc
|
||||||
rec.effective_process_source = 'Part default'
|
rec.effective_process_source = 'Part default'
|
||||||
continue
|
continue
|
||||||
cc_proc = (rec.coating_config_id.recipe_id
|
|
||||||
if rec.coating_config_id else False)
|
|
||||||
if cc_proc:
|
|
||||||
rec.effective_process_id = cc_proc
|
|
||||||
rec.effective_process_source = 'Coating default'
|
|
||||||
continue
|
|
||||||
rec.effective_process_id = False
|
rec.effective_process_id = False
|
||||||
rec.effective_process_source = False
|
rec.effective_process_source = False
|
||||||
|
|
||||||
@@ -166,33 +148,26 @@ class FpDirectOrderLine(models.Model):
|
|||||||
if not rec.part_catalog_id:
|
if not rec.part_catalog_id:
|
||||||
continue
|
continue
|
||||||
part = rec.part_catalog_id
|
part = rec.part_catalog_id
|
||||||
has_default_coating = bool(getattr(
|
# Default-spec auto-fill is implemented by an inherit in
|
||||||
part, 'x_fc_default_coating_config_id', False))
|
# fusion_plating_quality (where customer_spec_id field lives).
|
||||||
has_default_treatments = bool(getattr(
|
has_default_spec = bool(getattr(
|
||||||
part, 'x_fc_default_treatment_ids', False))
|
part, 'x_fc_default_customer_spec_id', False))
|
||||||
# Pre-fill default coating if the line is empty.
|
# New-part auto-suggest: if no default spec exists, this is
|
||||||
if not rec.coating_config_id and has_default_coating:
|
|
||||||
rec.coating_config_id = part.x_fc_default_coating_config_id
|
|
||||||
# Pre-fill default treatments if any are configured.
|
|
||||||
if not rec.treatment_ids and has_default_treatments:
|
|
||||||
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
|
|
||||||
# New-part auto-suggest: if neither default exists, this is
|
|
||||||
# likely a first-time use of the part. Auto-tick the
|
# likely a first-time use of the part. Auto-tick the
|
||||||
# push_to_defaults toggle so whatever Sarah picks becomes
|
# push_to_defaults toggle so whatever Sarah picks becomes
|
||||||
# the saved default — surface a warning popup so she knows.
|
# the saved default — surface a warning popup so she knows.
|
||||||
# `is_one_off` always wins (operator opted out of catalog
|
# `is_one_off` always wins (operator opted out of catalog
|
||||||
# persistence), so don't auto-tick in that case.
|
# persistence), so don't auto-tick in that case.
|
||||||
if (not has_default_coating
|
if (not has_default_spec
|
||||||
and not has_default_treatments
|
|
||||||
and not rec.is_one_off
|
and not rec.is_one_off
|
||||||
and not rec.push_to_defaults):
|
and not rec.push_to_defaults):
|
||||||
rec.push_to_defaults = True
|
rec.push_to_defaults = True
|
||||||
warning = {
|
warning = {
|
||||||
'title': _('First-Time Part — Defaults Will Be Saved'),
|
'title': _('First-Time Part — Defaults Will Be Saved'),
|
||||||
'message': _(
|
'message': _(
|
||||||
'%(part)s has no saved coating / treatments. '
|
'%(part)s has no saved specification. '
|
||||||
'The coating + treatments you pick on this line '
|
'The specification you pick on this line will '
|
||||||
'will be saved as the part\'s defaults so the '
|
'be saved as the part\'s default so the '
|
||||||
'next order auto-fills them. Untick "Save as '
|
'next order auto-fills them. Untick "Save as '
|
||||||
'Default" on the line if you don\'t want this.'
|
'Default" on the line if you don\'t want this.'
|
||||||
) % {'part': part.display_name or part.part_number or '(part)'},
|
) % {'part': part.display_name or part.part_number or '(part)'},
|
||||||
@@ -265,11 +240,11 @@ class FpDirectOrderLine(models.Model):
|
|||||||
start_at_node_id = fields.Many2one(
|
start_at_node_id = fields.Many2one(
|
||||||
'fusion.plating.process.node',
|
'fusion.plating.process.node',
|
||||||
string='Start at Node',
|
string='Start at Node',
|
||||||
domain="[('id', 'child_of', coating_config_id and coating_config_id.recipe_id.id or 0)]",
|
domain="[('id', 'child_of', process_variant_id and process_variant_id.id or 0)]",
|
||||||
help='For re-work jobs: pick the recipe step where this job should '
|
help='For re-work jobs: pick the recipe step where this job should '
|
||||||
'begin. Pick a coating first — nodes are scoped to its '
|
'begin. Pick a recipe first — nodes are scoped to it. Skips '
|
||||||
'recipe tree. Skips earlier steps in the generated WO but '
|
'earlier steps in the generated WO but keeps later siblings '
|
||||||
'keeps later siblings and sub-processes.',
|
'and sub-processes.',
|
||||||
)
|
)
|
||||||
is_one_off = fields.Boolean(
|
is_one_off = fields.Boolean(
|
||||||
string='One-off Part',
|
string='One-off Part',
|
||||||
@@ -419,11 +394,11 @@ class FpDirectOrderLine(models.Model):
|
|||||||
if rec.serial_id and rec.serial_id not in rec.serial_ids:
|
if rec.serial_id and rec.serial_id not in rec.serial_ids:
|
||||||
rec.serial_ids = [(4, rec.serial_id.id)]
|
rec.serial_ids = [(4, rec.serial_id.id)]
|
||||||
job_number = fields.Char(string='Job #')
|
job_number = fields.Char(string='Job #')
|
||||||
thickness_id = fields.Many2one(
|
thickness_range = fields.Char(
|
||||||
'fp.coating.thickness',
|
|
||||||
string='Thickness',
|
string='Thickness',
|
||||||
domain="[('coating_config_id', '=', coating_config_id)]",
|
help='Free-form range, e.g. "0.0005-0.0008 mils" or "5-10 mils". '
|
||||||
ondelete='set null',
|
'Auto-fills from last order for this (part, customer) pair, '
|
||||||
|
'or from the part\'s default range.',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Computes ----
|
# ---- Computes ----
|
||||||
@@ -432,22 +407,45 @@ class FpDirectOrderLine(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
|
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
|
||||||
|
|
||||||
@api.depends('part_catalog_id', 'coating_config_id', 'unit_price', 'quantity')
|
@api.depends('part_catalog_id', 'unit_price', 'quantity')
|
||||||
def _compute_is_missing_info(self):
|
def _compute_is_missing_info(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.is_missing_info = not (
|
rec.is_missing_info = not (
|
||||||
rec.part_catalog_id
|
rec.part_catalog_id
|
||||||
and rec.coating_config_id
|
|
||||||
and rec.unit_price
|
and rec.unit_price
|
||||||
and rec.quantity
|
and rec.quantity
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.onchange('coating_config_id')
|
@api.onchange('part_catalog_id')
|
||||||
def _onchange_coating_clears_thickness(self):
|
def _onchange_part_default_thickness(self):
|
||||||
|
"""Auto-fill thickness range — same chain as the SO line.
|
||||||
|
|
||||||
|
1. Operator already typed → keep
|
||||||
|
2. Most recent SO line for (part, customer) with a thickness → copy
|
||||||
|
3. Part's x_fc_default_thickness_range → copy
|
||||||
|
4. Blank
|
||||||
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if (rec.thickness_id
|
if rec.thickness_range:
|
||||||
and rec.thickness_id.coating_config_id != rec.coating_config_id):
|
continue
|
||||||
rec.thickness_id = False
|
if not rec.part_catalog_id:
|
||||||
|
continue
|
||||||
|
partner = rec.wizard_id.partner_id
|
||||||
|
if partner:
|
||||||
|
recent = self.env['sale.order.line'].search([
|
||||||
|
('x_fc_part_catalog_id', '=', rec.part_catalog_id.id),
|
||||||
|
('order_id.partner_id', '=', partner.id),
|
||||||
|
('x_fc_thickness_range', '!=', False),
|
||||||
|
('x_fc_thickness_range', '!=', ''),
|
||||||
|
], order='create_date desc', limit=1)
|
||||||
|
if recent:
|
||||||
|
rec.thickness_range = recent.x_fc_thickness_range
|
||||||
|
continue
|
||||||
|
part_default = getattr(
|
||||||
|
rec.part_catalog_id, 'x_fc_default_thickness_range', None,
|
||||||
|
)
|
||||||
|
if part_default:
|
||||||
|
rec.thickness_range = part_default
|
||||||
|
|
||||||
def action_generate_serial(self):
|
def action_generate_serial(self):
|
||||||
"""Generate one auto-sequenced fp.serial and append to the M2M.
|
"""Generate one auto-sequenced fp.serial and append to the M2M.
|
||||||
@@ -495,14 +493,16 @@ class FpDirectOrderLine(models.Model):
|
|||||||
# ---- Onchange ----
|
# ---- Onchange ----
|
||||||
@api.onchange('quote_id')
|
@api.onchange('quote_id')
|
||||||
def _onchange_quote_id(self):
|
def _onchange_quote_id(self):
|
||||||
"""Auto-fill part, coating, and unit price from the linked quote."""
|
"""Auto-fill part and unit price from the linked quote.
|
||||||
|
|
||||||
|
Spec carry-over from quote → wizard line is handled by an
|
||||||
|
inherit in fusion_plating_quality.
|
||||||
|
"""
|
||||||
if not self.quote_id:
|
if not self.quote_id:
|
||||||
return
|
return
|
||||||
q = self.quote_id
|
q = self.quote_id
|
||||||
if q.part_catalog_id and not self.part_catalog_id:
|
if q.part_catalog_id and not self.part_catalog_id:
|
||||||
self.part_catalog_id = q.part_catalog_id
|
self.part_catalog_id = q.part_catalog_id
|
||||||
if q.coating_config_id and not self.coating_config_id:
|
|
||||||
self.coating_config_id = q.coating_config_id
|
|
||||||
if not self.unit_price:
|
if not self.unit_price:
|
||||||
final = q.estimator_override_price or q.calculated_price
|
final = q.estimator_override_price or q.calculated_price
|
||||||
if final and q.quantity:
|
if final and q.quantity:
|
||||||
@@ -510,13 +510,13 @@ class FpDirectOrderLine(models.Model):
|
|||||||
|
|
||||||
@api.onchange('part_catalog_id')
|
@api.onchange('part_catalog_id')
|
||||||
def _onchange_part_defaults(self):
|
def _onchange_part_defaults(self):
|
||||||
"""When a part is picked, seed coating + treatments from its catalog defaults."""
|
"""Seed defaults when a part is picked.
|
||||||
|
|
||||||
|
Spec auto-fill is handled by an inherit in fusion_plating_quality
|
||||||
|
(the customer_spec_id field lives there).
|
||||||
|
"""
|
||||||
if not self.part_catalog_id:
|
if not self.part_catalog_id:
|
||||||
return
|
return
|
||||||
if not self.coating_config_id and self.part_catalog_id.x_fc_default_coating_config_id:
|
|
||||||
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
|
|
||||||
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
|
|
||||||
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
|
|
||||||
# Seed default taxes from the FP-SERVICE product, fiscal-position
|
# Seed default taxes from the FP-SERVICE product, fiscal-position
|
||||||
# mapped from the customer. Only fills when the user hasn't set
|
# mapped from the customer. Only fills when the user hasn't set
|
||||||
# taxes manually.
|
# taxes manually.
|
||||||
@@ -539,21 +539,10 @@ class FpDirectOrderLine(models.Model):
|
|||||||
if taxes:
|
if taxes:
|
||||||
self.tax_ids = [(6, 0, taxes.ids)]
|
self.tax_ids = [(6, 0, taxes.ids)]
|
||||||
|
|
||||||
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
|
# Auto-fill unit_price from a customer price list — extended in
|
||||||
def _onchange_lookup_price(self):
|
# fusion_plating_quality (the spec field lives there). The base
|
||||||
"""Auto-fill unit_price from customer price list when available."""
|
# configurator wizard no longer triggers price lookup since
|
||||||
if self.unit_price:
|
# coating_config_id is gone.
|
||||||
return
|
|
||||||
partner = self.wizard_id.partner_id
|
|
||||||
if not (partner and self.coating_config_id):
|
|
||||||
return
|
|
||||||
price = self.env['fp.customer.price.list']._find_price(
|
|
||||||
partner.id,
|
|
||||||
self.coating_config_id.id,
|
|
||||||
quantity=self.quantity or 1,
|
|
||||||
)
|
|
||||||
if price:
|
|
||||||
self.unit_price = price.unit_price
|
|
||||||
|
|
||||||
@api.onchange('description_template_id')
|
@api.onchange('description_template_id')
|
||||||
def _onchange_description_template(self):
|
def _onchange_description_template(self):
|
||||||
@@ -571,15 +560,14 @@ class FpDirectOrderLine(models.Model):
|
|||||||
if tpl.internal_description:
|
if tpl.internal_description:
|
||||||
self.internal_description = tpl.internal_description
|
self.internal_description = tpl.internal_description
|
||||||
|
|
||||||
@api.onchange('part_catalog_id', 'coating_config_id')
|
@api.onchange('part_catalog_id')
|
||||||
def _onchange_suggest_template(self):
|
def _onchange_suggest_template(self):
|
||||||
"""Offer a sensible default template — part-specific wins.
|
"""Offer a sensible default template — part-specific wins.
|
||||||
|
|
||||||
Priority (first non-empty result wins):
|
Priority (first non-empty result wins):
|
||||||
1. This part's lowest-sequence active template
|
1. This part's lowest-sequence active template
|
||||||
2. This customer's templates (no part)
|
2. This customer's templates (no part)
|
||||||
3. This coating's templates (no part)
|
3. Don't auto-pick — user has to choose
|
||||||
4. Don't auto-pick — user has to choose
|
|
||||||
"""
|
"""
|
||||||
if self.description_template_id or self.line_description:
|
if self.description_template_id or self.line_description:
|
||||||
return
|
return
|
||||||
@@ -612,16 +600,6 @@ class FpDirectOrderLine(models.Model):
|
|||||||
_apply(match)
|
_apply(match)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.coating_config_id:
|
|
||||||
match = Template.search([
|
|
||||||
('active', '=', True),
|
|
||||||
('part_catalog_id', '=', False),
|
|
||||||
('partner_id', '=', False),
|
|
||||||
('coating_config_id', '=', self.coating_config_id.id),
|
|
||||||
], order='sequence', limit=1)
|
|
||||||
if match:
|
|
||||||
_apply(match)
|
|
||||||
|
|
||||||
# ---- Helpers ----
|
# ---- Helpers ----
|
||||||
@api.model
|
@api.model
|
||||||
def _create_from_quote(self, quote, wizard):
|
def _create_from_quote(self, quote, wizard):
|
||||||
@@ -631,16 +609,17 @@ class FpDirectOrderLine(models.Model):
|
|||||||
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
|
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
|
||||||
in one place so the two flows can never drift.
|
in one place so the two flows can never drift.
|
||||||
"""
|
"""
|
||||||
if not quote.part_catalog_id or not quote.coating_config_id:
|
if not quote.part_catalog_id:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Quote %s has no part or coating set; cannot seed a line.'
|
'Quote %s has no part set; cannot seed a line.'
|
||||||
) % (quote.name or quote.id))
|
) % (quote.name or quote.id))
|
||||||
final = quote.estimator_override_price or quote.calculated_price
|
final = quote.estimator_override_price or quote.calculated_price
|
||||||
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
|
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
|
||||||
|
# Spec carry-over from quote → wizard line is handled by an
|
||||||
|
# inherit in fusion_plating_quality (customer_spec_id field).
|
||||||
return self.create({
|
return self.create({
|
||||||
'wizard_id': wizard.id,
|
'wizard_id': wizard.id,
|
||||||
'part_catalog_id': quote.part_catalog_id.id,
|
'part_catalog_id': quote.part_catalog_id.id,
|
||||||
'coating_config_id': quote.coating_config_id.id,
|
|
||||||
'quantity': int(quote.quantity) or 1,
|
'quantity': int(quote.quantity) or 1,
|
||||||
'unit_price': unit,
|
'unit_price': unit,
|
||||||
'quote_id': quote.id,
|
'quote_id': quote.id,
|
||||||
|
|||||||
@@ -550,12 +550,13 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
for line in self.line_ids:
|
for line in self.line_ids:
|
||||||
part = line._get_or_bump_revision()
|
part = line._get_or_bump_revision()
|
||||||
resolved_parts[line.id] = part
|
resolved_parts[line.id] = part
|
||||||
# Build the line header. Primary treatment is optional now;
|
# Build the line header. Specification is optional; when
|
||||||
# when missing, drop it from the header rather than printing
|
# missing, drop it from the header rather than printing
|
||||||
# "False - PartName Rev A".
|
# "False - PartName Rev A".
|
||||||
treatment_label = line.coating_config_id.name or _('No coating')
|
spec = getattr(line, 'customer_spec_id', False)
|
||||||
|
spec_label = (spec.display_name if spec else '') or _('No spec')
|
||||||
header = '%s - %s Rev %s (x%d)' % (
|
header = '%s - %s Rev %s (x%d)' % (
|
||||||
treatment_label,
|
spec_label,
|
||||||
part.name,
|
part.name,
|
||||||
part.revision,
|
part.revision,
|
||||||
line.quantity,
|
line.quantity,
|
||||||
@@ -573,8 +574,9 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'x_fc_part_catalog_id': part.id,
|
'x_fc_part_catalog_id': part.id,
|
||||||
'x_fc_description_template_id': line.description_template_id.id or False,
|
'x_fc_description_template_id': line.description_template_id.id or False,
|
||||||
'x_fc_internal_description': line.internal_description or False,
|
'x_fc_internal_description': line.internal_description or False,
|
||||||
'x_fc_coating_config_id': line.coating_config_id.id,
|
# x_fc_customer_spec_id is set on the resulting SO line
|
||||||
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
|
# by an extension in fusion_plating_quality (post-create
|
||||||
|
# patch — see fp_direct_order_line_inherit.py).
|
||||||
'x_fc_part_deadline': line.part_deadline,
|
'x_fc_part_deadline': line.part_deadline,
|
||||||
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
|
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
|
||||||
'x_fc_rush_order': line.rush_order,
|
'x_fc_rush_order': line.rush_order,
|
||||||
@@ -593,7 +595,7 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
if line.serial_ids else False),
|
if line.serial_ids else False),
|
||||||
'x_fc_serial_id': line.serial_id.id or False,
|
'x_fc_serial_id': line.serial_id.id or False,
|
||||||
'x_fc_job_number': line.job_number or False,
|
'x_fc_job_number': line.job_number or False,
|
||||||
'x_fc_thickness_id': line.thickness_id.id or False,
|
'x_fc_thickness_range': line.thickness_range or False,
|
||||||
# Sub 9 — explicit tax override from the wizard line.
|
# Sub 9 — explicit tax override from the wizard line.
|
||||||
# When blank, Odoo will compute taxes from the product
|
# When blank, Odoo will compute taxes from the product
|
||||||
# defaults at SO-line save time (the standard behaviour).
|
# defaults at SO-line save time (the standard behaviour).
|
||||||
@@ -628,19 +630,18 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
|
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
|
||||||
) % {'doo': self.name, 'so': so.name})
|
) % {'doo': self.name, 'so': so.name})
|
||||||
|
|
||||||
# 6. Push-to-defaults (C4) — uses the resolved part cached
|
# 6. Push-to-defaults — Specification carry-over to the part's
|
||||||
# during the build loop so rev-bumped lines write defaults to
|
# x_fc_default_customer_spec_id is handled by an inherit in
|
||||||
# the NEW revision, not the pre-bump one.
|
# fusion_plating_quality (the field lives there).
|
||||||
|
# Thickness range: lives in configurator, push here.
|
||||||
for line in self.line_ids:
|
for line in self.line_ids:
|
||||||
if not line.push_to_defaults or line.is_one_off:
|
if not line.push_to_defaults or line.is_one_off:
|
||||||
continue
|
continue
|
||||||
part = resolved_parts.get(line.id) or line.part_catalog_id
|
part = resolved_parts.get(line.id) or line.part_catalog_id
|
||||||
if not part:
|
if not part:
|
||||||
continue
|
continue
|
||||||
part.write({
|
if line.thickness_range and not part.x_fc_default_thickness_range:
|
||||||
'x_fc_default_coating_config_id': line.coating_config_id.id or False,
|
part.x_fc_default_thickness_range = line.thickness_range
|
||||||
'x_fc_default_treatment_ids': [(6, 0, line.treatment_ids.ids)],
|
|
||||||
})
|
|
||||||
so.message_post(body=_(
|
so.message_post(body=_(
|
||||||
'Quotation created from PO %s with %d line(s). '
|
'Quotation created from PO %s with %d line(s). '
|
||||||
'Review and confirm manually when ready.'
|
'Review and confirm manually when ready.'
|
||||||
|
|||||||
@@ -154,8 +154,6 @@
|
|||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="internal_description"
|
<field name="internal_description"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="coating_config_id"
|
|
||||||
optional="show"/>
|
|
||||||
<field name="process_variant_id"
|
<field name="process_variant_id"
|
||||||
string="Process / Recipe"
|
string="Process / Recipe"
|
||||||
options="{'no_quick_create': True}"
|
options="{'no_quick_create': True}"
|
||||||
@@ -174,11 +172,8 @@
|
|||||||
string="Process Source"
|
string="Process Source"
|
||||||
readonly="1"
|
readonly="1"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="thickness_id"
|
<field name="thickness_range"
|
||||||
options="{'no_quick_create': True}"
|
placeholder="e.g. 0.0005-0.0008 mils"
|
||||||
context="{'default_coating_config_id': coating_config_id}"
|
|
||||||
domain="[('coating_config_id', '=', coating_config_id)]"
|
|
||||||
invisible="not coating_config_id"
|
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="serial_ids"
|
<field name="serial_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
@@ -194,9 +189,6 @@
|
|||||||
class="btn-link"
|
class="btn-link"
|
||||||
invisible="not part_catalog_id or serial_count > 0"/>
|
invisible="not part_catalog_id or serial_count > 0"/>
|
||||||
<field name="job_number" optional="hide"/>
|
<field name="job_number" optional="hide"/>
|
||||||
<field name="treatment_ids"
|
|
||||||
widget="many2many_tags"
|
|
||||||
optional="hide"/>
|
|
||||||
<field name="quantity"
|
<field name="quantity"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="unit_price"
|
<field name="unit_price"
|
||||||
@@ -239,9 +231,6 @@
|
|||||||
invisible="not part_catalog_id"/>
|
invisible="not part_catalog_id"/>
|
||||||
<field name="part_revision"
|
<field name="part_revision"
|
||||||
invisible="not part_catalog_id"/>
|
invisible="not part_catalog_id"/>
|
||||||
<field name="coating_config_id"/>
|
|
||||||
<field name="treatment_ids"
|
|
||||||
widget="many2many_tags"/>
|
|
||||||
<field name="process_variant_id"
|
<field name="process_variant_id"
|
||||||
string="Process / Recipe"
|
string="Process / Recipe"
|
||||||
options="{'no_quick_create': True}"
|
options="{'no_quick_create': True}"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.27.0',
|
'version': '19.0.10.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
@@ -39,7 +39,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'fusion_plating', # fp.job, fp.job.step, fp.work.centre
|
'fusion_plating', # fp.job, fp.job.step, fp.work.centre
|
||||||
'fusion_plating_batch', # fusion.plating.batch (Phase 3)
|
'fusion_plating_batch', # fusion.plating.batch (Phase 3)
|
||||||
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
|
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
|
||||||
'fusion_plating_configurator', # fp.part.catalog, fp.coating.config
|
'fusion_plating_configurator', # fp.part.catalog
|
||||||
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
|
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
|
||||||
'fusion_plating_logistics', # fusion.plating.delivery
|
'fusion_plating_logistics', # fusion.plating.delivery
|
||||||
'fusion_plating_notifications', # fp.notification.template (Phase 4)
|
'fusion_plating_notifications', # fp.notification.template (Phase 4)
|
||||||
|
|||||||
@@ -48,15 +48,12 @@ class FpJob(models.Model):
|
|||||||
string='Part',
|
string='Part',
|
||||||
ondelete='restrict',
|
ondelete='restrict',
|
||||||
)
|
)
|
||||||
coating_config_id = fields.Many2one(
|
|
||||||
'fp.coating.config',
|
|
||||||
string='Coating Configuration',
|
|
||||||
ondelete='restrict',
|
|
||||||
)
|
|
||||||
customer_spec_id = fields.Many2one(
|
customer_spec_id = fields.Many2one(
|
||||||
'fusion.plating.customer.spec',
|
'fusion.plating.customer.spec',
|
||||||
string='Customer Spec',
|
string='Specification',
|
||||||
ondelete='set null',
|
ondelete='set null',
|
||||||
|
help='Customer / industry spec the job ships under. Auto-filled '
|
||||||
|
'from the SO line at job creation.',
|
||||||
)
|
)
|
||||||
portal_job_id = fields.Many2one(
|
portal_job_id = fields.Many2one(
|
||||||
'fusion.plating.portal.job',
|
'fusion.plating.portal.job',
|
||||||
@@ -996,29 +993,28 @@ class FpJob(models.Model):
|
|||||||
if node.estimated_duration:
|
if node.estimated_duration:
|
||||||
vals['dwell_time_minutes'] = node.estimated_duration
|
vals['dwell_time_minutes'] = node.estimated_duration
|
||||||
|
|
||||||
# Pull thickness target from the coating config when
|
# Pull thickness target from the recipe root when this
|
||||||
# this is a plating step (matched by node name keyword).
|
# is a plating step (matched by node name keyword).
|
||||||
coating = job.coating_config_id
|
# Recipe-root carries thickness fields post-promote-spec.
|
||||||
|
recipe_root = job.recipe_id
|
||||||
name_l = (node.name or '').lower()
|
name_l = (node.name or '').lower()
|
||||||
is_plating_node = (
|
is_plating_node = (
|
||||||
'plat' in name_l or 'nickel' in name_l
|
'plat' in name_l or 'nickel' in name_l
|
||||||
or 'chrome' in name_l or 'anodiz' in name_l
|
or 'chrome' in name_l or 'anodiz' in name_l
|
||||||
)
|
)
|
||||||
if coating and is_plating_node:
|
if recipe_root and is_plating_node:
|
||||||
if (
|
if (
|
||||||
'thickness_max' in coating._fields
|
'thickness_max' in recipe_root._fields
|
||||||
and coating.thickness_max
|
and recipe_root.thickness_max
|
||||||
):
|
):
|
||||||
vals['thickness_target'] = coating.thickness_max
|
vals['thickness_target'] = recipe_root.thickness_max
|
||||||
if (
|
if (
|
||||||
'thickness_uom' in coating._fields
|
'thickness_uom' in recipe_root._fields
|
||||||
and coating.thickness_uom
|
and recipe_root.thickness_uom
|
||||||
):
|
):
|
||||||
# fp.coating.config uses long-form uom names
|
# Recipe uses long-form uom names (mils /
|
||||||
# (mils / microns / inches); fp.job.step uses
|
# microns / inches); fp.job.step uses short
|
||||||
# short codes (mil / um / inch). Map between
|
# codes (mil / um / inch). Map between them.
|
||||||
# them. Unknown values fall through to the
|
|
||||||
# step's default ('um').
|
|
||||||
_UOM_MAP = {
|
_UOM_MAP = {
|
||||||
'mils': 'mil',
|
'mils': 'mil',
|
||||||
'mil': 'mil',
|
'mil': 'mil',
|
||||||
@@ -1029,7 +1025,7 @@ class FpJob(models.Model):
|
|||||||
'inch': 'inch',
|
'inch': 'inch',
|
||||||
'in': 'inch',
|
'in': 'inch',
|
||||||
}
|
}
|
||||||
mapped = _UOM_MAP.get(coating.thickness_uom)
|
mapped = _UOM_MAP.get(recipe_root.thickness_uom)
|
||||||
if mapped:
|
if mapped:
|
||||||
vals['thickness_uom'] = mapped
|
vals['thickness_uom'] = mapped
|
||||||
|
|
||||||
@@ -1546,7 +1542,9 @@ class FpJob(models.Model):
|
|||||||
if not required:
|
if not required:
|
||||||
return
|
return
|
||||||
has_job_link = 'x_fc_job_id' in Cert._fields
|
has_job_link = 'x_fc_job_id' in Cert._fields
|
||||||
coating = self.coating_config_id
|
# Spec drives the cert spec_reference. The customer.spec was
|
||||||
|
# auto-filled onto the job at confirm time (sale_order.py).
|
||||||
|
spec = self.customer_spec_id
|
||||||
for cert_type in sorted(required):
|
for cert_type in sorted(required):
|
||||||
# Idempotency per type.
|
# Idempotency per type.
|
||||||
existing_dom = [('certificate_type', '=', cert_type)]
|
existing_dom = [('certificate_type', '=', cert_type)]
|
||||||
@@ -1574,9 +1572,16 @@ class FpJob(models.Model):
|
|||||||
if 'sale_order_id' in Cert._fields and self.sale_order_id:
|
if 'sale_order_id' in Cert._fields and self.sale_order_id:
|
||||||
vals['sale_order_id'] = self.sale_order_id.id
|
vals['sale_order_id'] = self.sale_order_id.id
|
||||||
# spec_reference is what action_issue blocks on.
|
# spec_reference is what action_issue blocks on.
|
||||||
if coating and 'spec_reference' in Cert._fields \
|
# Format spec.code + revision for the cert text.
|
||||||
and getattr(coating, 'spec_reference', False):
|
if spec and 'spec_reference' in Cert._fields:
|
||||||
vals['spec_reference'] = coating.spec_reference
|
ref = spec.code or ''
|
||||||
|
if spec.revision:
|
||||||
|
ref = (f'{ref} Rev {spec.revision}'
|
||||||
|
if ref else f'Rev {spec.revision}')
|
||||||
|
if ref:
|
||||||
|
vals['spec_reference'] = ref
|
||||||
|
if 'customer_spec_id' in Cert._fields:
|
||||||
|
vals['customer_spec_id'] = spec.id
|
||||||
if 'part_number' in Cert._fields and self.part_catalog_id:
|
if 'part_number' in Cert._fields and self.part_catalog_id:
|
||||||
vals['part_number'] = (
|
vals['part_number'] = (
|
||||||
self.part_catalog_id.part_number or ''
|
self.part_catalog_id.part_number or ''
|
||||||
|
|||||||
@@ -474,8 +474,9 @@ class FpJobStep(models.Model):
|
|||||||
def button_finish(self):
|
def button_finish(self):
|
||||||
"""Override to:
|
"""Override to:
|
||||||
1) Auto-spawn a bake.window when a wet plating step finishes
|
1) Auto-spawn a bake.window when a wet plating step finishes
|
||||||
on a coating that requires hydrogen-embrittlement relief
|
on a recipe that requires hydrogen-embrittlement relief
|
||||||
(AS9100 / Nadcap compliance);
|
(AS9100 / Nadcap compliance). Bake fields live on the
|
||||||
|
recipe root post-promote-customer-spec.
|
||||||
2) Post a chatter warning when duration_actual exceeds 1.5×
|
2) Post a chatter warning when duration_actual exceeds 1.5×
|
||||||
duration_expected — silent overruns are a red flag for
|
duration_expected — silent overruns are a red flag for
|
||||||
scheduling and costing.
|
scheduling and costing.
|
||||||
@@ -499,12 +500,11 @@ class FpJobStep(models.Model):
|
|||||||
'estimate too tight.'
|
'estimate too tight.'
|
||||||
)) % (step.name, ratio, step.duration_expected,
|
)) % (step.name, ratio, step.duration_expected,
|
||||||
step.duration_actual))
|
step.duration_actual))
|
||||||
coating = step.job_id.coating_config_id \
|
recipe_root = step.job_id.recipe_id
|
||||||
if 'coating_config_id' in step.job_id._fields else False
|
if not recipe_root:
|
||||||
if not coating:
|
|
||||||
continue
|
continue
|
||||||
requires = getattr(coating, 'requires_bake_relief', False)
|
requires = getattr(recipe_root, 'requires_bake_relief', False)
|
||||||
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
|
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
|
||||||
if not requires or not window_hrs:
|
if not requires or not window_hrs:
|
||||||
continue
|
continue
|
||||||
# Trigger only on the actual plating-out step. We want
|
# Trigger only on the actual plating-out step. We want
|
||||||
|
|||||||
@@ -339,11 +339,8 @@ class SaleOrder(models.Model):
|
|||||||
1. line.x_fc_process_variant_id — Sarah explicitly picked a
|
1. line.x_fc_process_variant_id — Sarah explicitly picked a
|
||||||
part-scoped variant on this order line. Always wins.
|
part-scoped variant on this order line. Always wins.
|
||||||
2. part.default_process_id — part's flagged default
|
2. part.default_process_id — part's flagged default
|
||||||
variant. Customer-and-part-tuned recipe; must beat any
|
variant. Customer-and-part-tuned recipe.
|
||||||
generic coating template.
|
3. part.recipe_id — legacy fallback.
|
||||||
3. coating.recipe_id — coating-config recipe
|
|
||||||
(generic template fallback).
|
|
||||||
4. part.recipe_id — legacy fallback.
|
|
||||||
Returns the recipe record or an empty recordset.
|
Returns the recipe record or an empty recordset.
|
||||||
"""
|
"""
|
||||||
Node = self.env['fusion.plating.process.node']
|
Node = self.env['fusion.plating.process.node']
|
||||||
@@ -352,11 +349,6 @@ class SaleOrder(models.Model):
|
|||||||
) or False
|
) or False
|
||||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||||
part = self.x_fc_part_catalog_id or False
|
part = self.x_fc_part_catalog_id or False
|
||||||
coating = (
|
|
||||||
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
|
|
||||||
) or False
|
|
||||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
|
||||||
coating = self.x_fc_coating_config_id or False
|
|
||||||
picked = (
|
picked = (
|
||||||
'x_fc_process_variant_id' in line._fields
|
'x_fc_process_variant_id' in line._fields
|
||||||
and line.x_fc_process_variant_id
|
and line.x_fc_process_variant_id
|
||||||
@@ -365,8 +357,6 @@ class SaleOrder(models.Model):
|
|||||||
return picked
|
return picked
|
||||||
if part and 'default_process_id' in part._fields and part.default_process_id:
|
if part and 'default_process_id' in part._fields and part.default_process_id:
|
||||||
return part.default_process_id
|
return part.default_process_id
|
||||||
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
|
|
||||||
return coating.recipe_id
|
|
||||||
if part and 'recipe_id' in part._fields and part.recipe_id:
|
if part and 'recipe_id' in part._fields and part.recipe_id:
|
||||||
return part.recipe_id
|
return part.recipe_id
|
||||||
return Node
|
return Node
|
||||||
@@ -389,22 +379,22 @@ class SaleOrder(models.Model):
|
|||||||
if existing:
|
if existing:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find plating lines (those with a part_catalog_id or coating_config_id)
|
# Find plating lines (those with a part_catalog_id or
|
||||||
|
# customer_spec_id).
|
||||||
plating_lines = self.order_line.filtered(
|
plating_lines = self.order_line.filtered(
|
||||||
lambda l: (
|
lambda l: (
|
||||||
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
|
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
|
||||||
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
|
or ('x_fc_customer_spec_id' in l._fields and l.x_fc_customer_spec_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Fallback: legacy/configurator SOs that carry part+coating on the
|
# Fallback: SOs that carry part on the header but not on the
|
||||||
# header but not on the line. Treat the entire order as one
|
# line. Treat the entire order as one plating job so the planner
|
||||||
# plating line so the planner gets an fp.job to work against.
|
# gets an fp.job to work against.
|
||||||
if not plating_lines and self.order_line and (
|
if not plating_lines and self.order_line and (
|
||||||
('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id)
|
'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
|
||||||
or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
|
|
||||||
):
|
):
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'SO %s: no line-level part/coating but header carries one — '
|
'SO %s: no line-level part but header carries one — '
|
||||||
'treating all lines as a single plating job.', self.name,
|
'treating all lines as a single plating job.', self.name,
|
||||||
)
|
)
|
||||||
plating_lines = self.order_line
|
plating_lines = self.order_line
|
||||||
@@ -412,13 +402,12 @@ class SaleOrder(models.Model):
|
|||||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Group by (recipe, part, coating, thickness, serial). Lines that
|
# Group by (recipe, part, spec, thickness, serial). Lines that
|
||||||
# share ALL FIVE collapse into one WO. Same compliance reasoning
|
# share ALL FIVE collapse into one WO. Bundling lines with
|
||||||
# as part_id + coating_id: bundling lines with different thicknesses
|
# different specs / thicknesses / serials under one WO would
|
||||||
# or different serials under one WO would carry the first line's
|
# carry the first line's values onto the cert + sticker —
|
||||||
# values onto the cert + sticker — silent mis-attestation. Sub 5
|
# silent mis-attestation. No-recipe lines still get their own
|
||||||
# added thickness_id + serial_id; this extends the grouping logic
|
# group each.
|
||||||
# to honour them. No-recipe lines still get their own group each.
|
|
||||||
groups = {}
|
groups = {}
|
||||||
unrecipe_idx = 0
|
unrecipe_idx = 0
|
||||||
for line in plating_lines:
|
for line in plating_lines:
|
||||||
@@ -427,20 +416,20 @@ class SaleOrder(models.Model):
|
|||||||
'x_fc_part_catalog_id' in line._fields
|
'x_fc_part_catalog_id' in line._fields
|
||||||
and line.x_fc_part_catalog_id.id
|
and line.x_fc_part_catalog_id.id
|
||||||
) or False
|
) or False
|
||||||
coating_id = (
|
spec_id = (
|
||||||
'x_fc_coating_config_id' in line._fields
|
'x_fc_customer_spec_id' in line._fields
|
||||||
and line.x_fc_coating_config_id.id
|
and line.x_fc_customer_spec_id.id
|
||||||
) or False
|
) or False
|
||||||
thickness_id = (
|
thickness_key = (
|
||||||
'x_fc_thickness_id' in line._fields
|
'x_fc_thickness_range' in line._fields
|
||||||
and line.x_fc_thickness_id.id
|
and (line.x_fc_thickness_range or '').strip()
|
||||||
) or False
|
) or False
|
||||||
serial_id = (
|
serial_id = (
|
||||||
'x_fc_serial_id' in line._fields
|
'x_fc_serial_id' in line._fields
|
||||||
and line.x_fc_serial_id.id
|
and line.x_fc_serial_id.id
|
||||||
) or False
|
) or False
|
||||||
if recipe:
|
if recipe:
|
||||||
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
|
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
|
||||||
else:
|
else:
|
||||||
unrecipe_idx += 1
|
unrecipe_idx += 1
|
||||||
key = ('no_recipe', unrecipe_idx)
|
key = ('no_recipe', unrecipe_idx)
|
||||||
@@ -465,15 +454,13 @@ class SaleOrder(models.Model):
|
|||||||
and first_line.x_fc_part_catalog_id
|
and first_line.x_fc_part_catalog_id
|
||||||
or False
|
or False
|
||||||
)
|
)
|
||||||
coating = (
|
customer_spec = (
|
||||||
'x_fc_coating_config_id' in first_line._fields
|
'x_fc_customer_spec_id' in first_line._fields
|
||||||
and first_line.x_fc_coating_config_id
|
and first_line.x_fc_customer_spec_id
|
||||||
or False
|
or False
|
||||||
)
|
)
|
||||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||||
part = self.x_fc_part_catalog_id or False
|
part = self.x_fc_part_catalog_id or False
|
||||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
|
||||||
coating = self.x_fc_coating_config_id or False
|
|
||||||
recipe = self._fp_resolve_recipe_for_line(first_line)
|
recipe = self._fp_resolve_recipe_for_line(first_line)
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
@@ -487,8 +474,8 @@ class SaleOrder(models.Model):
|
|||||||
}
|
}
|
||||||
if part:
|
if part:
|
||||||
vals['part_catalog_id'] = part.id
|
vals['part_catalog_id'] = part.id
|
||||||
if coating:
|
if customer_spec:
|
||||||
vals['coating_config_id'] = coating.id
|
vals['customer_spec_id'] = customer_spec.id
|
||||||
if recipe:
|
if recipe:
|
||||||
vals['recipe_id'] = recipe.id
|
vals['recipe_id'] = recipe.id
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||||
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||||
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
||||||
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
|
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
|
||||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||||
<t t-set="_qty" t-value="job.qty"/>
|
<t t-set="_qty" t-value="job.qty"/>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||||
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||||
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
||||||
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
|
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
|
||||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||||
<t t-set="_qty" t-value="job.qty"/>
|
<t t-set="_qty" t-value="job.qty"/>
|
||||||
|
|||||||
@@ -200,8 +200,8 @@
|
|||||||
<t t-else="">—</t>
|
<t t-else="">—</t>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<t t-if="'coating_config_id' in job._fields and job.coating_config_id">
|
<t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
|
||||||
<span t-esc="job.coating_config_id.name"/>
|
<span t-esc="job.customer_spec_id.display_name"/>
|
||||||
</t>
|
</t>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//field[@name='product_id']" position="after">
|
<xpath expr="//field[@name='product_id']" position="after">
|
||||||
<field name="part_catalog_id" string="Part"/>
|
<field name="part_catalog_id" string="Part"/>
|
||||||
<field name="coating_config_id" string="Coating"/>
|
<field name="customer_spec_id" string="Specification"/>
|
||||||
<field name="recipe_id" string="Process Recipe"/>
|
<field name="recipe_id" string="Process Recipe"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<!-- Show qty completed alongside total so the partial-qty
|
<!-- Show qty completed alongside total so the partial-qty
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
<!-- Workflow milestones live under "Recipes & Steps" because each
|
<!-- Workflow milestones live under "Recipes & Steps" because each
|
||||||
state is triggered by a recipe-step kind / per-step override. -->
|
state is triggered by a recipe-step kind / per-step override. -->
|
||||||
<menuitem id="menu_fp_workflow_state"
|
<menuitem id="menu_fp_workflow_state"
|
||||||
name="Workflow States"
|
name="Job Workflow Stages"
|
||||||
parent="fusion_plating.menu_fp_config_recipes_steps"
|
parent="fusion_plating.menu_fp_config_recipes_steps"
|
||||||
action="action_fp_workflow_state"
|
action="action_fp_workflow_state"
|
||||||
sequence="50"/>
|
sequence="50"/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Logistics',
|
'name': 'Fusion Plating — Logistics',
|
||||||
'version': '19.0.3.6.0',
|
'version': '19.0.3.8.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': (
|
'summary': (
|
||||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ class FpDelivery(models.Model):
|
|||||||
string='Job #', index=True,
|
string='Job #', index=True,
|
||||||
help='Shop-floor job number from the MO. Prints on packing slip.',
|
help='Shop-floor job number from the MO. Prints on packing slip.',
|
||||||
)
|
)
|
||||||
x_fc_thickness_id = fields.Many2one(
|
x_fc_thickness_range = fields.Char(
|
||||||
'fp.coating.thickness', string='Thickness',
|
string='Thickness',
|
||||||
ondelete='set null',
|
help='Carried from the SO line — prints on packing slip / BoL.',
|
||||||
)
|
)
|
||||||
x_fc_revision_snapshot = fields.Char(
|
x_fc_revision_snapshot = fields.Char(
|
||||||
string='Revision (snapshot)',
|
string='Revision (snapshot)',
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
from . import wizards
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Quality (QMS)',
|
'name': 'Fusion Plating — Quality (QMS)',
|
||||||
'version': '19.0.4.14.0',
|
'version': '19.0.6.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||||
@@ -90,6 +90,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/fp_calibration_views.xml',
|
'views/fp_calibration_views.xml',
|
||||||
'views/fp_avl_views.xml',
|
'views/fp_avl_views.xml',
|
||||||
'views/fp_customer_spec_views.xml',
|
'views/fp_customer_spec_views.xml',
|
||||||
|
'views/fp_process_node_inherit_views.xml',
|
||||||
|
'views/sale_order_views_inherit.xml',
|
||||||
|
'views/fp_part_catalog_views_inherit.xml',
|
||||||
|
'views/fp_direct_order_wizard_views_inherit.xml',
|
||||||
|
'views/fp_pricing_rule_views_inherit.xml',
|
||||||
'views/fp_audit_views.xml',
|
'views/fp_audit_views.xml',
|
||||||
'views/fp_fair_views.xml',
|
'views/fp_fair_views.xml',
|
||||||
'views/fp_doc_control_views.xml',
|
'views/fp_doc_control_views.xml',
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ from . import fp_calibration
|
|||||||
from . import fp_calibration_event
|
from . import fp_calibration_event
|
||||||
from . import fp_avl
|
from . import fp_avl
|
||||||
from . import fp_customer_spec
|
from . import fp_customer_spec
|
||||||
|
from . import fp_process_node_inherit
|
||||||
|
from . import sale_order_line_inherit
|
||||||
|
from . import account_move_line_inherit
|
||||||
|
from . import fp_direct_order_line_inherit
|
||||||
|
from . import fp_pricing_rule_inherit
|
||||||
|
from . import fp_quote_configurator_inherit
|
||||||
|
from . import fp_certificate_inherit
|
||||||
from . import fp_audit
|
from . import fp_audit
|
||||||
from . import fp_fair
|
from . import fp_fair
|
||||||
from . import fp_doc_control
|
from . import fp_doc_control
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLine(models.Model):
|
||||||
|
"""Add the Specification reference to the invoice line.
|
||||||
|
|
||||||
|
Lives here (not in configurator) because fusion.plating.customer.spec
|
||||||
|
lives in fusion_plating_quality and configurator can't reference it
|
||||||
|
without a circular dep.
|
||||||
|
"""
|
||||||
|
_inherit = 'account.move.line'
|
||||||
|
|
||||||
|
x_fc_customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
help='Carried from the SO line so the invoice PDF can render the '
|
||||||
|
'spec reference next to the part number.',
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpCertificate(models.Model):
|
||||||
|
"""Add Specification linkage + auto-fill spec_reference from it.
|
||||||
|
|
||||||
|
Lives in fusion_plating_quality because customer.spec lives here.
|
||||||
|
Quality already depends on certificates, so the inverse direction
|
||||||
|
works.
|
||||||
|
"""
|
||||||
|
_inherit = 'fp.certificate'
|
||||||
|
|
||||||
|
customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
help='Snapshot of the specification the cert was issued against. '
|
||||||
|
'Drives the spec_reference printed on the CoC.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Auto-fill spec_reference from the SO line's customer_spec_id.
|
||||||
|
|
||||||
|
Resolution order (first match wins):
|
||||||
|
1. Explicit spec_reference passed in vals.
|
||||||
|
2. customer_spec_id (this field) → format "code Rev rev".
|
||||||
|
3. SO line x_fc_customer_spec_id (with print_on_cert=True).
|
||||||
|
4. Existing legacy fall-back lives in the parent module
|
||||||
|
(reads x_fc_coating_config_id.spec_reference). Untouched.
|
||||||
|
"""
|
||||||
|
SaleOrder = self.env['sale.order']
|
||||||
|
for vals in vals_list:
|
||||||
|
if vals.get('spec_reference'):
|
||||||
|
continue
|
||||||
|
spec = False
|
||||||
|
# 2. Explicit spec on the cert.
|
||||||
|
if vals.get('customer_spec_id'):
|
||||||
|
spec = self.env['fusion.plating.customer.spec'].browse(
|
||||||
|
vals['customer_spec_id'],
|
||||||
|
).exists()
|
||||||
|
# 3. SO line's spec.
|
||||||
|
if not spec and vals.get('sale_order_id'):
|
||||||
|
so = SaleOrder.browse(vals['sale_order_id'])
|
||||||
|
if 'x_fc_customer_spec_id' in so.order_line._fields:
|
||||||
|
spec = so.order_line.mapped(
|
||||||
|
'x_fc_customer_spec_id',
|
||||||
|
).filtered('print_on_cert')[:1]
|
||||||
|
if spec and not vals.get('customer_spec_id'):
|
||||||
|
vals['customer_spec_id'] = spec.id
|
||||||
|
if spec:
|
||||||
|
ref = spec.code or ''
|
||||||
|
if spec.revision:
|
||||||
|
ref = f'{ref} Rev {spec.revision}' if ref else f'Rev {spec.revision}'
|
||||||
|
if ref:
|
||||||
|
vals['spec_reference'] = ref
|
||||||
|
return super().create(vals_list)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpCustomerSpec(models.Model):
|
class FpCustomerSpec(models.Model):
|
||||||
@@ -74,6 +74,22 @@ class FpCustomerSpec(models.Model):
|
|||||||
notes = fields.Html(
|
notes = fields.Html(
|
||||||
string='Notes',
|
string='Notes',
|
||||||
)
|
)
|
||||||
|
recipe_ids = fields.Many2many(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
'fp_customer_spec_recipe_rel',
|
||||||
|
'spec_id', 'recipe_id',
|
||||||
|
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
|
||||||
|
string='Applicable Recipes',
|
||||||
|
help='Recipes that can produce work to this specification. '
|
||||||
|
'Many-to-many — one spec can cover multiple processes; '
|
||||||
|
'one recipe can satisfy multiple specs.',
|
||||||
|
)
|
||||||
|
print_on_cert = fields.Boolean(
|
||||||
|
string='Print on Certificate',
|
||||||
|
default=True,
|
||||||
|
help="When enabled, this spec's code+revision appear on the CoC "
|
||||||
|
'when the spec is selected on the SO line.',
|
||||||
|
)
|
||||||
company_id = fields.Many2one(
|
company_id = fields.Many2one(
|
||||||
'res.company',
|
'res.company',
|
||||||
string='Company',
|
string='Company',
|
||||||
@@ -89,6 +105,7 @@ class FpCustomerSpec(models.Model):
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@api.depends('code', 'revision', 'name')
|
||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
parts = [rec.code or '']
|
parts = [rec.code or '']
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpDirectOrderLine(models.Model):
|
||||||
|
"""Add the Specification picker to the direct-order wizard line.
|
||||||
|
|
||||||
|
Lives in fusion_plating_quality because fusion.plating.customer.spec
|
||||||
|
lives here.
|
||||||
|
"""
|
||||||
|
_inherit = 'fp.direct.order.line'
|
||||||
|
|
||||||
|
customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
help='Customer / industry specification the work ships to. '
|
||||||
|
'Carried onto the SO line at order creation.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.onchange('part_catalog_id')
|
||||||
|
def _onchange_part_default_spec(self):
|
||||||
|
"""Pre-fill the line's specification from the part's default."""
|
||||||
|
for rec in self:
|
||||||
|
if (rec.part_catalog_id
|
||||||
|
and rec.part_catalog_id.x_fc_default_customer_spec_id
|
||||||
|
and not rec.customer_spec_id):
|
||||||
|
rec.customer_spec_id = (
|
||||||
|
rec.part_catalog_id.x_fc_default_customer_spec_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FpDirectOrderWizard(models.Model):
|
||||||
|
_inherit = 'fp.direct.order.wizard'
|
||||||
|
|
||||||
|
def action_create_order(self):
|
||||||
|
"""Carry customer_spec_id from each wizard line to its SO line.
|
||||||
|
|
||||||
|
The base method (in configurator) builds the SO with all the
|
||||||
|
coating/treatment/process fields. We can't insert spec into the
|
||||||
|
vals dict from here without a circular dep, so post-create we
|
||||||
|
pair wizard lines to SO lines by sequence and patch.
|
||||||
|
"""
|
||||||
|
result = super().action_create_order()
|
||||||
|
if self.sale_order_id:
|
||||||
|
wiz_lines = self.line_ids.sorted(
|
||||||
|
key=lambda r: (r.sequence, r.id)
|
||||||
|
)
|
||||||
|
so_lines = self.sale_order_id.order_line.sorted(
|
||||||
|
key=lambda r: (r.sequence, r.id)
|
||||||
|
)
|
||||||
|
for wiz_line, so_line in zip(wiz_lines, so_lines):
|
||||||
|
if wiz_line.customer_spec_id and not so_line.x_fc_customer_spec_id:
|
||||||
|
so_line.x_fc_customer_spec_id = wiz_line.customer_spec_id.id
|
||||||
|
return result
|
||||||
@@ -14,6 +14,12 @@ _logger = logging.getLogger(__name__)
|
|||||||
class FpPartCatalog(models.Model):
|
class FpPartCatalog(models.Model):
|
||||||
_inherit = 'fp.part.catalog'
|
_inherit = 'fp.part.catalog'
|
||||||
|
|
||||||
|
x_fc_default_customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Default Specification',
|
||||||
|
help='Default specification applied when this part is dropped on '
|
||||||
|
'a direct order line. Operator can override per order.',
|
||||||
|
)
|
||||||
x_fc_contract_review_id = fields.Many2one(
|
x_fc_contract_review_id = fields.Many2one(
|
||||||
'fp.contract.review',
|
'fp.contract.review',
|
||||||
string='Contract Review',
|
string='Contract Review',
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpPricingRule(models.Model):
|
||||||
|
"""Add Specification + Recipe match keys to the pricing rule.
|
||||||
|
|
||||||
|
Lives in fusion_plating_quality because fusion.plating.customer.spec
|
||||||
|
lives here. Rules can now match on:
|
||||||
|
- customer_spec_id (most specific — e.g. "AMS 2404 surcharge")
|
||||||
|
- recipe_id (recipe-tier — e.g. "EN Mid-Phos $X/sqft")
|
||||||
|
- both blank (fallback — material/cert-level matching)
|
||||||
|
|
||||||
|
The configurator's matcher is extended in fp_quote_configurator_inherit.
|
||||||
|
"""
|
||||||
|
_inherit = 'fp.pricing.rule'
|
||||||
|
|
||||||
|
customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
help='Match rule against the SO line specification. Combine with '
|
||||||
|
'recipe_id for spec+recipe specific pricing, or leave recipe '
|
||||||
|
'blank for spec-tier pricing.',
|
||||||
|
)
|
||||||
|
recipe_id = fields.Many2one(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
string='Recipe',
|
||||||
|
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
|
||||||
|
help='Match rule against the SO line recipe. Combine with '
|
||||||
|
'customer_spec_id for spec+recipe specific pricing, or '
|
||||||
|
'leave spec blank for recipe-tier pricing.',
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionPlatingProcessNode(models.Model):
|
||||||
|
"""Add the reverse M2M from recipe → applicable specifications.
|
||||||
|
|
||||||
|
The forward M2M lives on fusion.plating.customer.spec.recipe_ids.
|
||||||
|
Defined here (in the quality module) because customer.spec is owned
|
||||||
|
by quality and core can't reference it without a circular dep.
|
||||||
|
"""
|
||||||
|
_inherit = 'fusion.plating.process.node'
|
||||||
|
|
||||||
|
applicable_spec_ids = fields.Many2many(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
'fp_customer_spec_recipe_rel',
|
||||||
|
'recipe_id', 'spec_id',
|
||||||
|
string='Applicable Specifications',
|
||||||
|
help='Customer / industry specifications this recipe is qualified '
|
||||||
|
'to satisfy. Set on the spec record; mirrored here for '
|
||||||
|
'navigation.',
|
||||||
|
)
|
||||||
@@ -63,9 +63,22 @@ class FpQualityPoint(models.Model):
|
|||||||
'fp.part.catalog', 'fp_quality_point_part_rel',
|
'fp.part.catalog', 'fp_quality_point_part_rel',
|
||||||
'point_id', 'part_id', string='Parts',
|
'point_id', 'part_id', string='Parts',
|
||||||
)
|
)
|
||||||
coating_config_ids = fields.Many2many(
|
customer_spec_ids = fields.Many2many(
|
||||||
'fp.coating.config', 'fp_quality_point_coating_rel',
|
'fusion.plating.customer.spec',
|
||||||
'point_id', 'coating_id', string='Coatings',
|
'fp_quality_point_spec_rel',
|
||||||
|
'point_id', 'spec_id',
|
||||||
|
string='Specifications',
|
||||||
|
help='If set, this trigger only fires for SOs / jobs whose '
|
||||||
|
'specification is in this list. Leave blank to ignore spec.',
|
||||||
|
)
|
||||||
|
recipe_ids = fields.Many2many(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
'fp_quality_point_recipe_rel',
|
||||||
|
'point_id', 'recipe_id',
|
||||||
|
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
|
||||||
|
string='Recipes',
|
||||||
|
help='If set, this trigger only fires for jobs running one of '
|
||||||
|
'these recipes. Leave blank to ignore recipe.',
|
||||||
)
|
)
|
||||||
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
|
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
|
||||||
|
|
||||||
@@ -102,7 +115,8 @@ class FpQualityPoint(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Matching + spawning
|
# Matching + spawning
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _matches(self, partner=None, part=None, coating=None, step=None):
|
def _matches(self, partner=None, part=None, step=None,
|
||||||
|
customer_spec=None, recipe=None):
|
||||||
"""Return True if this point's filters all pass against the supplied
|
"""Return True if this point's filters all pass against the supplied
|
||||||
context. Empty filter == match anything.
|
context. Empty filter == match anything.
|
||||||
"""
|
"""
|
||||||
@@ -112,8 +126,12 @@ class FpQualityPoint(models.Model):
|
|||||||
if self.part_catalog_ids and (
|
if self.part_catalog_ids and (
|
||||||
not part or part not in self.part_catalog_ids):
|
not part or part not in self.part_catalog_ids):
|
||||||
return False
|
return False
|
||||||
if self.coating_config_ids and (
|
if self.customer_spec_ids and (
|
||||||
not coating or coating not in self.coating_config_ids):
|
not customer_spec
|
||||||
|
or customer_spec not in self.customer_spec_ids):
|
||||||
|
return False
|
||||||
|
if self.recipe_ids and (
|
||||||
|
not recipe or recipe not in self.recipe_ids):
|
||||||
return False
|
return False
|
||||||
if self.step_kind and step and getattr(step, 'kind', None) \
|
if self.step_kind and step and getattr(step, 'kind', None) \
|
||||||
and step.kind != self.step_kind:
|
and step.kind != self.step_kind:
|
||||||
@@ -121,15 +139,16 @@ class FpQualityPoint(models.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _find_matching(self, trigger, partner=None, part=None, coating=None,
|
def _find_matching(self, trigger, partner=None, part=None,
|
||||||
step=None):
|
step=None, customer_spec=None, recipe=None):
|
||||||
"""Return active points whose trigger + filters match the context."""
|
"""Return active points whose trigger + filters match the context."""
|
||||||
candidates = self.search([
|
candidates = self.search([
|
||||||
('active', '=', True),
|
('active', '=', True),
|
||||||
('trigger_type', '=', trigger),
|
('trigger_type', '=', trigger),
|
||||||
])
|
])
|
||||||
return candidates.filtered(lambda p: p._matches(
|
return candidates.filtered(lambda p: p._matches(
|
||||||
partner=partner, part=part, coating=coating, step=step,
|
partner=partner, part=part, step=step,
|
||||||
|
customer_spec=customer_spec, recipe=recipe,
|
||||||
))
|
))
|
||||||
|
|
||||||
def _spawn_check_for(self, source, partner=None, job=None, step=None):
|
def _spawn_check_for(self, source, partner=None, job=None, step=None):
|
||||||
|
|||||||
@@ -49,21 +49,21 @@ class SaleOrderPointHook(models.Model):
|
|||||||
Point = self.env['fp.quality.point']
|
Point = self.env['fp.quality.point']
|
||||||
for so in self:
|
for so in self:
|
||||||
partner = so.partner_id
|
partner = so.partner_id
|
||||||
# Walk lines for part / coating context.
|
# Walk lines for part / coating / spec context.
|
||||||
parts = so.order_line.mapped('x_fc_part_catalog_id') \
|
parts = so.order_line.mapped('x_fc_part_catalog_id') \
|
||||||
if 'x_fc_part_catalog_id' in so.order_line._fields else False
|
if 'x_fc_part_catalog_id' in so.order_line._fields else False
|
||||||
coatings = so.order_line.mapped('x_fc_coating_config_id') \
|
specs = so.order_line.mapped('x_fc_customer_spec_id') \
|
||||||
if 'x_fc_coating_config_id' in so.order_line._fields else False
|
if 'x_fc_customer_spec_id' in so.order_line._fields else False
|
||||||
points = Point._find_matching(
|
points = Point._find_matching(
|
||||||
trigger='so_confirmed', partner=partner,
|
trigger='so_confirmed', partner=partner,
|
||||||
)
|
)
|
||||||
for point in points:
|
for point in points:
|
||||||
# Filter by part / coating intersection if the point cares.
|
# Filter by part / spec intersection if the point cares.
|
||||||
if point.part_catalog_ids and parts and \
|
if point.part_catalog_ids and parts and \
|
||||||
not (point.part_catalog_ids & parts):
|
not (point.part_catalog_ids & parts):
|
||||||
continue
|
continue
|
||||||
if point.coating_config_ids and coatings and \
|
if point.customer_spec_ids and specs and \
|
||||||
not (point.coating_config_ids & coatings):
|
not (point.customer_spec_ids & specs):
|
||||||
continue
|
continue
|
||||||
point._spawn_check_for(source=so, partner=partner)
|
point._spawn_check_for(source=so, partner=partner)
|
||||||
return result
|
return result
|
||||||
@@ -79,10 +79,13 @@ class FpJobPointHook(models.Model):
|
|||||||
for job in self:
|
for job in self:
|
||||||
partner = job.partner_id
|
partner = job.partner_id
|
||||||
part = getattr(job, 'part_catalog_id', False) or False
|
part = getattr(job, 'part_catalog_id', False) or False
|
||||||
coating = getattr(job, 'coating_config_id', False) or False
|
customer_spec = getattr(job, 'customer_spec_id', False) or False
|
||||||
|
recipe = getattr(job, 'recipe_id', False) or False
|
||||||
points = Point._find_matching(
|
points = Point._find_matching(
|
||||||
trigger='job_confirmed', partner=partner,
|
trigger='job_confirmed', partner=partner,
|
||||||
part=part or None, coating=coating or None,
|
part=part or None,
|
||||||
|
customer_spec=customer_spec or None,
|
||||||
|
recipe=recipe or None,
|
||||||
)
|
)
|
||||||
for point in points:
|
for point in points:
|
||||||
point._spawn_check_for(
|
point._spawn_check_for(
|
||||||
@@ -98,10 +101,13 @@ class FpJobPointHook(models.Model):
|
|||||||
continue
|
continue
|
||||||
partner = job.partner_id
|
partner = job.partner_id
|
||||||
part = getattr(job, 'part_catalog_id', False) or False
|
part = getattr(job, 'part_catalog_id', False) or False
|
||||||
coating = getattr(job, 'coating_config_id', False) or False
|
customer_spec = getattr(job, 'customer_spec_id', False) or False
|
||||||
|
recipe = getattr(job, 'recipe_id', False) or False
|
||||||
points = Point._find_matching(
|
points = Point._find_matching(
|
||||||
trigger='job_done', partner=partner,
|
trigger='job_done', partner=partner,
|
||||||
part=part or None, coating=coating or None,
|
part=part or None,
|
||||||
|
customer_spec=customer_spec or None,
|
||||||
|
recipe=recipe or None,
|
||||||
)
|
)
|
||||||
for point in points:
|
for point in points:
|
||||||
point._spawn_check_for(
|
point._spawn_check_for(
|
||||||
@@ -123,10 +129,13 @@ class FpJobStepPointHook(models.Model):
|
|||||||
job = step.job_id
|
job = step.job_id
|
||||||
partner = job.partner_id if job else False
|
partner = job.partner_id if job else False
|
||||||
part = getattr(job, 'part_catalog_id', False) or False
|
part = getattr(job, 'part_catalog_id', False) or False
|
||||||
coating = getattr(job, 'coating_config_id', False) or False
|
customer_spec = getattr(job, 'customer_spec_id', False) or False
|
||||||
|
recipe = getattr(job, 'recipe_id', False) or False
|
||||||
points = Point._find_matching(
|
points = Point._find_matching(
|
||||||
trigger='job_step_done', partner=partner,
|
trigger='job_step_done', partner=partner,
|
||||||
part=part or None, coating=coating or None, step=step,
|
part=part or None, step=step,
|
||||||
|
customer_spec=customer_spec or None,
|
||||||
|
recipe=recipe or None,
|
||||||
)
|
)
|
||||||
for point in points:
|
for point in points:
|
||||||
point._spawn_check_for(
|
point._spawn_check_for(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpQuoteConfigurator(models.Model):
|
||||||
|
"""Add Specification field + extend the pricing rule matcher.
|
||||||
|
|
||||||
|
Lives in fusion_plating_quality because customer.spec lives here.
|
||||||
|
"""
|
||||||
|
_inherit = 'fp.quote.configurator'
|
||||||
|
|
||||||
|
customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
help='Customer / industry spec the quote is built against. '
|
||||||
|
'Drives pricing rule lookup and certificate auto-fill.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_matching_rule(self):
|
||||||
|
"""Extend the configurator's matcher to consider Spec + Recipe.
|
||||||
|
|
||||||
|
Spec match adds +8 (highest priority — explicit customer spec
|
||||||
|
wins over chemistry filters). Recipe adds +6. Material is +2.
|
||||||
|
"""
|
||||||
|
recipe = self.recipe_id or False
|
||||||
|
builder_rules = (
|
||||||
|
recipe.pricing_rule_ids
|
||||||
|
if recipe else self.env['fp.pricing.rule']
|
||||||
|
)
|
||||||
|
if builder_rules:
|
||||||
|
rules = builder_rules.filtered('active').sorted(
|
||||||
|
lambda r: (r.sequence, r.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rules = self.env['fp.pricing.rule'].search(
|
||||||
|
[('active', '=', True)], order='sequence, id'
|
||||||
|
)
|
||||||
|
|
||||||
|
best = None
|
||||||
|
best_score = -1
|
||||||
|
for rule in rules:
|
||||||
|
score = 0
|
||||||
|
# Spec wins biggest
|
||||||
|
if rule.customer_spec_id:
|
||||||
|
if rule.customer_spec_id != self.customer_spec_id:
|
||||||
|
continue
|
||||||
|
score += 8
|
||||||
|
# Recipe is next
|
||||||
|
if rule.recipe_id:
|
||||||
|
if rule.recipe_id != recipe:
|
||||||
|
continue
|
||||||
|
score += 6
|
||||||
|
if rule.substrate_material:
|
||||||
|
if rule.substrate_material != self.substrate_material:
|
||||||
|
continue
|
||||||
|
score += 2
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best = rule
|
||||||
|
return best
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrder(models.Model):
|
||||||
|
"""Add an order-level Specification mirror so reports can print it
|
||||||
|
in the header summary section. Computed from the lines (first
|
||||||
|
spec wins; falls back to blank when lines have no spec).
|
||||||
|
"""
|
||||||
|
_inherit = 'sale.order'
|
||||||
|
|
||||||
|
x_fc_customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
compute='_compute_x_fc_customer_spec_id',
|
||||||
|
store=True,
|
||||||
|
help='First specification cited on this order (or blank). '
|
||||||
|
'Drives the order-level header in customer-facing PDFs.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('order_line.x_fc_customer_spec_id')
|
||||||
|
def _compute_x_fc_customer_spec_id(self):
|
||||||
|
for so in self:
|
||||||
|
specs = so.order_line.mapped('x_fc_customer_spec_id')
|
||||||
|
so.x_fc_customer_spec_id = specs[:1] if specs else False
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderLine(models.Model):
|
||||||
|
"""Add the Specification picker to the SO line.
|
||||||
|
|
||||||
|
Lives in fusion_plating_quality because fusion.plating.customer.spec
|
||||||
|
lives here. Configurator can't reference it directly without a
|
||||||
|
circular dep (quality depends on configurator).
|
||||||
|
"""
|
||||||
|
_inherit = 'sale.order.line'
|
||||||
|
|
||||||
|
x_fc_customer_spec_id = fields.Many2one(
|
||||||
|
'fusion.plating.customer.spec',
|
||||||
|
string='Specification',
|
||||||
|
help='Customer / industry specification the work is being shipped '
|
||||||
|
'to (e.g. AMS 2404 Rev D, BAC 5680 Rev E). Drives certificate '
|
||||||
|
'auto-fill and FAI / Nadcap routing.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.onchange('x_fc_part_catalog_id')
|
||||||
|
def _onchange_part_default_spec(self):
|
||||||
|
"""Pre-fill the line's specification from the part's default."""
|
||||||
|
for line in self:
|
||||||
|
if (line.x_fc_part_catalog_id
|
||||||
|
and line.x_fc_part_catalog_id.x_fc_default_customer_spec_id
|
||||||
|
and not line.x_fc_customer_spec_id):
|
||||||
|
line.x_fc_customer_spec_id = (
|
||||||
|
line.x_fc_part_catalog_id.x_fc_default_customer_spec_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Fall back to the part's default spec when none is supplied.
|
||||||
|
|
||||||
|
Catches programmatic creation paths (wizard, import, sale_mrp
|
||||||
|
bridge) where the onchange doesn't fire. Explicit spec wins;
|
||||||
|
only fills when blank AND the part has a default.
|
||||||
|
"""
|
||||||
|
Part = self.env['fp.part.catalog']
|
||||||
|
for vals in vals_list:
|
||||||
|
if (not vals.get('x_fc_customer_spec_id')
|
||||||
|
and vals.get('x_fc_part_catalog_id')):
|
||||||
|
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||||
|
if part and part.x_fc_default_customer_spec_id:
|
||||||
|
vals['x_fc_customer_spec_id'] = (
|
||||||
|
part.x_fc_default_customer_spec_id.id
|
||||||
|
)
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def _prepare_invoice_line(self, **optional_values):
|
||||||
|
"""Carry x_fc_customer_spec_id to the invoice line."""
|
||||||
|
vals = super()._prepare_invoice_line(**optional_values)
|
||||||
|
if self.x_fc_customer_spec_id:
|
||||||
|
vals['x_fc_customer_spec_id'] = self.x_fc_customer_spec_id.id
|
||||||
|
return vals
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
<group string="Applicable Processes" name="applicable_processes">
|
<group string="Applicable Processes" name="applicable_processes">
|
||||||
<field name="process_type_ids" widget="many2many_tags" nolabel="1"/>
|
<field name="process_type_ids" widget="many2many_tags" nolabel="1"/>
|
||||||
</group>
|
</group>
|
||||||
|
<group string="Applicable Recipes" name="applicable_recipes">
|
||||||
|
<field name="recipe_ids" widget="many2many_tags" nolabel="1"
|
||||||
|
options="{'no_create_edit': True}"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="print_on_cert"/>
|
||||||
|
</group>
|
||||||
<notebook>
|
<notebook>
|
||||||
<page string="Notes">
|
<page string="Notes">
|
||||||
<field name="notes"/>
|
<field name="notes"/>
|
||||||
@@ -84,10 +91,35 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="action_fp_customer_spec" model="ir.actions.act_window">
|
<record id="action_fp_customer_spec" model="ir.actions.act_window">
|
||||||
<field name="name">Customer Specifications</field>
|
<field name="name">Specifications</field>
|
||||||
<field name="res_model">fusion.plating.customer.spec</field>
|
<field name="res_model">fusion.plating.customer.spec</field>
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
<field name="search_view_id" ref="view_fp_customer_spec_search"/>
|
<field name="search_view_id" ref="view_fp_customer_spec_search"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Add your first specification
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The Specifications library holds every standard your
|
||||||
|
customers cite on POs — industry standards (AMS 2404,
|
||||||
|
ASTM B733, MIL-C-26074), prime-specific codes (Boeing
|
||||||
|
BAC 5680, Lockheed LMS-3045), and your own internal
|
||||||
|
references.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When an estimator picks a Specification on a sale order
|
||||||
|
line, the certificate auto-fills with the spec's code
|
||||||
|
and revision (e.g. "AMS 2404 Rev D"). Aerospace flags
|
||||||
|
(Nadcap required, FAI required, AS9100 clauses) drive
|
||||||
|
workflow gates downstream.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Add a new Specification the moment a new code shows up
|
||||||
|
on a customer PO — there's no need to wait for a
|
||||||
|
manager. Set the document URL so the controlled copy
|
||||||
|
is one click away during audits.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Adds the Specification picker to the direct-order wizard line.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_direct_order_wizard_form_spec_inherit"
|
||||||
|
model="ir.ui.view">
|
||||||
|
<field name="name">fp.direct.order.wizard.form.spec.inherit</field>
|
||||||
|
<field name="model">fp.direct.order.wizard</field>
|
||||||
|
<field name="inherit_id"
|
||||||
|
ref="fusion_plating_configurator.view_fp_direct_order_wizard_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<!-- Wizard line list (main editable rows). Anchor on
|
||||||
|
internal_description (stable, configurator-defined). -->
|
||||||
|
<xpath expr="//field[@name='line_ids']/list/field[@name='internal_description']"
|
||||||
|
position="after">
|
||||||
|
<field name="customer_spec_id"
|
||||||
|
string="Specification"
|
||||||
|
options="{'no_quick_create': True}"
|
||||||
|
optional="show"/>
|
||||||
|
</xpath>
|
||||||
|
<!-- Wizard line drawer / form view -->
|
||||||
|
<xpath expr="//field[@name='line_ids']/form//field[@name='process_variant_id']"
|
||||||
|
position="before">
|
||||||
|
<field name="customer_spec_id"
|
||||||
|
string="Specification"
|
||||||
|
options="{'no_quick_create': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -68,11 +68,18 @@
|
|||||||
action="action_fp_cal_event"
|
action="action_fp_cal_event"
|
||||||
sequence="60"/>
|
sequence="60"/>
|
||||||
|
|
||||||
|
<!-- Promote-Customer-Spec (Phase F) — Specifications is now central
|
||||||
|
to order entry (estimators add new BAC / AMS / customer codes
|
||||||
|
when they hit them). Moved out of Configuration (manager-only)
|
||||||
|
into Quality where the workflow lives. Single menu, accessible
|
||||||
|
to estimator/supervisor/manager via the Quality top-level.
|
||||||
|
Old xmlid kept so links / breadcrumbs / search references
|
||||||
|
continue to resolve. -->
|
||||||
<menuitem id="menu_fp_config_customer_spec"
|
<menuitem id="menu_fp_config_customer_spec"
|
||||||
name="Customer Specs"
|
name="Specifications"
|
||||||
parent="fusion_plating.menu_fp_config_quality_docs"
|
parent="menu_fp_quality"
|
||||||
action="action_fp_customer_spec"
|
action="action_fp_customer_spec"
|
||||||
sequence="10"/>
|
sequence="70"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_config_avl"
|
<menuitem id="menu_fp_config_avl"
|
||||||
name="Approved Vendor List"
|
name="Approved Vendor List"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Adds the "Default Specification" picker to the part catalog form
|
||||||
|
next to "Default Treatment". Phase E removes the legacy field
|
||||||
|
entirely.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_part_catalog_form_spec_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">fp.part.catalog.form.spec.inherit</field>
|
||||||
|
<field name="model">fp.part.catalog</field>
|
||||||
|
<field name="inherit_id"
|
||||||
|
ref="fusion_plating_configurator.view_fp_part_catalog_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<!-- Anchor on default_process_id (stable, in core).
|
||||||
|
Default Treatment block was removed in Phase E. -->
|
||||||
|
<xpath expr="//field[@name='default_process_id']"
|
||||||
|
position="after">
|
||||||
|
<field name="x_fc_default_customer_spec_id"
|
||||||
|
string="Default Specification"
|
||||||
|
options="{'no_create_edit': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Adds Specification + Recipe pickers to the pricing rule form.
|
||||||
|
Both fields live on this module's inherit (the customer.spec model
|
||||||
|
lives here).
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_pricing_rule_form_quality_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">fp.pricing.rule.form.quality.spec.inherit</field>
|
||||||
|
<field name="model">fp.pricing.rule</field>
|
||||||
|
<field name="inherit_id"
|
||||||
|
ref="fusion_plating_configurator.view_fp_pricing_rule_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='substrate_material']"
|
||||||
|
position="before">
|
||||||
|
<field name="customer_spec_id"
|
||||||
|
options="{'no_quick_create': True}"/>
|
||||||
|
<field name="recipe_id"
|
||||||
|
options="{'no_quick_create': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Adds the "Applicable Specifications" group to the recipe form
|
||||||
|
(defined in core under the "Specification & Bake" notebook page).
|
||||||
|
Lives here because the field applicable_spec_ids is added by an
|
||||||
|
inherit in this module.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_process_node_form_quality_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.plating.process.node.form.quality.inherit</field>
|
||||||
|
<field name="model">fusion.plating.process.node</field>
|
||||||
|
<field name="inherit_id" ref="fusion_plating.view_fp_process_node_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//page[@name='spec_metadata']" position="inside">
|
||||||
|
<group string="Applicable Specifications">
|
||||||
|
<field name="applicable_spec_ids" nolabel="1"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -65,8 +65,10 @@
|
|||||||
placeholder="All parts if empty"/>
|
placeholder="All parts if empty"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="coating_config_ids" widget="many2many_tags"
|
<field name="customer_spec_ids" widget="many2many_tags"
|
||||||
placeholder="All coatings if empty"/>
|
placeholder="All specs if empty"/>
|
||||||
|
<field name="recipe_ids" widget="many2many_tags"
|
||||||
|
placeholder="All recipes if empty"/>
|
||||||
<field name="step_kind"
|
<field name="step_kind"
|
||||||
invisible="trigger_type != 'job_step_done'"
|
invisible="trigger_type != 'job_step_done'"
|
||||||
placeholder="Any step kind if empty"/>
|
placeholder="Any step kind if empty"/>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Adds the Specification picker to the SO line tree (the configurator's
|
||||||
|
main editable line list inside the SO form). The Spec field lives on
|
||||||
|
sale.order.line as an _inherit added in this module, so the view
|
||||||
|
that surfaces it must also live here.
|
||||||
|
|
||||||
|
During Phases B-D the Spec picker sits ALONGSIDE the legacy
|
||||||
|
Primary Treatment picker (both visible). Phase E removes the legacy
|
||||||
|
field entirely.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Configurator's view_sale_order_form_fp inherits sale.view_order_form
|
||||||
|
and adds Plating fields to the order_line tree. We inherit THAT
|
||||||
|
view to add Specification next to Part Catalog. -->
|
||||||
|
<record id="view_sale_order_form_quality_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.form.quality.spec.inherit</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="inherit_id"
|
||||||
|
ref="fusion_plating_configurator.view_sale_order_form_fp"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<!-- Editable order_line tree (estimator's main grid).
|
||||||
|
Anchor on x_fc_internal_description because it's
|
||||||
|
unique to the editable list (not in the read-only
|
||||||
|
summary list at the form bottom). -->
|
||||||
|
<xpath expr="//field[@name='x_fc_internal_description']"
|
||||||
|
position="after">
|
||||||
|
<field name="x_fc_customer_spec_id"
|
||||||
|
string="Specification"
|
||||||
|
options="{'no_quick_create': True}"
|
||||||
|
optional="show"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- The SO list's coating column is on sale.order itself (header
|
||||||
|
field). Adding a parallel spec column on the order header is
|
||||||
|
a Phase B+ enhancement — for now, the line tree (above) is
|
||||||
|
sufficient for the operator. -->
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from . import fp_contract_review_client_email_wizard
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class FpContractReviewClientEmailWizard(models.TransientModel):
|
||||||
|
"""Email-composer wizard for the Contract Review "Awaiting Client Info"
|
||||||
|
workflow. Pre-fills subject + body from the QA failure reason so the
|
||||||
|
QA Signer (Brett, or any other configured signer) can ping the
|
||||||
|
customer in a single click.
|
||||||
|
|
||||||
|
Sending the wizard:
|
||||||
|
1. Posts a chatter message of message_type='email' on the review
|
||||||
|
(the smart-button counter on the review form picks this up).
|
||||||
|
2. Sends the actual email via mail.mail to the customer's email.
|
||||||
|
3. Stamps `info_requested_date` on the review the first time, so
|
||||||
|
the form clearly shows when the request went out.
|
||||||
|
"""
|
||||||
|
_name = 'fp.contract.review.client.email.wizard'
|
||||||
|
_description = 'Contract Review — Email Client (Request Info)'
|
||||||
|
|
||||||
|
review_id = fields.Many2one(
|
||||||
|
'fp.contract.review',
|
||||||
|
string='Contract Review',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
customer_id = fields.Many2one(
|
||||||
|
'res.partner',
|
||||||
|
related='review_id.customer_id',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
recipient_email = fields.Char(
|
||||||
|
string='To',
|
||||||
|
required=True,
|
||||||
|
help='Customer contact email. Edit if the request needs to go to a '
|
||||||
|
'specific buyer / engineer.',
|
||||||
|
)
|
||||||
|
recipient_name = fields.Char(
|
||||||
|
string='Recipient Name',
|
||||||
|
)
|
||||||
|
subject = fields.Char(
|
||||||
|
string='Subject',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
body = fields.Html(
|
||||||
|
string='Message',
|
||||||
|
required=True,
|
||||||
|
sanitize=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
vals = super().default_get(fields_list)
|
||||||
|
review_id = self.env.context.get('default_review_id')
|
||||||
|
if review_id:
|
||||||
|
review = self.env['fp.contract.review'].browse(review_id)
|
||||||
|
company = review.company_id or self.env.company
|
||||||
|
part_label = (review.part_id and review.part_id.display_name) or '-'
|
||||||
|
po_label = review.contract_po_number or review.quote_or_job_number or '-'
|
||||||
|
failure_html = review.qa_failure_reason or _(
|
||||||
|
'<p>(Reason not yet captured — type details here.)</p>'
|
||||||
|
)
|
||||||
|
if 'subject' in fields_list and not vals.get('subject'):
|
||||||
|
vals['subject'] = _(
|
||||||
|
'%(company)s — Information request for Contract Review '
|
||||||
|
'%(name)s (PO %(po)s)'
|
||||||
|
) % {
|
||||||
|
'company': company.name or '',
|
||||||
|
'name': review.name or '',
|
||||||
|
'po': po_label,
|
||||||
|
}
|
||||||
|
if 'body' in fields_list and not vals.get('body'):
|
||||||
|
vals['body'] = _(
|
||||||
|
'<p>Hello %(recipient)s,</p>'
|
||||||
|
'<p>We are reviewing your contract for <b>%(part)s</b> '
|
||||||
|
'(PO %(po)s) and need additional information to '
|
||||||
|
'finalise our QA-005 review.</p>'
|
||||||
|
'<p><b>Items requiring clarification:</b></p>'
|
||||||
|
'%(failure)s'
|
||||||
|
'<p>Please reply with the requested information at '
|
||||||
|
'your earliest convenience so we can complete the '
|
||||||
|
'review and proceed with production.</p>'
|
||||||
|
'<p>Thank you,<br/>%(company)s — Quality Team</p>'
|
||||||
|
) % {
|
||||||
|
'recipient': (review.customer_id.name or _('there')),
|
||||||
|
'part': part_label,
|
||||||
|
'po': po_label,
|
||||||
|
'failure': failure_html,
|
||||||
|
'company': company.name or '',
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def action_send(self):
|
||||||
|
"""Send the email + post chatter + stamp request date."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.recipient_email:
|
||||||
|
raise UserError(_(
|
||||||
|
'A recipient email is required. Set the customer\'s email '
|
||||||
|
'on their contact card or override here.'
|
||||||
|
))
|
||||||
|
review = self.review_id
|
||||||
|
# Post into the review's chatter as message_type='email' so the
|
||||||
|
# smart-button counter picks it up. message_post handles the
|
||||||
|
# actual mail.mail send when partner_ids / email_to is set.
|
||||||
|
review.message_post(
|
||||||
|
body=self.body,
|
||||||
|
subject=self.subject,
|
||||||
|
message_type='email',
|
||||||
|
subtype_xmlid='mail.mt_comment',
|
||||||
|
partner_ids=review.customer_id.ids if review.customer_id else [],
|
||||||
|
email_layout_xmlid='mail.mail_notification_light',
|
||||||
|
email_add_signature=True,
|
||||||
|
)
|
||||||
|
# Belt-and-braces direct send to the recipient_email when it
|
||||||
|
# differs from the partner's primary email (e.g. a buyer-specific
|
||||||
|
# address typed into the wizard).
|
||||||
|
partner_email = review.customer_id.email if review.customer_id else ''
|
||||||
|
if self.recipient_email and self.recipient_email != partner_email:
|
||||||
|
self.env['mail.mail'].sudo().create({
|
||||||
|
'subject': self.subject,
|
||||||
|
'body_html': self.body,
|
||||||
|
'email_from': self.env.user.email_formatted or
|
||||||
|
(review.company_id and review.company_id.email) or '',
|
||||||
|
'email_to': self.recipient_email,
|
||||||
|
'auto_delete': True,
|
||||||
|
'model': 'fp.contract.review',
|
||||||
|
'res_id': review.id,
|
||||||
|
}).send()
|
||||||
|
if not review.info_requested_date:
|
||||||
|
review.write({'info_requested_date': fields.Datetime.now()})
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<record id="view_fp_contract_review_client_email_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.contract.review.client.email.wizard.form</field>
|
||||||
|
<field name="model">fp.contract.review.client.email.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Email Client — Request Info">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<field name="review_id" readonly="1"/>
|
||||||
|
<field name="customer_id" readonly="1"/>
|
||||||
|
<field name="recipient_email"/>
|
||||||
|
<field name="recipient_name"/>
|
||||||
|
<field name="subject"/>
|
||||||
|
</group>
|
||||||
|
<separator string="Message"/>
|
||||||
|
<field name="body" placeholder="Compose the message to the client. The body has been pre-filled with the QA failure reason — edit as needed."/>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_send"
|
||||||
|
type="object"
|
||||||
|
string="Send Email"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-paper-plane"/>
|
||||||
|
<button special="cancel"
|
||||||
|
string="Cancel"
|
||||||
|
class="btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.10.16.0',
|
'version': '19.0.11.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -94,9 +94,9 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
|
<t t-if="'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range">
|
||||||
<br/>
|
<br/>
|
||||||
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>
|
<small>Thickness: <span t-esc="line.x_fc_thickness_range"/></small>
|
||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
|
|||||||
@@ -110,10 +110,10 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="info-header">Coating Config</th>
|
<th class="info-header">Specification</th>
|
||||||
<td>
|
<td>
|
||||||
<t t-if="so and so.x_fc_coating_config_id">
|
<t t-if="so and so.x_fc_customer_spec_id">
|
||||||
<span t-field="so.x_fc_coating_config_id"/>
|
<span t-field="so.x_fc_customer_spec_id"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">—</t>
|
<t t-else="">—</t>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -75,19 +75,19 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Plating info -->
|
<!-- Plating info -->
|
||||||
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id or doc.x_fc_delivery_method">
|
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id or doc.x_fc_delivery_method">
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="info-header" style="width: 34%;">PART</th>
|
<th class="info-header" style="width: 34%;">PART</th>
|
||||||
<th class="info-header" style="width: 33%;">COATING CONFIG</th>
|
<th class="info-header" style="width: 33%;">SPECIFICATION</th>
|
||||||
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
|
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
|
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
|
||||||
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td>
|
<td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
|
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
|
||||||
<span t-esc="dm"/>
|
<span t-esc="dm"/>
|
||||||
@@ -340,12 +340,12 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Plating details -->
|
<!-- Plating details -->
|
||||||
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id">
|
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id">
|
||||||
<table class="bordered info-table">
|
<table class="bordered info-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>PART CATALOG</th>
|
<th>PART CATALOG</th>
|
||||||
<th>COATING CONFIGURATION</th>
|
<th>SPECIFICATION</th>
|
||||||
<th>INVOICE STRATEGY</th>
|
<th>INVOICE STRATEGY</th>
|
||||||
<th>DEPOSIT %</th>
|
<th>DEPOSIT %</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
|
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
|
||||||
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td>
|
<td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
|
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
|
||||||
<span t-esc="inv_strat"/>
|
<span t-esc="inv_strat"/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* _mo — the mrp.production record (or False)
|
* _mo — the mrp.production record (or False)
|
||||||
* _so, _line — the originating sale order / line
|
* _so, _line — the originating sale order / line
|
||||||
* _part — fp.part.catalog
|
* _part — fp.part.catalog
|
||||||
* _coating — fp.coating.config
|
* _spec — fusion.plating.customer.spec (audit-tracked spec)
|
||||||
* _process — the resolved fusion.plating.process.node tree
|
* _process — the resolved fusion.plating.process.node tree
|
||||||
* _due — datetime/date for "Due Date" row
|
* _due — datetime/date for "Due Date" row
|
||||||
* _qty — float for "Qty" row
|
* _qty — float for "Qty" row
|
||||||
@@ -47,10 +47,9 @@
|
|||||||
or (_so and _so.order_line[:1])
|
or (_so and _so.order_line[:1])
|
||||||
or False"/>
|
or False"/>
|
||||||
<t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/>
|
<t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/>
|
||||||
<t t-set="_coating" t-value="_coating or (_line and _line.x_fc_coating_config_id) or False"/>
|
<t t-set="_spec" t-value="_spec or (_line and _line.x_fc_customer_spec_id) or False"/>
|
||||||
<t t-set="_process" t-value="_process
|
<t t-set="_process" t-value="_process
|
||||||
or (_part and _part.default_process_id)
|
or (_part and _part.default_process_id)
|
||||||
or (_coating and _coating.recipe_id)
|
|
||||||
or False"/>
|
or False"/>
|
||||||
<t t-set="_due" t-value="_due
|
<t t-set="_due" t-value="_due
|
||||||
or (_mo and (_mo.date_deadline or _mo.date_finished))
|
or (_mo and (_mo.date_deadline or _mo.date_finished))
|
||||||
@@ -88,14 +87,11 @@
|
|||||||
<!-- Serial number — Sub 5 added x_fc_serial_id (M2O fp.serial) on
|
<!-- Serial number — Sub 5 added x_fc_serial_id (M2O fp.serial) on
|
||||||
the SO line. The serial record's `name` is the printable label. -->
|
the SO line. The serial record's `name` is the printable label. -->
|
||||||
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_id' in _line._fields and _line.x_fc_serial_id and _line.x_fc_serial_id.name) or '-'"/>
|
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_id' in _line._fields and _line.x_fc_serial_id and _line.x_fc_serial_id.name) or '-'"/>
|
||||||
<!-- Thickness — Sub 5 added x_fc_thickness_id (M2O fp.coating.thickness)
|
<!-- Thickness — operator-typed Char range, e.g. "0.0005-0.0008 mils".
|
||||||
on the SO line. `display_name` is the human-readable range, e.g.
|
Stored as-typed; ASCII-safe by convention. Strip en/em-dash
|
||||||
"0.3–0.5 mils". The en-dash (U+2013) in display_name mojibakes
|
defensively for the wkhtmltopdf font path on entech. -->
|
||||||
to "â€"" through wkhtmltopdf's font path on entech, so we
|
<t t-set="_thickness_raw" t-value="_line and 'x_fc_thickness_range' in _line._fields and _line.x_fc_thickness_range"/>
|
||||||
swap en-dash + em-dash for a plain hyphen-minus before
|
<t t-set="_thickness" t-value="(_thickness_raw and _thickness_raw.replace(u'–', '-').replace(u'—', '-')) or '-'"/>
|
||||||
rendering. ASCII-only printable for any QR-label printer. -->
|
|
||||||
<t t-set="_thickness_dn" t-value="_line and 'x_fc_thickness_id' in _line._fields and _line.x_fc_thickness_id and _line.x_fc_thickness_id.display_name"/>
|
|
||||||
<t t-set="_thickness" t-value="(_thickness_dn and _thickness_dn.replace(u'–', '-').replace(u'—', '-')) or '-'"/>
|
|
||||||
<!-- Notes content — outer can pre-set this (e.g. the Internal
|
<!-- Notes content — outer can pre-set this (e.g. the Internal
|
||||||
variant passes line.x_fc_internal_description). Otherwise
|
variant passes line.x_fc_internal_description). Otherwise
|
||||||
falls back to line.name (customer-facing description per
|
falls back to line.name (customer-facing description per
|
||||||
@@ -468,7 +464,7 @@
|
|||||||
<t t-set="_so" t-value="so"/>
|
<t t-set="_so" t-value="so"/>
|
||||||
<t t-set="_line" t-value="line"/>
|
<t t-set="_line" t-value="line"/>
|
||||||
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
||||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
|
||||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||||
@@ -498,7 +494,7 @@
|
|||||||
<t t-set="_so" t-value="so"/>
|
<t t-set="_so" t-value="so"/>
|
||||||
<t t-set="_line" t-value="line"/>
|
<t t-set="_line" t-value="line"/>
|
||||||
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
||||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
|
||||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Safety (EHS)',
|
'name': 'Fusion Plating — Safety (EHS)',
|
||||||
'version': '19.0.1.2.0',
|
'version': '19.0.1.3.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Occupational health and safety for plating shops: SDS library, '
|
'summary': 'Occupational health and safety for plating shops: SDS library, '
|
||||||
'WHMIS/TDG training, exposure monitoring, JHSC, incidents, PPE, '
|
'WHMIS/TDG training, exposure monitoring, JHSC, incidents, PPE, '
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user