Split 49 modules/suites into independent git repos; untrack from monorepo
Each top-level module/suite folder is now its own private repo on GitHub (gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial commit. The monorepo no longer tracks them (added to .gitignore + git rm --cached); working-tree files are retained on disk and managed in their own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/, tools/, AGENTS.md, WIP/obsolete dirs) and full history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -1,128 +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 . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import tests
|
||||
|
||||
|
||||
def _backfill_currency(env):
|
||||
"""Fill missing currency_id on existing money-holding records.
|
||||
|
||||
Older demo data and manually-created rows were persisted before the
|
||||
`required=True` was added, so some records sit with currency_id=NULL
|
||||
and Monetary fields render without a $ symbol. This runs on module
|
||||
install/upgrade and pins them to the company's currency.
|
||||
"""
|
||||
company_currency = env.company.currency_id.id
|
||||
if not company_currency:
|
||||
return
|
||||
for model_name in (
|
||||
'fp.pricing.rule',
|
||||
'fp.quote.configurator',
|
||||
):
|
||||
Model = env.get(model_name)
|
||||
if Model is None:
|
||||
continue
|
||||
Model.search([('currency_id', '=', False)]).write(
|
||||
{'currency_id': company_currency}
|
||||
)
|
||||
|
||||
|
||||
def _backfill_cloned_process_names(env):
|
||||
"""Append " - <part_number> Rev <revision>" to every existing part-
|
||||
cloned process ROOT whose name doesn't already carry the suffix.
|
||||
|
||||
Feedback on 2026-04-23: the Process tab on the part form was
|
||||
showing a bare template name ("General Processing"), so users
|
||||
couldn't tell at a glance that the clone belonged to THIS part.
|
||||
The clone logic now adds the suffix automatically; this backfill
|
||||
brings older clones up to the same format without forcing
|
||||
users to re-compose (which would wipe their edits).
|
||||
|
||||
Idempotent: checks for a literal " - " separator before rewriting.
|
||||
"""
|
||||
Node = env['fusion.plating.process.node']
|
||||
roots = Node.search([
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
('parent_id', '=', False),
|
||||
])
|
||||
renamed = 0
|
||||
for root in roots:
|
||||
part = root.part_catalog_id
|
||||
if not part:
|
||||
continue
|
||||
if ' - ' in (root.name or ''):
|
||||
continue # Already has a suffix - leave alone.
|
||||
suffix_bits = []
|
||||
if part.part_number:
|
||||
suffix_bits.append(part.part_number)
|
||||
if part.revision:
|
||||
# `revision` sometimes already carries a "Rev " prefix
|
||||
# (e.g. "Rev 2") - don't double up.
|
||||
rev = part.revision.strip()
|
||||
if not rev.lower().startswith('rev'):
|
||||
rev = 'Rev %s' % rev
|
||||
suffix_bits.append(rev)
|
||||
if not suffix_bits:
|
||||
continue
|
||||
root.name = '%s - %s' % (root.name or '', ' '.join(suffix_bits))
|
||||
renamed += 1
|
||||
|
||||
|
||||
def _backfill_part_material_id(env):
|
||||
"""Pin existing parts AND quote configurators to a row in the
|
||||
shared material library.
|
||||
|
||||
Pre-Sub-12d, both models only had a `substrate_material` Selection.
|
||||
This sets `material_id` on every record that doesn't yet have one,
|
||||
matching by substrate_material → seed material XML id. Idempotent.
|
||||
"""
|
||||
Part = env['fp.part.catalog']
|
||||
Material = env['fp.part.material']
|
||||
if Part is None or Material is None:
|
||||
return
|
||||
# Map legacy Selection key → seed XML id (the generic per-category entry).
|
||||
xmlid_by_key = {
|
||||
'aluminium': 'fusion_plating_configurator.fp_material_aluminium',
|
||||
'steel': 'fusion_plating_configurator.fp_material_steel',
|
||||
'stainless': 'fusion_plating_configurator.fp_material_stainless',
|
||||
'copper': 'fusion_plating_configurator.fp_material_copper',
|
||||
'titanium': 'fusion_plating_configurator.fp_material_titanium',
|
||||
'other': 'fusion_plating_configurator.fp_material_other',
|
||||
}
|
||||
cache = {}
|
||||
for key, xmlid in xmlid_by_key.items():
|
||||
rec = env.ref(xmlid, raise_if_not_found=False)
|
||||
if rec:
|
||||
cache[key] = rec.id
|
||||
if not cache:
|
||||
return
|
||||
# Parts
|
||||
for part in Part.search([('material_id', '=', False)]):
|
||||
mid = cache.get(part.substrate_material)
|
||||
if mid:
|
||||
part.material_id = mid
|
||||
# Quote configurators (same Selection key → same library)
|
||||
Quote = env['fp.quote.configurator']
|
||||
if Quote is not None:
|
||||
for q in Quote.search([('material_id', '=', False)]):
|
||||
mid = cache.get(q.substrate_material)
|
||||
if mid:
|
||||
q.material_id = mid
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
_backfill_currency(env)
|
||||
_backfill_cloned_process_names(env)
|
||||
_backfill_part_material_id(env)
|
||||
|
||||
|
||||
def post_upgrade_hook(env):
|
||||
_backfill_currency(env)
|
||||
_backfill_cloned_process_names(env)
|
||||
_backfill_part_material_id(env)
|
||||
@@ -1,112 +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.
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating - Configurator',
|
||||
'version': '19.0.22.13.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
Fusion Plating - Configurator
|
||||
==============================
|
||||
|
||||
Part of the Fusion Plating product family by Nexa Systems Inc.
|
||||
|
||||
Provides:
|
||||
- Customer part catalog with geometry and material data
|
||||
- Coating configuration templates (process, thickness, spec)
|
||||
- Pre/post treatment library
|
||||
- Formula-based pricing engine with complexity surcharges
|
||||
- Configurator sessions that generate sale orders
|
||||
- Custom sale order views with plating-specific fields
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://www.nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'support': 'support@nexasystems.ca',
|
||||
'license': 'OPL-1',
|
||||
'price': 0.00,
|
||||
'currency': 'CAD',
|
||||
'depends': [
|
||||
'fusion_plating',
|
||||
'sale_management',
|
||||
'fusion_pdf_preview',
|
||||
],
|
||||
'data': [
|
||||
'security/fp_configurator_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_configurator_sequence_data.xml',
|
||||
'data/fp_sub5_sequence_data.xml',
|
||||
'data/fp_part_material_data.xml',
|
||||
'views/fp_part_material_views.xml',
|
||||
'views/fp_part_catalog_views.xml',
|
||||
'views/fp_process_node_part_scoped_views.xml',
|
||||
'views/fp_pricing_rule_views.xml',
|
||||
'views/fp_quote_configurator_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_sale_description_template_views.xml',
|
||||
'views/fp_serial_views.xml',
|
||||
'wizard/fp_direct_order_wizard_views.xml',
|
||||
'views/fp_express_order_views.xml',
|
||||
'wizard/fp_add_from_so_wizard_views.xml',
|
||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||
'wizard/fp_quote_promote_wizard_views.xml',
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'views/fp_so_job_sort_views.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
||||
'fusion_plating_configurator/static/src/xml/fp_drawing_preview.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_drawing_preview.js',
|
||||
'fusion_plating_configurator/static/src/xml/fp_pdf_inline_preview.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js',
|
||||
# Sub 3 - part-scoped Process Composer
|
||||
'fusion_plating_configurator/static/src/scss/fp_part_process_composer.scss',
|
||||
'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
|
||||
# Express Orders (2026-05-26) - tokens MUST load FIRST so
|
||||
# $xpr-* vars are in scope for the consumer SCSS below.
|
||||
'fusion_plating_configurator/static/src/scss/_express_tokens.scss',
|
||||
'fusion_plating_configurator/static/src/scss/express_order.scss',
|
||||
# OWL widgets - multi-row Part cell + click-to-edit Bake pill
|
||||
# + stacked DWG/OPEN action buttons
|
||||
'fusion_plating_configurator/static/src/js/express_part_cell.js',
|
||||
'fusion_plating_configurator/static/src/js/express_bake_pill.js',
|
||||
'fusion_plating_configurator/static/src/js/express_action_btns.js',
|
||||
'fusion_plating_configurator/static/src/xml/express_part_cell.xml',
|
||||
'fusion_plating_configurator/static/src/xml/express_bake_pill.xml',
|
||||
'fusion_plating_configurator/static/src/xml/express_action_btns.xml',
|
||||
],
|
||||
# Register colour-aware SCSS in both bundles so the
|
||||
# `@if $o-webclient-color-scheme == dark` branch compiles for
|
||||
# the dark variant (see CLAUDE.md "Dark Mode" - Odoo 19 has no
|
||||
# runtime DOM toggle, two pre-built bundles).
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
'fusion_plating_configurator/static/src/scss/_express_tokens.scss',
|
||||
'fusion_plating_configurator/static/src/scss/express_order.scss',
|
||||
'fusion_plating_configurator/static/src/js/express_part_cell.js',
|
||||
'fusion_plating_configurator/static/src/js/express_bake_pill.js',
|
||||
'fusion_plating_configurator/static/src/js/express_action_btns.js',
|
||||
'fusion_plating_configurator/static/src/xml/express_part_cell.xml',
|
||||
'fusion_plating_configurator/static/src/xml/express_bake_pill.xml',
|
||||
'fusion_plating_configurator/static/src/xml/express_action_btns.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'post_load': None,
|
||||
'post_upgrade_hook': 'post_upgrade_hook',
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import configurator_controller
|
||||
from . import fp_part_composer_controller
|
||||
@@ -1,101 +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.
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpConfiguratorController(http.Controller):
|
||||
|
||||
@http.route('/fp/3d-viewer', type='http', auth='user', website=False)
|
||||
def viewer_3d(self, **kw):
|
||||
"""Serve the standalone 3D viewer HTML page.
|
||||
|
||||
Query params: id (attachment ID), name (filename for format detection).
|
||||
The HTML page loads Online3DViewer and renders the model.
|
||||
"""
|
||||
from odoo.modules.module import get_module_path
|
||||
import os
|
||||
mod_path = get_module_path('fusion_plating_configurator')
|
||||
html_path = os.path.join(
|
||||
mod_path, 'static', 'src', 'html', '3d_viewer.html',
|
||||
)
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return request.make_response(content, headers=[
|
||||
('Content-Type', 'text/html; charset=utf-8'),
|
||||
])
|
||||
|
||||
@http.route('/fp/3d-model/<int:attachment_id>/<string:filename>',
|
||||
type='http', auth='user', website=False)
|
||||
def serve_3d_model(self, attachment_id, filename, **kw):
|
||||
"""Serve a 3D model file from ir.attachment.
|
||||
|
||||
This bypasses the /web/content auth issues when loading inside
|
||||
an iframe. The filename in the URL ensures Online3DViewer can
|
||||
detect the format from the extension.
|
||||
"""
|
||||
attachment = request.env['ir.attachment'].browse(attachment_id)
|
||||
if not attachment.exists():
|
||||
return request.not_found()
|
||||
raw = base64.b64decode(attachment.datas)
|
||||
# Map common CAD extensions to MIME types
|
||||
mime_map = {
|
||||
'.step': 'application/step', '.stp': 'application/step',
|
||||
'.iges': 'application/iges', '.igs': 'application/iges',
|
||||
'.stl': 'application/sla',
|
||||
'.brep': 'application/octet-stream', '.brp': 'application/octet-stream',
|
||||
'.obj': 'text/plain', '.gltf': 'model/gltf+json', '.glb': 'model/gltf-binary',
|
||||
}
|
||||
import os
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_type = mime_map.get(ext, 'application/octet-stream')
|
||||
return request.make_response(raw, headers=[
|
||||
('Content-Type', content_type),
|
||||
('Content-Disposition', f'inline; filename="{filename}"'),
|
||||
('Content-Length', str(len(raw))),
|
||||
])
|
||||
|
||||
@http.route('/fp/configurator/calculate_surface_area', type='jsonrpc', auth='user')
|
||||
def calculate_surface_area(self, attachment_id, **kw):
|
||||
"""Calculate surface area from an uploaded STL file using trimesh."""
|
||||
attachment = request.env['ir.attachment'].browse(int(attachment_id))
|
||||
if not attachment.exists():
|
||||
return {'error': 'Attachment not found.'}
|
||||
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError:
|
||||
return {'error': 'trimesh library not installed. Run: pip install trimesh'}
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(attachment.datas)
|
||||
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
|
||||
|
||||
# trimesh returns area in the file's native units (usually mm²)
|
||||
area_mm2 = mesh.area
|
||||
area_sqin = area_mm2 / 645.16 # mm² to sq in
|
||||
|
||||
return {
|
||||
'surface_area': round(area_sqin, 4),
|
||||
'surface_area_mm2': round(area_mm2, 2),
|
||||
'unit': 'sq_in',
|
||||
'vertex_count': len(mesh.vertices),
|
||||
'face_count': len(mesh.faces),
|
||||
'bounding_box': {
|
||||
'x': round(float(mesh.bounding_box.extents[0]), 2),
|
||||
'y': round(float(mesh.bounding_box.extents[1]), 2),
|
||||
'z': round(float(mesh.bounding_box.extents[2]), 2),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.warning('STL surface area calculation failed: %s', e)
|
||||
return {'error': str(e)}
|
||||
@@ -1,333 +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.
|
||||
#
|
||||
# Sub 3 - part-scoped Process Composer RPC.
|
||||
#
|
||||
# Endpoints:
|
||||
# POST /fp/part/composer/state - part info + current tree status
|
||||
# POST /fp/part/composer/templates - list shared-template recipes
|
||||
# POST /fp/part/composer/load_template - clone a shared template into a part
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Node fields we defensively copy from source → cloned node.
|
||||
# Only include fields that definitely exist on fusion.plating.process.node.
|
||||
# Verified against fusion_plating/models/fp_process_node.py.
|
||||
_CLONABLE_FIELDS = (
|
||||
'description',
|
||||
'notes',
|
||||
'icon',
|
||||
'color',
|
||||
'estimated_duration',
|
||||
'auto_complete',
|
||||
'customer_visible',
|
||||
'is_manual',
|
||||
'requires_signoff',
|
||||
'active',
|
||||
'process_type_id',
|
||||
'work_center_id',
|
||||
)
|
||||
|
||||
|
||||
def _list_variants(part):
|
||||
"""Return a list of {id, label, is_default, node_count} for a part's variants."""
|
||||
Node = part.env['fusion.plating.process.node']
|
||||
variants = part.process_variant_ids.sorted(
|
||||
lambda v: (not v.is_default_variant, v.variant_label or v.name or '')
|
||||
)
|
||||
out = []
|
||||
for v in variants:
|
||||
node_count = Node.search_count([
|
||||
('part_catalog_id', '=', part.id),
|
||||
('id', 'child_of', v.id),
|
||||
])
|
||||
out.append({
|
||||
'id': v.id,
|
||||
'label': v.variant_label or v.name or '(unnamed)',
|
||||
'name': v.name or '',
|
||||
'is_default': bool(v.is_default_variant),
|
||||
'node_count': node_count,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _clone_subtree(env, source, part, parent):
|
||||
"""Recursively clone a process node subtree for a specific part.
|
||||
|
||||
Creates a new node mirroring ``source`` with part ownership set,
|
||||
links it to ``cloned_from_id``, then recurses into child nodes.
|
||||
|
||||
:param env: active Odoo environment.
|
||||
:param source: source fusion.plating.process.node (shared template or otherwise).
|
||||
:param part: fp.part.catalog record receiving the clone.
|
||||
:param parent: parent fusion.plating.process.node for the new node, or False for root.
|
||||
:return: newly created root node (recordset).
|
||||
"""
|
||||
Node = env['fusion.plating.process.node']
|
||||
|
||||
# Root clone gets a part-identifier suffix so the part form's
|
||||
# Default Process field reads like "General Processing - 1234567
|
||||
# Rev 2" instead of a bare template name. Child nodes keep the
|
||||
# source names unchanged - the suffix would only clutter the tree.
|
||||
if parent is False:
|
||||
suffix_bits = []
|
||||
if part.part_number:
|
||||
suffix_bits.append(part.part_number)
|
||||
if part.revision:
|
||||
# `revision` sometimes already carries a "Rev " prefix
|
||||
# (e.g. "Rev 2") - don't double up.
|
||||
rev = (part.revision or '').strip()
|
||||
if rev and not rev.lower().startswith('rev'):
|
||||
rev = 'Rev %s' % rev
|
||||
if rev:
|
||||
suffix_bits.append(rev)
|
||||
node_name = source.name or ''
|
||||
if suffix_bits:
|
||||
node_name = '%s - %s' % (node_name, ' '.join(suffix_bits))
|
||||
else:
|
||||
node_name = source.name
|
||||
|
||||
vals = {
|
||||
'name': node_name,
|
||||
'code': False, # codes must be globally unique; don't carry over
|
||||
'node_type': source.node_type,
|
||||
'sequence': source.sequence,
|
||||
'opt_in_out': source.opt_in_out,
|
||||
'treatment_uom': source.treatment_uom,
|
||||
'part_catalog_id': part.id,
|
||||
'cloned_from_id': source.id,
|
||||
'parent_id': parent.id if parent else False,
|
||||
}
|
||||
|
||||
# Copy additional fields defensively - skip anything missing on the
|
||||
# model (future-safe for field removals).
|
||||
for fname in _CLONABLE_FIELDS:
|
||||
if fname in source._fields:
|
||||
try:
|
||||
value = source[fname]
|
||||
# Many2one → extract id; everything else passes through.
|
||||
if source._fields[fname].type == 'many2one':
|
||||
vals[fname] = value.id if value else False
|
||||
else:
|
||||
vals[fname] = value
|
||||
except Exception:
|
||||
# Field exists but read failed - ignore and move on.
|
||||
pass
|
||||
|
||||
new_node = Node.create(vals)
|
||||
|
||||
# Copy operator-input prompts (temperature reading, visual inspection,
|
||||
# etc.) onto the cloned node. Without this, "Load Template" copies the
|
||||
# step structure but loses every custom prompt the recipe author set up
|
||||
# - operators end up with empty data-capture screens. .copy() handles
|
||||
# every field on the input model (kind, target_min/max/unit,
|
||||
# compliance_tag, sequence, hint, …) and rebinds node_id via override.
|
||||
for src_input in source.input_ids:
|
||||
src_input.copy({'node_id': new_node.id})
|
||||
|
||||
# Recurse into children in deterministic sequence order.
|
||||
for child in source.child_ids.sorted('sequence'):
|
||||
_clone_subtree(env, child, part, new_node)
|
||||
|
||||
return new_node
|
||||
|
||||
|
||||
class FpPartComposerController(http.Controller):
|
||||
"""JSON-RPC endpoints for the part-scoped Process Composer."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read - current part + tree status
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/part/composer/state', type='jsonrpc', auth='user')
|
||||
def state(self, part_id):
|
||||
"""Return part info, current default tree, and full variant list."""
|
||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
root = part.default_process_id
|
||||
return {
|
||||
'ok': True,
|
||||
'part': {
|
||||
'id': part.id,
|
||||
'part_number': part.part_number or '',
|
||||
'revision': part.revision or '',
|
||||
'name': part.name or '',
|
||||
'display': part.display_name or '',
|
||||
'customer': part.partner_id.display_name or '',
|
||||
},
|
||||
'has_tree': bool(root),
|
||||
'root_id': root.id if root else False,
|
||||
'variants': _list_variants(part),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List - shared-template recipes
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/part/composer/templates', type='jsonrpc', auth='user')
|
||||
def templates(self):
|
||||
"""Return shared-template recipes (part_catalog_id IS NULL, node_type='recipe')."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
templates = Node.search([
|
||||
('part_catalog_id', '=', False),
|
||||
('node_type', '=', 'recipe'),
|
||||
('active', '=', True),
|
||||
], order='name asc')
|
||||
return {
|
||||
'ok': True,
|
||||
'templates': [
|
||||
{'id': t.id, 'name': t.name or '', 'code': t.code or ''}
|
||||
for t in templates
|
||||
],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write - create a new variant by cloning a template OR another variant
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user')
|
||||
def load_template(self, part_id, template_id, variant_label=None,
|
||||
make_default=None):
|
||||
"""Clone a shared template into a NEW variant on this part.
|
||||
|
||||
Unlike the previous behaviour (wipe & replace), this now adds a
|
||||
variant alongside any existing ones. The first variant created
|
||||
becomes the default; subsequent variants only become default if
|
||||
``make_default`` is true.
|
||||
|
||||
If ``variant_label`` is omitted, the controller uses the
|
||||
template's name as the label.
|
||||
"""
|
||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||
tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
if not tpl:
|
||||
return {'ok': False, 'error': 'Template not found'}
|
||||
if tpl.part_catalog_id:
|
||||
return {'ok': False, 'error': 'Invalid template (must be a shared-template recipe)'}
|
||||
if tpl.node_type != 'recipe':
|
||||
return {'ok': False, 'error': 'Template must be a recipe-type node'}
|
||||
|
||||
label = (variant_label or tpl.name or 'Variant').strip()
|
||||
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
# First variant on this part is always the default.
|
||||
is_first = not part.process_variant_ids
|
||||
make_default_flag = bool(make_default) or is_first
|
||||
|
||||
new_root = _clone_subtree(request.env, tpl, part, parent=False)
|
||||
new_root.variant_label = label
|
||||
new_root.is_default_variant = make_default_flag
|
||||
|
||||
if make_default_flag:
|
||||
# Clear flag from any other variants and pin default_process_id.
|
||||
others = part.process_variant_ids.filtered(
|
||||
lambda v: v.id != new_root.id and v.is_default_variant
|
||||
)
|
||||
if others:
|
||||
others.write({'is_default_variant': False})
|
||||
part.default_process_id = new_root.id
|
||||
|
||||
node_count = request.env['fusion.plating.process.node'].search_count([
|
||||
('id', 'child_of', new_root.id),
|
||||
])
|
||||
|
||||
_logger.info(
|
||||
'Part Composer: variant "%s" cloned from template %s onto part %s (default=%s, %s nodes), uid %s',
|
||||
label, tpl.id, part.id, make_default_flag, node_count, request.env.uid,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'root_id': new_root.id,
|
||||
'node_count': node_count,
|
||||
'variants': _list_variants(part),
|
||||
}
|
||||
except Exception as exc:
|
||||
_logger.exception('Part Composer load_template failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Variant CRUD
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/part/composer/duplicate_variant', type='jsonrpc', auth='user')
|
||||
def duplicate_variant(self, part_id, source_variant_id, variant_label=None):
|
||||
"""Deep-copy an existing variant into a new variant on the same part."""
|
||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||
src = request.env['fusion.plating.process.node'].browse(int(source_variant_id)).exists()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
if not src or src.part_catalog_id.id != part.id or src.parent_id:
|
||||
return {'ok': False, 'error': 'Invalid source variant'}
|
||||
|
||||
label = (variant_label or ((src.variant_label or src.name or 'Variant') + ' (copy)')).strip()
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
new_root = _clone_subtree(request.env, src, part, parent=False)
|
||||
new_root.variant_label = label
|
||||
new_root.is_default_variant = False # never auto-default a duplicate
|
||||
node_count = request.env['fusion.plating.process.node'].search_count([
|
||||
('id', 'child_of', new_root.id),
|
||||
])
|
||||
return {
|
||||
'ok': True,
|
||||
'root_id': new_root.id,
|
||||
'node_count': node_count,
|
||||
'variants': _list_variants(part),
|
||||
}
|
||||
except Exception as exc:
|
||||
_logger.exception('Part Composer duplicate_variant failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
@http.route('/fp/part/composer/rename_variant', type='jsonrpc', auth='user')
|
||||
def rename_variant(self, part_id, variant_id, variant_label):
|
||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
if not v or v.part_catalog_id.id != part.id or v.parent_id:
|
||||
return {'ok': False, 'error': 'Invalid variant'}
|
||||
label = (variant_label or '').strip()
|
||||
if not label:
|
||||
return {'ok': False, 'error': 'Label cannot be empty'}
|
||||
v.variant_label = label
|
||||
return {'ok': True, 'variants': _list_variants(part)}
|
||||
|
||||
@http.route('/fp/part/composer/set_default_variant', type='jsonrpc', auth='user')
|
||||
def set_default_variant(self, part_id, variant_id):
|
||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
ok = part.action_set_default_variant(int(variant_id))
|
||||
if not ok:
|
||||
return {'ok': False, 'error': 'Variant does not belong to this part'}
|
||||
return {'ok': True, 'variants': _list_variants(part)}
|
||||
|
||||
@http.route('/fp/part/composer/delete_variant', type='jsonrpc', auth='user')
|
||||
def delete_variant(self, part_id, variant_id):
|
||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
if not v or v.part_catalog_id.id != part.id or v.parent_id:
|
||||
return {'ok': False, 'error': 'Invalid variant'}
|
||||
if v.is_default_variant and len(part.process_variant_ids) > 1:
|
||||
return {'ok': False,
|
||||
'error': 'Cannot delete the default variant. Set another variant as default first.'}
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
if part.default_process_id.id == v.id:
|
||||
part.default_process_id = False
|
||||
# ondelete=cascade on parent_id wipes descendants.
|
||||
v.unlink()
|
||||
return {'ok': True, 'variants': _list_variants(part)}
|
||||
except Exception as exc:
|
||||
_logger.exception('Part Composer delete_variant failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="charge_type_tooling" model="fp.additional.charge.type">
|
||||
<field name="name">Tooling Charge</field>
|
||||
<field name="sequence">1</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,25 +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">
|
||||
|
||||
<record id="seq_fp_quote_configurator" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Configurator</field>
|
||||
<field name="code">fp.quote.configurator</field>
|
||||
<field name="prefix">CFG-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="seq_fp_direct_order_wizard" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Direct Order Draft</field>
|
||||
<field name="code">fp.direct.order.wizard</field>
|
||||
<field name="prefix">DOD-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,134 +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.
|
||||
|
||||
Seed materials. noupdate="1" so users can rename / archive without
|
||||
a module upgrade reverting their edits.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Aluminium family -->
|
||||
<record id="fp_material_aluminium" model="fp.part.material">
|
||||
<field name="name">Aluminium</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_6061" model="fp.part.material">
|
||||
<field name="name">Aluminium 6061</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">11</field>
|
||||
<field name="notes">Common 6000-series alloy. Magnesium + silicon.</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_6063" model="fp.part.material">
|
||||
<field name="name">Aluminium 6063</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">12</field>
|
||||
<field name="notes">Architectural 6000-series alloy.</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_7075" model="fp.part.material">
|
||||
<field name="name">Aluminium 7075</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">13</field>
|
||||
<field name="notes">High-strength 7000-series. Aerospace.</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_2024" model="fp.part.material">
|
||||
<field name="name">Aluminium 2024</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">14</field>
|
||||
<field name="notes">2000-series. Copper alloy, aerospace.</field>
|
||||
</record>
|
||||
|
||||
<!-- Steel family -->
|
||||
<record id="fp_material_steel" model="fp.part.material">
|
||||
<field name="name">Steel</field>
|
||||
<field name="category">steel</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
<record id="fp_material_steel_1018" model="fp.part.material">
|
||||
<field name="name">Steel 1018</field>
|
||||
<field name="category">steel</field>
|
||||
<field name="sequence">21</field>
|
||||
<field name="notes">Low-carbon mild steel.</field>
|
||||
</record>
|
||||
<record id="fp_material_steel_4140" model="fp.part.material">
|
||||
<field name="name">Steel 4140</field>
|
||||
<field name="category">steel</field>
|
||||
<field name="sequence">22</field>
|
||||
<field name="notes">Chrome-moly alloy steel.</field>
|
||||
</record>
|
||||
|
||||
<!-- Stainless family -->
|
||||
<record id="fp_material_stainless" model="fp.part.material">
|
||||
<field name="name">Stainless Steel</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
<record id="fp_material_stainless_304" model="fp.part.material">
|
||||
<field name="name">Stainless 304</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">31</field>
|
||||
<field name="notes">Austenitic. General-purpose stainless.</field>
|
||||
</record>
|
||||
<record id="fp_material_stainless_316" model="fp.part.material">
|
||||
<field name="name">Stainless 316</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">32</field>
|
||||
<field name="notes">Marine-grade. Molybdenum-bearing.</field>
|
||||
</record>
|
||||
<record id="fp_material_stainless_17_4" model="fp.part.material">
|
||||
<field name="name">Stainless 17-4 PH</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">33</field>
|
||||
<field name="notes">Precipitation hardening.</field>
|
||||
</record>
|
||||
|
||||
<!-- Copper family -->
|
||||
<record id="fp_material_copper" model="fp.part.material">
|
||||
<field name="name">Copper</field>
|
||||
<field name="category">copper</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
<record id="fp_material_brass_360" model="fp.part.material">
|
||||
<field name="name">Brass C360</field>
|
||||
<field name="category">copper</field>
|
||||
<field name="sequence">41</field>
|
||||
<field name="density">8.5</field>
|
||||
<field name="notes">Free-machining brass.</field>
|
||||
</record>
|
||||
<record id="fp_material_bronze" model="fp.part.material">
|
||||
<field name="name">Bronze</field>
|
||||
<field name="category">copper</field>
|
||||
<field name="sequence">42</field>
|
||||
<field name="density">8.8</field>
|
||||
</record>
|
||||
|
||||
<!-- Titanium family -->
|
||||
<record id="fp_material_titanium" model="fp.part.material">
|
||||
<field name="name">Titanium</field>
|
||||
<field name="category">titanium</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
<record id="fp_material_titanium_grade_2" model="fp.part.material">
|
||||
<field name="name">Titanium Grade 2</field>
|
||||
<field name="category">titanium</field>
|
||||
<field name="sequence">51</field>
|
||||
<field name="notes">Commercially pure titanium.</field>
|
||||
</record>
|
||||
<record id="fp_material_titanium_grade_5" model="fp.part.material">
|
||||
<field name="name">Titanium Grade 5 (Ti-6Al-4V)</field>
|
||||
<field name="category">titanium</field>
|
||||
<field name="sequence">52</field>
|
||||
<field name="density">4.43</field>
|
||||
<field name="notes">Aerospace alloy.</field>
|
||||
</record>
|
||||
|
||||
<!-- Other -->
|
||||
<record id="fp_material_other" model="fp.part.material">
|
||||
<field name="name">Other</field>
|
||||
<field name="category">other</field>
|
||||
<field name="sequence">99</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,81 +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.
|
||||
|
||||
Eight starter description templates covering the common things
|
||||
estimators write on SO lines. noupdate="1" so customers can edit
|
||||
freely without upgrades clobbering their changes.
|
||||
|
||||
Sub 2 (Task 27): legacy `description` field dropped. Seed data now
|
||||
sets `internal_description` + `customer_facing_description` with the
|
||||
same text - estimators split them over time as needed.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="desc_tpl_enp_standard" model="fp.sale.description.template">
|
||||
<field name="name">ENP - Standard (AMS 2404 Class I)</field>
|
||||
<field name="tag">standard</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="internal_description">Electroless nickel plating per AMS 2404, Class I, Type II (medium phosphorus, 6-9%). Plate to 0.0005" thickness, heat-treat 4 hours @ 375°F for hydrogen embrittlement relief. Parts to be cleaned, deoxidised and activated prior to plating. All threaded holes & tapped features to remain unplated.</field>
|
||||
<field name="customer_facing_description">Electroless nickel plating per AMS 2404, Class I, Type II (medium phosphorus, 6-9%). Plate to 0.0005" thickness, heat-treat 4 hours @ 375°F for hydrogen embrittlement relief. Parts to be cleaned, deoxidised and activated prior to plating. All threaded holes & tapped features to remain unplated.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_enp_aerospace" model="fp.sale.description.template">
|
||||
<field name="name">ENP - Aerospace (AMS 2404 w/ CoC)</field>
|
||||
<field name="tag">aerospace</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="internal_description">Electroless nickel plating per AMS 2404, Class I, Type II, Grade A. Plate to customer-specified thickness. Post-bake 4 hours @ 375°F min. Certificate of Conformance and thickness readings (3 points minimum per lot) required. Traceability to raw material heat lot. Nadcap-accredited process.</field>
|
||||
<field name="customer_facing_description">Electroless nickel plating per AMS 2404, Class I, Type II, Grade A. Plate to customer-specified thickness. Post-bake 4 hours @ 375°F min. Certificate of Conformance and thickness readings (3 points minimum per lot) required. Traceability to raw material heat lot. Nadcap-accredited process.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_enp_nuclear" model="fp.sale.description.template">
|
||||
<field name="name">ENP - Nuclear (CSA N299 / 10CFR50 App B)</field>
|
||||
<field name="tag">nuclear</field>
|
||||
<field name="sequence">25</field>
|
||||
<field name="internal_description">Electroless nickel plating under CSA N299 / 10CFR50 Appendix B quality program. Full material traceability, dedicated tooling, independent QA inspection. Certificate package includes thickness, adhesion tape test, visual inspection sign-off, and chemistry log for the processing shift.</field>
|
||||
<field name="customer_facing_description">Electroless nickel plating under CSA N299 / 10CFR50 Appendix B quality program. Full material traceability, dedicated tooling, independent QA inspection. Certificate package includes thickness, adhesion tape test, visual inspection sign-off, and chemistry log for the processing shift.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_masking_threaded" model="fp.sale.description.template">
|
||||
<field name="name">Masking - Threaded & Tapped Features</field>
|
||||
<field name="tag">masking</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="internal_description">Selective plating. Mask all threaded holes, tapped features and mating surfaces per customer drawing. Non-plated areas to be free of residue. Remove masking prior to shipment. Any masking residue is cause for rejection.</field>
|
||||
<field name="customer_facing_description">Selective plating. Mask all threaded holes, tapped features and mating surfaces per customer drawing. Non-plated areas to be free of residue. Remove masking prior to shipment. Any masking residue is cause for rejection.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_masking_od" model="fp.sale.description.template">
|
||||
<field name="name">Masking - Selective O.D. / Journals</field>
|
||||
<field name="tag">masking</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="internal_description">Plate O.D. and specified journal surfaces only. Mask all bore surfaces, end faces, and sealing surfaces. Maintain ±0.0001" on masked-feature edges. Rack holes to be plugged.</field>
|
||||
<field name="customer_facing_description">Plate O.D. and specified journal surfaces only. Mask all bore surfaces, end faces, and sealing surfaces. Maintain ±0.0001" on masked-feature edges. Rack holes to be plugged.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_rework_strip" model="fp.sale.description.template">
|
||||
<field name="name">Rework - Strip & Replate</field>
|
||||
<field name="tag">rework</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="internal_description">Rework of previously-plated parts. Chemically strip existing nickel deposit without attacking the base metal. Dimensional inspection after strip - any parts outside blueprint tolerance to be held for customer disposition. Replate to original spec. New Certificate of Conformance issued for the rework lot.</field>
|
||||
<field name="customer_facing_description">Rework of previously-plated parts. Chemically strip existing nickel deposit without attacking the base metal. Dimensional inspection after strip - any parts outside blueprint tolerance to be held for customer disposition. Replate to original spec. New Certificate of Conformance issued for the rework lot.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_packaging_individual" model="fp.sale.description.template">
|
||||
<field name="name">Packaging - Individual Bag + Desiccant</field>
|
||||
<field name="tag">packaging</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="internal_description">Each part individually bagged in anti-static poly bag with desiccant pack. Bagged parts packed in cushioned cardboard cartons with corner protection. Outer carton labelled with part number, lot, quantity, and Entech W/O number. Do not ship open-top or mixed part-number cartons.</field>
|
||||
<field name="customer_facing_description">Each part individually bagged in anti-static poly bag with desiccant pack. Bagged parts packed in cushioned cardboard cartons with corner protection. Outer carton labelled with part number, lot, quantity, and Entech W/O number. Do not ship open-top or mixed part-number cartons.</field>
|
||||
</record>
|
||||
|
||||
<record id="desc_tpl_hazmat_note" model="fp.sale.description.template">
|
||||
<field name="name">Handling - Delicate / No Tumble</field>
|
||||
<field name="tag">other</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="internal_description">Delicate parts - rack plating only, no barrel. No tumbling or vibratory finishing before or after plating. Inspect for handling damage prior to final packaging. Any edge, surface or impact damage is cause for segregation.</field>
|
||||
<field name="customer_facing_description">Delicate parts - rack plating only, no barrel. No tumbling or vibratory finishing before or after plating. Inspect for handling damage prior to final packaging. Any edge, surface or impact damage is cause for segregation.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,26 +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.
|
||||
Sub 5 - sequences for serial numbers and job numbers on SO lines.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="seq_fp_serial" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Serial Number</field>
|
||||
<field name="code">fp.serial</field>
|
||||
<field name="prefix">FP-SN-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="seq_fp_job_number" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Job Number</field>
|
||||
<field name="code">fp.job.number</field>
|
||||
<field name="prefix">FP-JOB-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,235 +0,0 @@
|
||||
# Express Orders — Smoke Test Runbook
|
||||
|
||||
End-to-end manual checks for the Express Orders feature. Run after every deploy that touches `fusion_plating_configurator`, `fusion_plating_jobs`, or `fusion_plating`. Expect ~10 minutes if everything works; longer if a test fails and you have to dig into logs.
|
||||
|
||||
Spec: [docs/superpowers/specs/2026-05-26-express-orders-design.md](../../docs/superpowers/specs/2026-05-26-express-orders-design.md)
|
||||
Plan: [docs/superpowers/plans/2026-05-26-express-orders-plan.md](../../docs/superpowers/plans/2026-05-26-express-orders-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open Odoo (entech: https://erp.enplating.com, local: http://localhost:8069)
|
||||
2. Log in as a user with the Estimator role
|
||||
3. Navigate to: **Plating → Sales → + New Express Order**
|
||||
4. Confirm the Express form opens (NOT the legacy form). The header should say "Express Order Entry" and there should be a "Switch to Legacy View" button.
|
||||
|
||||
If the menu is missing or the wrong form opens, the deploy didn't land correctly. Check the manifest's `data` list includes `views/fp_express_order_views.xml`.
|
||||
|
||||
---
|
||||
|
||||
## Test 1 — Happy path with mask ON + bake non-empty
|
||||
|
||||
Goal: confirm the SO → job → step pipeline works for the simplest case (everything on).
|
||||
|
||||
1. Pick customer **WESTIN HEALTHCARE INC.** (or any partner with at least one part in the catalog)
|
||||
2. Confirm `partner_shipping_id` auto-fills to the customer's first child address
|
||||
3. Confirm `pricelist_id` defaults to the customer's pricelist (currency pill should match)
|
||||
4. Type PO# **`PO-SMOKE-01`** and upload any PDF
|
||||
5. Add a line: pick a part with a recipe. Auto-fill should populate description / thickness / process / masking checkbox (on) / bake text (from the part's defaults)
|
||||
6. Set quantity **5**, price **42.00**
|
||||
7. Click **Confirm Order**
|
||||
|
||||
Expected:
|
||||
- A `sale.order` is created in `quotation` state
|
||||
- The wizard transitions to `confirmed`
|
||||
- The "Open Sale Order" button appears
|
||||
- Clicking it opens the SO with the lines you entered
|
||||
|
||||
Verify via SQL (entech LXC 111):
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"SELECT id, name, partner_id, state FROM sale_order WHERE x_fc_po_number = '\''PO-SMOKE-01'\'';\""'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 2 — Masking opt-out
|
||||
|
||||
Goal: unchecking masking on a line should spawn override rows for masking + de_masking nodes.
|
||||
|
||||
1. Repeat Test 1 setup with a fresh PO# `PO-SMOKE-02`
|
||||
2. **Uncheck the Mask toggle on the line**
|
||||
3. Set bake to a non-empty text (e.g. `350F x 4hr`) so bake doesn't also opt out
|
||||
4. Confirm Order
|
||||
5. The SO confirms; an `fp.job` should be auto-created. Find it via:
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"SELECT j.id, j.name FROM fp_job j JOIN sale_order so ON j.sale_order_id=so.id WHERE so.x_fc_po_number='\''PO-SMOKE-02'\'';\""'
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `fp.job.node.override` rows exist with `included=False` for the recipe's masking + de_masking nodes:
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"SELECT ov.id, n.name, n.default_kind, ov.included FROM fp_job_node_override ov JOIN fusion_plating_process_node n ON ov.node_id=n.id WHERE ov.job_id=<job-id-from-above>;\""'
|
||||
```
|
||||
- Open the job in the UI. The audit chatter should contain: **"Masking + de-masking steps opted out (per SO line)"**
|
||||
|
||||
---
|
||||
|
||||
## Test 3 — Bake opt-out
|
||||
|
||||
Goal: emptying the bake cell should spawn an override for the recipe's baking node.
|
||||
|
||||
1. Fresh order with PO# `PO-SMOKE-03`
|
||||
2. Keep Mask checked
|
||||
3. **Clear the Bake cell** (the value should be empty / "no bake")
|
||||
4. Confirm Order
|
||||
5. Verify the resulting `fp.job` has `fp.job.node.override` row(s) for nodes with `default_kind='baking'`, `included=False`
|
||||
6. Audit chatter on the job: **"Baking steps opted out (per SO line)"**
|
||||
|
||||
---
|
||||
|
||||
## Test 4 — Bake instructions write through to step
|
||||
|
||||
Goal: typed bake instructions should appear on the operator tablet's bake step.
|
||||
|
||||
1. Fresh order with PO# `PO-SMOKE-04`
|
||||
2. Set bake to **`375F x 2hr`** on the line
|
||||
3. Confirm Order
|
||||
4. **Assign To Me** on the resulting SO (this calls `fp.job.action_confirm` which generates steps)
|
||||
5. Open the job's step list; find the step with `recipe_node_id.default_kind='baking'`
|
||||
|
||||
Expected:
|
||||
- The bake step's `instructions` field reads **`375F x 2hr`**
|
||||
- Audit chatter: **"Bake step instructions set to: 375F x 2hr"**
|
||||
|
||||
Verify via SQL:
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"SELECT s.id, s.name, s.instructions FROM fp_job_step s JOIN fusion_plating_process_node n ON s.recipe_node_id=n.id WHERE n.default_kind='\''baking'\'' AND s.job_id=<job-id>;\""'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 5 — Part-default write-back
|
||||
|
||||
Goal: values typed on the line should persist to the part's defaults for next time.
|
||||
|
||||
1. Pick a part that has NO `default_bake_instructions` set yet
|
||||
2. Type bake `400F x 1hr` and Specification `Per drawing rev C, mirror finish`
|
||||
3. Confirm Order
|
||||
4. Reload the part record:
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"SELECT part_number, default_bake_instructions, default_specification_text, default_masking_enabled FROM fp_part_catalog WHERE id=<part-id>;\""'
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `default_bake_instructions = '400F x 1hr'`
|
||||
- `default_specification_text = 'Per drawing rev C, mirror finish'`
|
||||
- `default_masking_enabled` matches what was on the line
|
||||
|
||||
Next, fresh order with the SAME part:
|
||||
- Auto-fill should pre-populate the bake + specification cells from the part defaults
|
||||
|
||||
---
|
||||
|
||||
## Test 6 — Customer line ref (ABC/DEF/GHJ)
|
||||
|
||||
Goal: per-line customer reference persists to the SO line and prints on docs.
|
||||
|
||||
1. Fresh order with 2 lines
|
||||
2. Type `ABC` in Line Job # on line 1, `DEF` on line 2
|
||||
3. Confirm Order
|
||||
4. Open the resulting SO; the two lines should have the same Line Job # values
|
||||
5. Print the Sale Order PDF — the Customer Line Job # should appear in the line table column
|
||||
|
||||
Verify via SQL:
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"SELECT id, x_fc_customer_line_ref FROM sale_order_line WHERE order_id=<so-id>;\""'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 7 — Bulk-add serials (existing wizard, new trigger)
|
||||
|
||||
Goal: confirm the `+ bulk` button on the part cell's serial row opens the bulk-add wizard.
|
||||
|
||||
1. Open a draft Express Order. Pick a part on a line.
|
||||
2. (v1 deferment: the inline `+ bulk` button isn't on the form yet — this test currently has to use the standard Serials field directly. Skip until C polish lands.)
|
||||
|
||||
---
|
||||
|
||||
## Test 8 — DWG / OPEN buttons per line
|
||||
|
||||
Goal: drawings upload to the part record, OPEN navigates to the part form.
|
||||
|
||||
(v1 deferment: not on the form yet — comes in C polish.)
|
||||
|
||||
---
|
||||
|
||||
## Test 9 — Currency switch
|
||||
|
||||
Goal: changing the pricelist updates the currency pill and triggers a price recompute prompt.
|
||||
|
||||
1. Open a draft. Pricelist defaults to CAD.
|
||||
2. Add a line at $42.00 CAD.
|
||||
3. Switch the Pricelist to a USD pricelist.
|
||||
4. Odoo prompts: **"Update Prices?"** Click Yes.
|
||||
|
||||
Expected:
|
||||
- The line's `unit_price` recomputes via the USD pricelist's rules
|
||||
- The Grand Total currency pill flips to USD
|
||||
- The line subtotal shows in USD
|
||||
|
||||
If you don't have a USD pricelist, this test can't run — confirm with admin that at least one alternative-currency pricelist exists.
|
||||
|
||||
---
|
||||
|
||||
## Test 10 — Round-trip with legacy view
|
||||
|
||||
Goal: Express drafts open in Express, Legacy drafts open in Legacy, but each can be flipped via the header button.
|
||||
|
||||
1. Create a draft in Express (PO `PO-SMOKE-10A`, don't confirm — just save)
|
||||
2. Go to **Plating → Sales → Direct Order Drafts**
|
||||
3. The list shows a `view_source` badge column. Your draft has `EXPRESS` (blue).
|
||||
4. Click the **Open** button on the row → confirm it opens in Express
|
||||
5. From the Express form, click **Switch to Legacy View** → confirms legacy form loads with same data
|
||||
6. Back to drafts list. Click Open again — should re-route to Legacy (because the draft's `view_source` is now... wait, the switch button doesn't change `view_source`, just temporarily renders in a different view. So Open should still go to Express.)
|
||||
|
||||
Verify behaviour above. Then:
|
||||
7. From **Plating → Sales → + New Direct Order (Legacy)**, create a draft. Save without confirming.
|
||||
8. Open the drafts list: this draft has `view_source = LEGACY` (muted badge).
|
||||
9. Click Open → legacy form opens.
|
||||
|
||||
---
|
||||
|
||||
## Quick PG verification — every Express field
|
||||
|
||||
After deploy, confirm all 17 new schema fields are present:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 'pct exec 111 -- bash -c "cd /tmp && sudo -u postgres psql -d admin -t -c \"
|
||||
SELECT table_name, column_name FROM information_schema.columns WHERE
|
||||
(table_name=\\\"fp_part_catalog\\\" AND column_name IN (\\\"default_specification_text\\\",\\\"default_bake_instructions\\\",\\\"default_masking_enabled\\\")) OR
|
||||
(table_name=\\\"sale_order_line\\\" AND column_name IN (\\\"x_fc_customer_line_ref\\\",\\\"x_fc_masking_enabled\\\",\\\"x_fc_bake_instructions\\\")) OR
|
||||
(table_name=\\\"sale_order\\\" AND column_name IN (\\\"x_fc_material_process\\\",\\\"x_fc_internal_notes\\\",\\\"x_fc_print_terms\\\")) OR
|
||||
(table_name=\\\"fp_direct_order_wizard\\\" AND column_name IN (\\\"terms_and_conditions\\\",\\\"internal_notes\\\",\\\"material_process\\\",\\\"pricelist_id\\\",\\\"validity_date\\\",\\\"view_source\\\")) OR
|
||||
(table_name=\\\"fp_direct_order_line\\\" AND column_name IN (\\\"customer_line_ref\\\",\\\"masking_enabled\\\",\\\"bake_instructions\\\"))
|
||||
ORDER BY table_name, column_name;
|
||||
\""'
|
||||
```
|
||||
|
||||
Expected: 17 rows. If fewer, the upgrade didn't fully land — re-run the canonical CLAUDE.md upgrade command.
|
||||
|
||||
---
|
||||
|
||||
## Common failure modes
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Menu "+ New Express Order" doesn't appear | manifest doesn't include `views/fp_express_order_views.xml`, OR module wasn't upgraded | Check manifest data list; re-run `-u fusion_plating_configurator` |
|
||||
| Form opens but bake/masking columns are empty | onchange `_onchange_part_default_thickness` didn't fire | Check `fp.direct.order.line.bake_instructions` field exists; check the onchange extension landed |
|
||||
| Confirm Order errors with `KeyError: 'x_fc_masking_enabled'` | `sale.order.line` field missing | Re-run module upgrade |
|
||||
| Job created but no override rows | `_fp_auto_create_job` hook missing | Check `fusion_plating_jobs/models/sale_order.py` for the Express overrides block after `Job.create(vals)` |
|
||||
| Override rows present but bake step.instructions empty | The second hook (in `fp.job.action_confirm`) didn't fire, OR steps weren't generated yet | Check `fusion_plating_jobs/models/fp_job.py` for the Express block after `_generate_steps_from_recipe()` |
|
||||
| Customer line ref blank on SO line | `_prepare_order_line_vals` carry-through missing | Check `fp_direct_order_wizard.py` for `x_fc_customer_line_ref` in the `so_vals['order_line'].append` block |
|
||||
|
||||
---
|
||||
|
||||
## Deploy command (canonical CLAUDE.md pattern)
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_configurator,fusion_plating_jobs,fusion_plating --stop-after-init\" > /tmp/odoo-up.log 2>&1; systemctl start odoo; tail -25 /tmp/odoo-up.log'"
|
||||
```
|
||||
|
||||
Expected: 20-30 seconds, exit 0, log ends with **"Modules loaded."**
|
||||
|
||||
Do NOT add `--test-enable` — it caused a 20-minute hang during the original deploy by getting tangled with production HTTP traffic. Run tests separately if needed (`--no-http --test-enable --test-tags fp_express`).
|
||||
@@ -1,56 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
# Sub 9 - Process Variants per Part. Runs on upgrade to 19.0.15.0.0.
|
||||
#
|
||||
# For every part that had a default_process_id, mark its root node as
|
||||
# the default variant and seed a friendly label. Idempotent (NULL guards).
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return # Fresh install - nothing to migrate
|
||||
|
||||
_logger.info("Sub 9: backfilling process variant flags")
|
||||
|
||||
# Step 1: Mark each part's existing default_process_id root as the
|
||||
# default variant. The flag is cleared on every other root so we
|
||||
# land in a consistent "exactly one default" state.
|
||||
cr.execute("""
|
||||
UPDATE fusion_plating_process_node
|
||||
SET is_default_variant = FALSE
|
||||
WHERE parent_id IS NULL
|
||||
AND node_type = 'recipe'
|
||||
AND part_catalog_id IS NOT NULL
|
||||
""")
|
||||
_logger.info("Sub 9: cleared is_default_variant on %d roots", cr.rowcount)
|
||||
|
||||
cr.execute("""
|
||||
UPDATE fusion_plating_process_node node
|
||||
SET is_default_variant = TRUE
|
||||
FROM fp_part_catalog part
|
||||
WHERE part.default_process_id = node.id
|
||||
AND node.parent_id IS NULL
|
||||
AND node.node_type = 'recipe'
|
||||
AND node.part_catalog_id = part.id
|
||||
""")
|
||||
_logger.info(
|
||||
"Sub 9: flagged is_default_variant on %d roots (one per part with default_process_id)",
|
||||
cr.rowcount,
|
||||
)
|
||||
|
||||
# Step 2: Seed variant_label='Default' on the now-flagged variants
|
||||
# so the picker shows something readable. Only fills NULL/empty.
|
||||
cr.execute("""
|
||||
UPDATE fusion_plating_process_node
|
||||
SET variant_label = 'Default'
|
||||
WHERE is_default_variant = TRUE
|
||||
AND (variant_label IS NULL OR variant_label = '')
|
||||
""")
|
||||
_logger.info("Sub 9: seeded variant_label='Default' on %d records", cr.rowcount)
|
||||
|
||||
_logger.info("Sub 9: migration complete")
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 1 multi-serial - backfill the new M2M relations from the
|
||||
# pre-existing single-M2O column on sale.order.line and account.move.line.
|
||||
#
|
||||
# x_fc_serial_id was historically a stored Many2one. Phase 1 made it a
|
||||
# computed alias of `x_fc_serial_ids` (the new M2M). Existing rows have
|
||||
# the old FK column populated but no rows in the M2M relation table.
|
||||
# This migration walks the legacy column and inserts one M2M row per
|
||||
# (line, serial) pair so smart buttons / reverse links continue to find
|
||||
# the linked records.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Backfill fp_sale_order_line_serial_rel + fp_account_move_line_serial_rel."""
|
||||
backfill_table(cr, 'sale_order_line', 'fp_sale_order_line_serial_rel', 'line_id')
|
||||
backfill_table(cr, 'account_move_line', 'fp_account_move_line_serial_rel', 'line_id')
|
||||
|
||||
|
||||
def backfill_table(cr, source_table, m2m_table, line_col):
|
||||
cr.execute(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = %s AND column_name = 'x_fc_serial_id'",
|
||||
(source_table,),
|
||||
)
|
||||
if not cr.fetchone():
|
||||
_logger.info("Phase 1 multi-serial: %s has no x_fc_serial_id column, skip", source_table)
|
||||
return
|
||||
|
||||
# Make sure the M2M table exists (Odoo creates it on registry load,
|
||||
# but the migration runs BEFORE the registry comes up on upgrade -
|
||||
# use IF NOT EXISTS to be safe).
|
||||
cr.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS "{m2m_table}" (
|
||||
"{line_col}" integer NOT NULL REFERENCES "{source_table}"(id) ON DELETE CASCADE,
|
||||
"serial_id" integer NOT NULL REFERENCES "fp_serial"(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY ("{line_col}", "serial_id")
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cr.execute(
|
||||
f"""
|
||||
INSERT INTO "{m2m_table}" ("{line_col}", "serial_id")
|
||||
SELECT id, x_fc_serial_id FROM "{source_table}"
|
||||
WHERE x_fc_serial_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
_logger.info(
|
||||
"Phase 1 multi-serial: backfilled %s rows from %s.x_fc_serial_id into %s",
|
||||
cr.rowcount, source_table, m2m_table,
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Drop the redundant ``revision_number`` Integer column on fp.part.catalog.
|
||||
|
||||
The model historically carried two revision fields:
|
||||
* ``revision`` (Char, required) - the customer's actual revision label
|
||||
* ``revision_number`` (Integer) - an internal counter
|
||||
|
||||
The Integer counter duplicated information already in ``revision`` and
|
||||
got out of sync whenever the customer used a non-numeric scheme
|
||||
(A/B/C, A1/A2, "ECO-2024-014" etc.). This migration drops the column.
|
||||
``action_create_revision`` and the auto-rev path on 3D-model upload now
|
||||
use ``_bump_revision_label`` which best-effort bumps the alphanumeric
|
||||
label and lets the user adjust to the customer's actual scheme.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_part_catalog DROP COLUMN IF EXISTS revision_number;
|
||||
""")
|
||||
@@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Drop the 'inspected' value from sale_order.x_fc_receiving_status.
|
||||
|
||||
Sub 8 (2026-04-22) moved part inspection out of receiving and into the
|
||||
recipe's racking step. The SO-level receiving status no longer needs
|
||||
'inspected' as a terminal value - 'received' (boxes counted/staged/
|
||||
closed) is now the final state.
|
||||
|
||||
This migration flips any existing rows with the obsolete value to the
|
||||
new terminal value. On a freshly-installed instance there are zero rows;
|
||||
the migration is defensive for instances that had pre-Sub-8 records.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE sale_order
|
||||
SET x_fc_receiving_status = 'received'
|
||||
WHERE x_fc_receiving_status = 'inspected'
|
||||
""")
|
||||
@@ -1,84 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Pre-migration for Express Orders (19.0.22.0.0).
|
||||
|
||||
Runs BEFORE Odoo's field-registration pass on `-u fusion_plating_configurator`,
|
||||
so the model can register `terms_and_conditions` and `pricelist_id` against
|
||||
columns that already hold data.
|
||||
|
||||
Actions:
|
||||
1. Rename `fp_direct_order_wizard.notes` → `terms_and_conditions`.
|
||||
Existing data preserves its semantic (always was customer-facing because
|
||||
the old `action_create_order` wrote it to sale.order.note).
|
||||
2. Add the new Express columns (idempotent - IF NOT EXISTS guards).
|
||||
3. Backfill `pricelist_id` from the legacy `currency_id` via any active
|
||||
pricelist matching the currency. After this, the model's stored-related
|
||||
currency_id (related='pricelist_id.currency_id') takes over.
|
||||
4. (No currency_id column drop here - Odoo's schema sync recognises the
|
||||
related field and keeps the column shape; data refreshes from the
|
||||
related lookup on subsequent writes.)
|
||||
|
||||
Dev-stage caveat: per the Express Orders design spec section 12, we are
|
||||
ignoring past orders. If `currency_id` data on legacy rows doesn't line up
|
||||
with the new pricelist_id-driven flow, that's acceptable.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# 1. Rename notes → terms_and_conditions (same column, same data)
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'fp_direct_order_wizard'
|
||||
AND column_name = 'notes'
|
||||
""")
|
||||
if cr.fetchone():
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_direct_order_wizard
|
||||
RENAME COLUMN notes TO terms_and_conditions
|
||||
""")
|
||||
|
||||
# 2. Add new Express columns (idempotent)
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_direct_order_wizard
|
||||
ADD COLUMN IF NOT EXISTS internal_notes TEXT
|
||||
""")
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_direct_order_wizard
|
||||
ADD COLUMN IF NOT EXISTS pricelist_id INTEGER
|
||||
REFERENCES product_pricelist(id) ON DELETE SET NULL
|
||||
""")
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_direct_order_wizard
|
||||
ADD COLUMN IF NOT EXISTS material_process VARCHAR
|
||||
""")
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_direct_order_wizard
|
||||
ADD COLUMN IF NOT EXISTS validity_date DATE
|
||||
""")
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_direct_order_wizard
|
||||
ADD COLUMN IF NOT EXISTS view_source VARCHAR DEFAULT 'legacy'
|
||||
""")
|
||||
# Note: view_source defaults to 'legacy' for EXISTING rows - they were
|
||||
# created via the legacy view. New rows default to 'express' via the model.
|
||||
|
||||
# 3. Backfill pricelist_id from any active pricelist matching the
|
||||
# legacy currency_id (only if the currency_id column still exists).
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'fp_direct_order_wizard'
|
||||
AND column_name = 'currency_id'
|
||||
""")
|
||||
if cr.fetchone():
|
||||
cr.execute("""
|
||||
UPDATE fp_direct_order_wizard w
|
||||
SET pricelist_id = (
|
||||
SELECT p.id
|
||||
FROM product_pricelist p
|
||||
WHERE p.currency_id = w.currency_id
|
||||
AND p.active = TRUE
|
||||
ORDER BY p.id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE w.pricelist_id IS NULL
|
||||
AND w.currency_id IS NOT NULL
|
||||
""")
|
||||
@@ -1,37 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Pre-migration for 19.0.22.1.0 - material_process Char → Many2One.
|
||||
|
||||
The material_process field on fp.direct.order.wizard was originally a
|
||||
free-text Char tag for shop-level metadata (e.g. "ENP-STEEL-HP-ADVANCED").
|
||||
Per 2026-05-27 customer feedback, it should instead link directly to a
|
||||
recipe (fusion.plating.process.node, node_type='recipe') so that picking
|
||||
a tag auto-applies the recipe to every order line.
|
||||
|
||||
Drop the old VARCHAR column so Odoo can recreate it as an INTEGER FK
|
||||
when the new field declaration loads. Per the Express Orders spec
|
||||
section 12 (dev-stage, ignore past orders), losing the old Char values
|
||||
is acceptable.
|
||||
|
||||
Idempotent - IF EXISTS guards mean a re-run is safe.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Same model needs the same pre-migration on sale.order too
|
||||
for table, column in (
|
||||
('fp_direct_order_wizard', 'material_process'),
|
||||
('sale_order', 'x_fc_material_process'),
|
||||
):
|
||||
cr.execute("""
|
||||
SELECT data_type FROM information_schema.columns
|
||||
WHERE table_name = %s AND column_name = %s
|
||||
""", (table, column))
|
||||
row = cr.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
data_type = row[0]
|
||||
# Only drop if the existing column is the old Char shape
|
||||
if data_type in ('character varying', 'text'):
|
||||
cr.execute(
|
||||
f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column}"
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
# Sub 2 - Part Data Model Overhaul. Runs on upgrade from < 19.0.9.0.0.
|
||||
# Idempotent (NULL / empty guards). Safe to re-run.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return # Fresh install - nothing to migrate
|
||||
|
||||
_logger.info("Sub 2: starting part-data-model migration to %s", version)
|
||||
|
||||
# Step 1: Backfill part_number from name where empty
|
||||
cr.execute("""
|
||||
UPDATE fp_part_catalog
|
||||
SET part_number = name
|
||||
WHERE part_number IS NULL OR part_number = ''
|
||||
""")
|
||||
_logger.info("Sub 2: backfilled part_number on %d records", cr.rowcount)
|
||||
|
||||
# Step 2: Backfill revision with 'A' where empty
|
||||
cr.execute("""
|
||||
UPDATE fp_part_catalog
|
||||
SET revision = 'A'
|
||||
WHERE revision IS NULL OR revision = ''
|
||||
""")
|
||||
_logger.info("Sub 2: backfilled revision on %d records", cr.rowcount)
|
||||
|
||||
# Step 3: Split fp_sale_description_template.description into two columns
|
||||
# Copy existing description into BOTH internal_description and
|
||||
# customer_facing_description. Estimators split them later.
|
||||
cr.execute("""
|
||||
UPDATE fp_sale_description_template
|
||||
SET internal_description = description,
|
||||
customer_facing_description = description
|
||||
WHERE description IS NOT NULL
|
||||
AND description <> ''
|
||||
AND (internal_description IS NULL OR internal_description = '')
|
||||
""")
|
||||
_logger.info(
|
||||
"Sub 2: duplicated description into new columns on %d template rows",
|
||||
cr.rowcount,
|
||||
)
|
||||
|
||||
# Step 4: Backfill x_fc_internal_description on sale.order.line
|
||||
# Copy the existing `name` (Odoo's line description) into internal so
|
||||
# historical lines satisfy the required-field check when it flips.
|
||||
cr.execute("""
|
||||
UPDATE sale_order_line
|
||||
SET x_fc_internal_description = name
|
||||
WHERE x_fc_internal_description IS NULL OR x_fc_internal_description = ''
|
||||
""")
|
||||
_logger.info(
|
||||
"Sub 2: backfilled x_fc_internal_description on %d SO lines",
|
||||
cr.rowcount,
|
||||
)
|
||||
|
||||
# Step 5: Default certificate_requirement to 'inherit' on any rows
|
||||
# where it's NULL (shouldn't happen given Odoo default=, but defensive).
|
||||
cr.execute("""
|
||||
UPDATE fp_part_catalog
|
||||
SET certificate_requirement = 'inherit'
|
||||
WHERE certificate_requirement IS NULL
|
||||
""")
|
||||
_logger.info(
|
||||
"Sub 2: defaulted certificate_requirement to 'inherit' on %d parts",
|
||||
cr.rowcount,
|
||||
)
|
||||
|
||||
# Step 6: Drop legacy description column (all reads migrated to new fields).
|
||||
# Runs after Steps 3 duplicated data into internal_description +
|
||||
# customer_facing_description. Idempotent via IF EXISTS.
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_sale_description_template
|
||||
DROP COLUMN IF EXISTS description
|
||||
""")
|
||||
_logger.info("Sub 2: dropped legacy description column")
|
||||
|
||||
_logger.info("Sub 2: migration complete")
|
||||
@@ -1,22 +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 . import fp_part_material
|
||||
from . import fp_part_catalog
|
||||
from . import fp_pricing_complexity_surcharge
|
||||
from . import fp_pricing_rule
|
||||
from . import fp_sale_description_template
|
||||
from . import fp_part_description_version
|
||||
from . import fp_additional_charge_type
|
||||
from . import fp_so_job_sort
|
||||
from . import fp_quote_configurator
|
||||
from . import fp_serial
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import account_move_line
|
||||
from . import fp_sale_assembly
|
||||
from . import res_partner
|
||||
from . import fp_process_node
|
||||
from . import product_pricelist
|
||||
@@ -1,76 +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.
|
||||
#
|
||||
# Sub 2 Task 19 - propagate the customer part reference from SO line to
|
||||
# invoice line so customer-facing invoice PDFs can print the part number
|
||||
# via the shared fusion_plating_reports.customer_line_header macro.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
def fp_customer_description(self):
|
||||
"""Strip the "[code] product_name" prefix from line.name.
|
||||
|
||||
Mirror of sale.order.line.fp_customer_description so the shared
|
||||
customer_line_description QWeb macro renders cleanly on invoice
|
||||
PDFs too.
|
||||
"""
|
||||
self.ensure_one()
|
||||
name = (self.name or '').strip()
|
||||
if not self.product_id or not name:
|
||||
return name
|
||||
code = self.product_id.default_code or ''
|
||||
pname = self.product_id.name or ''
|
||||
prefixes = []
|
||||
if code and pname:
|
||||
prefixes.append(f'[{code}] {pname}')
|
||||
if pname:
|
||||
prefixes.append(pname)
|
||||
for prefix in prefixes:
|
||||
if name.startswith(prefix):
|
||||
tail = name[len(prefix):]
|
||||
return tail.lstrip(' \t\r\n---:').strip()
|
||||
return name
|
||||
|
||||
x_fc_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Part',
|
||||
help="Copied from sale.order.line on invoice creation so customer-"
|
||||
"facing invoice PDFs can render the customer's part number.",
|
||||
)
|
||||
# ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
|
||||
x_fc_serial_ids = fields.Many2many(
|
||||
'fp.serial',
|
||||
relation='fp_account_move_line_serial_rel',
|
||||
column1='line_id',
|
||||
column2='serial_id',
|
||||
string='Serial Numbers',
|
||||
help='Copied from sale.order.line for traceability. Multi-serial '
|
||||
'support added 2026-04-28.',
|
||||
)
|
||||
x_fc_serial_id = fields.Many2one(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
index=True,
|
||||
help='Back-compat alias of the first serial in x_fc_serial_ids. '
|
||||
'Kept so legacy invoice templates that read the singular '
|
||||
'continue to render.',
|
||||
)
|
||||
x_fc_job_number = fields.Char(
|
||||
string='Job #', index=True,
|
||||
help='Copied from sale.order.line.',
|
||||
)
|
||||
x_fc_thickness_range = fields.Char(
|
||||
string='Thickness',
|
||||
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(
|
||||
string='Revision (snapshot)',
|
||||
help='Revision letter from the source SO line.',
|
||||
)
|
||||
@@ -1,29 +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 FpAdditionalChargeType(models.Model):
|
||||
"""A configurable, reusable 'additional charge' label (Tooling, Rush,
|
||||
Setup, …) picked on the order-entry summary. Searchable + quick-create.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-29-configurable-charge-tax-lot-pricing-design.md
|
||||
"""
|
||||
_name = 'fp.additional.charge.type'
|
||||
_description = 'Fusion Plating - Additional Charge Type'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Charge Type', required=True)
|
||||
default_amount = fields.Monetary(
|
||||
string='Default Amount', currency_field='currency_id',
|
||||
help='Optional amount pre-filled when this type is picked on an '
|
||||
'order. The operator can override it.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
@@ -1,80 +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 FpPartDescriptionVersion(models.Model):
|
||||
"""Immutable per-part snapshot of the internal + customer-facing
|
||||
description entered on an order. A new version is written on sale
|
||||
order confirm whenever the description changes
|
||||
(fp.part.catalog._fp_save_description_version). The latest version
|
||||
auto-loads into the next order line for the part.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-29-part-description-history-design.md
|
||||
"""
|
||||
_name = 'fp.part.description.version'
|
||||
_description = 'Fusion Plating - Part Description Version'
|
||||
_order = 'part_catalog_id, version_no desc, id desc'
|
||||
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part', required=True,
|
||||
ondelete='cascade', index=True,
|
||||
)
|
||||
internal_description = fields.Text(string='Internal Description')
|
||||
customer_facing_description = fields.Text(string='Customer-Facing Description')
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order', string='Sale Order', ondelete='set null')
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Order Line', ondelete='set null')
|
||||
source_date = fields.Date(string='Date')
|
||||
version_no = fields.Integer(string='Version', readonly=True)
|
||||
name = fields.Char(string='Reference', readonly=True)
|
||||
is_latest = fields.Boolean(string='Latest', default=False, index=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
related='part_catalog_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.model
|
||||
def _fp_build_name(self, vals):
|
||||
"""Title = '<SO name> · <date>', with a '(n)' suffix if a version
|
||||
with that title already exists for the part (e.g. two lines of the
|
||||
same part on one order)."""
|
||||
so = (self.env['sale.order'].browse(vals['sale_order_id'])
|
||||
if vals.get('sale_order_id') else None)
|
||||
order_ref = so.name if so and so.name else _('Manual')
|
||||
d = vals.get('source_date') or fields.Date.context_today(self)
|
||||
base = '%s · %s' % (order_ref, d)
|
||||
part_id = vals.get('part_catalog_id')
|
||||
if part_id:
|
||||
dup = self.search_count([
|
||||
('part_catalog_id', '=', part_id),
|
||||
('name', '=like', base + '%'),
|
||||
])
|
||||
if dup:
|
||||
base = '%s (%d)' % (base, dup + 1)
|
||||
return base
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
part_id = vals.get('part_catalog_id')
|
||||
if part_id and not vals.get('version_no'):
|
||||
prev = self.search(
|
||||
[('part_catalog_id', '=', part_id)],
|
||||
order='version_no desc', limit=1)
|
||||
vals['version_no'] = (prev.version_no or 0) + 1
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self._fp_build_name(vals)
|
||||
vals['is_latest'] = True
|
||||
records = super().create(vals_list)
|
||||
# Exactly one latest per part - flip prior latest rows off.
|
||||
for rec in records:
|
||||
rec.part_catalog_id.description_version_ids.filtered(
|
||||
lambda v, r=rec: v.id != r.id and v.is_latest
|
||||
).write({'is_latest': False})
|
||||
return records
|
||||
@@ -1,61 +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 FpPartMaterial(models.Model):
|
||||
"""Custom material library.
|
||||
|
||||
Lets shops define their own materials (e.g. "Aluminium 6061",
|
||||
"Stainless 316", "Brass C360") instead of being limited to the
|
||||
fixed Selection. Each material maps to a `category` that drives
|
||||
legacy pricing-rule matching and the default density used for
|
||||
material-weight rollups.
|
||||
"""
|
||||
_name = 'fp.part.material'
|
||||
_description = 'Fusion Plating - Part Material'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Material', required=True, translate=False)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
category = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'),
|
||||
('stainless', 'Stainless Steel'), ('copper', 'Copper'),
|
||||
('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Category', required=True, default='other',
|
||||
help='Used for pricing-rule matching and to pick a default '
|
||||
'density when one is not set explicitly.',
|
||||
)
|
||||
density = fields.Float(
|
||||
string='Density (g/cm³)', digits=(8, 4),
|
||||
help='Override the category default. Leave 0 to use the '
|
||||
'category density (Aluminium 2.70, Steel 7.85, '
|
||||
'Stainless 8.00, Copper 8.96, Titanium 4.51).',
|
||||
)
|
||||
notes = fields.Char(string='Notes', help='Internal note (alloy spec, source, etc.).')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
_CATEGORY_DENSITY = {
|
||||
'aluminium': 2.70,
|
||||
'steel': 7.85,
|
||||
'stainless': 8.00,
|
||||
'copper': 8.96,
|
||||
'titanium': 4.51,
|
||||
'other': 7.85,
|
||||
}
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_part_material_name_uniq', 'unique(name)',
|
||||
'Material name must be unique.'),
|
||||
]
|
||||
|
||||
def effective_density(self):
|
||||
"""Return density override if set, else the category default."""
|
||||
self.ensure_one()
|
||||
if self.density and self.density > 0:
|
||||
return self.density
|
||||
return self._CATEGORY_DENSITY.get(self.category, 7.85)
|
||||
@@ -1,34 +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 FpPricingComplexitySurcharge(models.Model):
|
||||
"""Complexity-based surcharge line on a pricing rule."""
|
||||
_name = 'fp.pricing.complexity.surcharge'
|
||||
_description = 'Fusion Plating - Pricing Complexity Surcharge'
|
||||
_order = 'complexity'
|
||||
|
||||
rule_id = fields.Many2one('fp.pricing.rule', string='Pricing Rule', required=True, ondelete='cascade')
|
||||
complexity = fields.Selection(
|
||||
[('simple', 'Simple'), ('moderate', 'Moderate'), ('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||
string='Complexity', required=True,
|
||||
)
|
||||
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
|
||||
|
||||
@api.depends('complexity', 'surcharge_percent')
|
||||
def _compute_display_name(self):
|
||||
labels = dict(self._fields['complexity'].selection)
|
||||
for rec in self:
|
||||
label = labels.get(rec.complexity, rec.complexity or '')
|
||||
if rec.surcharge_percent:
|
||||
label = '%s +%g%%' % (label, rec.surcharge_percent)
|
||||
rec.display_name = label or 'Surcharge'
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
|
||||
'Only one surcharge per complexity level per rule.'),
|
||||
]
|
||||
@@ -1,55 +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 FpPricingRule(models.Model):
|
||||
"""Formula-based pricing rule.
|
||||
|
||||
Rules are matched by coating config, substrate material, and
|
||||
certification level. The first matching rule (by sequence) wins.
|
||||
Global rules (no filters set) act as fallbacks.
|
||||
"""
|
||||
_name = 'fp.pricing.rule'
|
||||
_description = 'Fusion Plating - Pricing Rule'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Rule Name', required=True)
|
||||
# coating_config_id removed. Spec + recipe match keys live on
|
||||
# fusion_plating_quality.fp_pricing_rule_inherit. Material +
|
||||
# cert_level (below) remain as generic filters.
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Substrate Material', help='Leave blank to match all materials.',
|
||||
)
|
||||
certification_level = fields.Selection(
|
||||
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
|
||||
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
|
||||
string='Certification Level', help='Leave blank to match all levels.',
|
||||
)
|
||||
pricing_method = fields.Selection(
|
||||
[('per_sqin', 'Per Square Inch'), ('per_sqft', 'Per Square Foot'),
|
||||
('per_piece', 'Per Piece'), ('flat_rate', 'Flat Rate')],
|
||||
string='Pricing Method', required=True, default='per_sqin',
|
||||
)
|
||||
currency_id = fields.Many2one('res.currency', string='Currency',
|
||||
required=True, default=lambda self: self.env.company.currency_id)
|
||||
base_rate = fields.Monetary(string='Base Rate', currency_field='currency_id',
|
||||
help='Price per unit (sq in, sq ft, piece, or flat).')
|
||||
thickness_factor = fields.Float(string='Thickness Factor', default=1.0,
|
||||
help='Multiplier per mil of coating thickness. 1.0 = no adjustment.')
|
||||
complexity_surcharge_ids = fields.One2many('fp.pricing.complexity.surcharge', 'rule_id',
|
||||
string='Complexity Surcharges')
|
||||
masking_rate_per_zone = fields.Monetary(string='Masking Rate / Zone', currency_field='currency_id')
|
||||
setup_fee = fields.Monetary(string='Setup Fee', currency_field='currency_id',
|
||||
help='One-time setup fee per batch.')
|
||||
minimum_charge = fields.Monetary(string='Minimum Charge', currency_field='currency_id',
|
||||
help='Floor price.')
|
||||
rush_surcharge_percent = fields.Float(string='Rush Surcharge %')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
notes = fields.Text(string='Notes')
|
||||
@@ -1,119 +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.
|
||||
"""Configurator-side extensions to fusion.plating.process.node.
|
||||
|
||||
Lives here (not in core fusion_plating) so the core module doesn't have
|
||||
to depend on the configurator. Any field that references a model defined
|
||||
in configurator - like fp.pricing.rule, fp.part.catalog - must be
|
||||
declared here.
|
||||
"""
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
_inherit = 'fusion.plating.process.node'
|
||||
|
||||
# ---- Existing: pricing rule linkage (Steelhead "Use Price Builders") ----
|
||||
pricing_rule_ids = fields.Many2many(
|
||||
'fp.pricing.rule',
|
||||
relation='fp_process_node_pricing_rule_rel',
|
||||
column1='node_id',
|
||||
column2='rule_id',
|
||||
string='Price Builders',
|
||||
help='Pricing rules to apply when this recipe is selected on a '
|
||||
'quotation (mirrors Steelhead "Use Price Builders").',
|
||||
)
|
||||
|
||||
# ---- Sub 3: part ownership + template reference + treatment UoM --------
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Part',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
help='Populated on nodes that belong to a specific part\'s '
|
||||
'composed process tree. NULL on shared templates.',
|
||||
)
|
||||
cloned_from_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Cloned From',
|
||||
ondelete='set null',
|
||||
help='On a part-cloned node, points back at the source template '
|
||||
'node it was copied from.',
|
||||
)
|
||||
treatment_uom = fields.Selection(
|
||||
[
|
||||
('lbs', 'Lbs (weight-based)'),
|
||||
('sq_in', 'Sq in (area-based)'),
|
||||
],
|
||||
string='Treatment UoM',
|
||||
help='How this process step is measured for costing / pricing. '
|
||||
'Picks which physical property of the part to multiply by '
|
||||
'the per-unit rate: weight (Lbs) or surface area (Sq in).',
|
||||
)
|
||||
|
||||
# ---- Process Variants (per-part) ----------------------------------------
|
||||
# A part can carry multiple recipe-root trees ("variants"). Examples:
|
||||
# "Standard ENP", "Selective Masking", "Rework". Each order line picks a
|
||||
# variant; the MO walker resolves through it. One variant per part is the
|
||||
# default - used when the order line doesn't pick one explicitly.
|
||||
#
|
||||
# Variant identification only applies to root nodes (parent_id IS NULL,
|
||||
# node_type='recipe') with a part_catalog_id set. Non-root nodes carry
|
||||
# these fields too because they sit on the same model, but they're only
|
||||
# meaningful on roots.
|
||||
is_default_variant = fields.Boolean(
|
||||
string='Default Variant',
|
||||
help='When ticked, this variant is used by default for new orders '
|
||||
'of this part. Exactly one variant per part is the default.',
|
||||
)
|
||||
variant_label = fields.Char(
|
||||
string='Variant Label',
|
||||
help='Friendly label shown in the variant picker '
|
||||
'(e.g. "Standard ENP", "Selective Masking", "Rework").',
|
||||
)
|
||||
|
||||
# ---- Linked Parts (cloned recipes) --------------------------------------
|
||||
# On a shared template recipe, count + open all part-cloned recipe
|
||||
# roots that were copied from this template (cloned_from_id == self).
|
||||
# Only meaningful on shared templates (part_catalog_id IS NULL,
|
||||
# node_type='recipe').
|
||||
cloned_recipe_count = fields.Integer(
|
||||
string='Linked Part Recipes',
|
||||
compute='_compute_cloned_recipe_count',
|
||||
)
|
||||
|
||||
def _compute_cloned_recipe_count(self):
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
groups = Node._read_group(
|
||||
domain=[
|
||||
('cloned_from_id', 'in', self.ids),
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
],
|
||||
groupby=['cloned_from_id'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
counts = {src.id: count for src, count in groups}
|
||||
for rec in self:
|
||||
rec.cloned_recipe_count = counts.get(rec.id, 0)
|
||||
|
||||
def action_open_cloned_recipes(self):
|
||||
"""Open the list of part-cloned recipe roots that came from this
|
||||
template (i.e. cloned_from_id == self)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Linked Parts - %s', self.name),
|
||||
'res_model': 'fusion.plating.process.node',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('cloned_from_id', '=', self.id),
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
],
|
||||
'context': {
|
||||
'search_default_group_part': 1,
|
||||
},
|
||||
}
|
||||
@@ -1,988 +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.
|
||||
|
||||
import math
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpQuoteConfigurator(models.Model):
|
||||
"""Persistent configurator session.
|
||||
|
||||
Collects part geometry, coating config, and pricing inputs.
|
||||
Calculates a price from matching pricing rules. The estimator
|
||||
can override the calculated price. Creates a sale.order when confirmed.
|
||||
"""
|
||||
_name = 'fp.quote.configurator'
|
||||
_description = 'Fusion Plating - Quote Configurator'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('confirmed', 'Won (SO Created)'),
|
||||
('lost', 'Lost'),
|
||||
('expired', 'Expired'),
|
||||
('cancelled', 'Cancelled')],
|
||||
string='Status', default='draft', tracking=True,
|
||||
)
|
||||
|
||||
# ---- Win/Loss tracking (T3.2) ----
|
||||
lost_reason = fields.Selection(
|
||||
[('price', 'Price'),
|
||||
('lead_time', 'Lead Time'),
|
||||
('tech_capability', 'Technical Capability'),
|
||||
('spec_mismatch', 'Spec / Certification Mismatch'),
|
||||
('no_bid', 'No-Bid'),
|
||||
('no_response', 'Customer No-Response'),
|
||||
('competitor', 'Lost to Competitor'),
|
||||
('other', 'Other')],
|
||||
string='Lost Reason', tracking=True,
|
||||
)
|
||||
lost_competitor_name = fields.Char(string='Competitor', tracking=True)
|
||||
lost_details = fields.Text(string='Loss Notes')
|
||||
won_date = fields.Date(string='Won Date', readonly=True)
|
||||
lost_date = fields.Date(string='Lost Date', readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True,
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
|
||||
)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part (Catalog)',
|
||||
domain="[('partner_id', '=', partner_id)]",
|
||||
help="Select from this customer's part catalog, or leave blank for a one-off.",
|
||||
)
|
||||
model_attachment_id = fields.Many2one(
|
||||
related='part_catalog_id.model_attachment_id',
|
||||
string='3D Model',
|
||||
readonly=True,
|
||||
)
|
||||
drawing_attachment_ids = fields.Many2many(
|
||||
related='part_catalog_id.drawing_attachment_ids',
|
||||
string='Drawings',
|
||||
readonly=True,
|
||||
)
|
||||
# -- Physical part properties (intrinsic, related from part catalog) --
|
||||
bbox_summary_in = fields.Char(
|
||||
related='part_catalog_id.bbox_summary_in', string='Dimensions (in)',
|
||||
readonly=True,
|
||||
)
|
||||
volume_mm3 = fields.Float(
|
||||
related='part_catalog_id.volume_mm3', string='Volume (mm³)',
|
||||
readonly=True,
|
||||
)
|
||||
hole_count = fields.Integer(
|
||||
related='part_catalog_id.hole_count', string='Holes',
|
||||
readonly=True,
|
||||
)
|
||||
hole_summary = fields.Char(
|
||||
related='part_catalog_id.hole_summary', string='Hole Summary',
|
||||
readonly=True,
|
||||
)
|
||||
is_manifold = fields.Boolean(
|
||||
related='part_catalog_id.is_manifold', string='Watertight',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# -- Quote-editable fields that drive weight / effective area --
|
||||
# These are independent from part catalog (working copy for this quote)
|
||||
masking_area_sqin = fields.Float(
|
||||
string='Masking Area (sq in)',
|
||||
digits=(12, 4),
|
||||
help='Surface area excluded from plating (masked surfaces).',
|
||||
)
|
||||
# Computed using CONFIGURATOR's substrate + part catalog's volume
|
||||
# so changing substrate on the quote updates the weight live.
|
||||
material_weight_kg = fields.Float(
|
||||
string='Weight (kg)',
|
||||
digits=(12, 4),
|
||||
compute='_compute_material_weight_kg',
|
||||
store=False,
|
||||
help='Computed from part volume × this quote\'s substrate density. '
|
||||
'Changing substrate on the quote updates weight immediately.',
|
||||
)
|
||||
# Computed using CONFIGURATOR's surface_area and masking_area
|
||||
effective_area_sqin = fields.Float(
|
||||
string='Effective Plating Area (sq in)',
|
||||
digits=(12, 4),
|
||||
compute='_compute_effective_area_sqin',
|
||||
store=False,
|
||||
help='Surface area minus masked area, using the values on this quote.',
|
||||
)
|
||||
|
||||
@api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
|
||||
def _compute_material_weight_kg(self):
|
||||
"""Compute weight from part volume × THIS QUOTE'S substrate density.
|
||||
|
||||
Prefer the per-material density override; fall back to the
|
||||
category default when only the legacy Selection is set.
|
||||
"""
|
||||
density_map = {
|
||||
'aluminium': 2.70,
|
||||
'steel': 7.85,
|
||||
'stainless': 8.00,
|
||||
'copper': 8.96,
|
||||
'titanium': 4.51,
|
||||
'other': 7.85,
|
||||
}
|
||||
for rec in self:
|
||||
if not rec.volume_mm3:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
if rec.material_id:
|
||||
density = rec.material_id.effective_density()
|
||||
elif rec.substrate_material:
|
||||
density = density_map.get(rec.substrate_material, 7.85)
|
||||
else:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||
|
||||
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
||||
def _compute_effective_area_sqin(self):
|
||||
"""Surface area minus masking area, using THIS QUOTE'S values."""
|
||||
for rec in self:
|
||||
uom = rec.surface_area_uom or 'sq_in'
|
||||
if uom == 'sq_in':
|
||||
area_sqin = rec.surface_area or 0.0
|
||||
elif uom == 'sq_ft':
|
||||
area_sqin = (rec.surface_area or 0.0) * 144.0
|
||||
elif uom == 'sq_cm':
|
||||
area_sqin = (rec.surface_area or 0.0) / 6.4516
|
||||
elif uom == 'sq_m':
|
||||
area_sqin = (rec.surface_area or 0.0) * 1550.0
|
||||
else:
|
||||
area_sqin = rec.surface_area or 0.0
|
||||
rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0))
|
||||
drawing_count = fields.Integer(
|
||||
string='Drawings',
|
||||
compute='_compute_drawing_count',
|
||||
)
|
||||
first_drawing_id = fields.Many2one(
|
||||
'ir.attachment', string='First Drawing',
|
||||
compute='_compute_first_drawing',
|
||||
inverse='_inverse_first_drawing',
|
||||
)
|
||||
|
||||
@api.depends('part_catalog_id.drawing_attachment_ids')
|
||||
def _compute_drawing_count(self):
|
||||
for rec in self:
|
||||
rec.drawing_count = len(rec.part_catalog_id.drawing_attachment_ids) if rec.part_catalog_id else 0
|
||||
|
||||
@api.depends('part_catalog_id.drawing_attachment_ids')
|
||||
def _compute_first_drawing(self):
|
||||
for rec in self:
|
||||
atts = rec.part_catalog_id.drawing_attachment_ids if rec.part_catalog_id else False
|
||||
rec.first_drawing_id = atts[0] if atts else False
|
||||
|
||||
def _inverse_first_drawing(self):
|
||||
"""When user clears or replaces the first drawing in the configurator,
|
||||
propagate that change to the part catalog's drawing list."""
|
||||
for rec in self:
|
||||
if not rec.part_catalog_id:
|
||||
continue
|
||||
atts = rec.part_catalog_id.drawing_attachment_ids
|
||||
current_first = atts[0] if atts else False
|
||||
new_first = rec.first_drawing_id
|
||||
# Cleared
|
||||
if current_first and not new_first:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(3, current_first.id)],
|
||||
})
|
||||
# Replaced
|
||||
elif new_first and current_first and new_first.id != current_first.id:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [
|
||||
(3, current_first.id), (4, new_first.id),
|
||||
],
|
||||
})
|
||||
# Added (no current first, new value set)
|
||||
elif new_first and not current_first:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, new_first.id)],
|
||||
})
|
||||
|
||||
# -- Quick file upload (creates/updates part catalog automatically) --
|
||||
upload_3d_file = fields.Binary(
|
||||
string='Upload 3D File',
|
||||
attachment=False,
|
||||
help='Upload a STEP, IGES, or STL file. Auto-creates or updates the part catalog entry.',
|
||||
)
|
||||
upload_3d_filename = fields.Char(string='3D Filename')
|
||||
upload_drawing = fields.Binary(
|
||||
string='Upload Drawing',
|
||||
attachment=False,
|
||||
help='Upload a PDF drawing. Attaches to the part catalog entry.',
|
||||
)
|
||||
upload_drawing_filename = fields.Char(string='Drawing Filename')
|
||||
|
||||
# -- RFQ / PO document tracking (from the beginning of the quote) --
|
||||
rfq_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='RFQ Document', copy=False, tracking=True,
|
||||
help="Customer's original Request for Quote document (PDF). "
|
||||
"Transferred to the sale order on quotation.",
|
||||
)
|
||||
po_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Customer PO', copy=False, tracking=True,
|
||||
help='Customer PO document if already received. '
|
||||
'Transferred to the sale order on quotation.',
|
||||
)
|
||||
po_number_preliminary = fields.Char(
|
||||
string='PO Number', copy=False, tracking=True,
|
||||
help='Customer PO number if already known. '
|
||||
'Transferred to the sale order on quotation.',
|
||||
)
|
||||
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
|
||||
upload_rfq_filename = fields.Char(string='RFQ Filename')
|
||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||
upload_po_filename = fields.Char(string='PO Filename')
|
||||
|
||||
# Renamed from coating_config_id (Phase E - Promote Customer Spec).
|
||||
# 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)
|
||||
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
|
||||
|
||||
# ----- Geometry (auto-filled from catalog or entered manually) ----------
|
||||
surface_area = fields.Float(string='Surface Area', digits=(12, 4))
|
||||
surface_area_uom = fields.Selection(
|
||||
[('sq_in', 'sq in'), ('sq_ft', 'sq ft'), ('sq_cm', 'sq cm'), ('sq_m', 'sq m')],
|
||||
string='Area UoM', default='sq_in',
|
||||
)
|
||||
thickness_requested = fields.Float(string='Requested Thickness', digits=(10, 4))
|
||||
masking_zones = fields.Integer(string='Masking Zones')
|
||||
complexity = fields.Selection(
|
||||
[('simple', 'Simple'), ('moderate', 'Moderate'),
|
||||
('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||
string='Complexity', default='simple',
|
||||
)
|
||||
# Single source of truth: pick a material from the shared library.
|
||||
# `substrate_material` below is now a stored compute mirroring
|
||||
# `material_id.category` so legacy consumers (pricing rules, portal,
|
||||
# data exports) keep working unchanged.
|
||||
material_id = fields.Many2one(
|
||||
'fp.part.material', string='Material',
|
||||
ondelete='restrict',
|
||||
help='Picks from the shared material library - same picker as '
|
||||
'the Part Catalog. Create custom alloys (e.g. "Aluminium '
|
||||
'6061") on the fly.',
|
||||
)
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Material Category',
|
||||
compute='_compute_substrate_material',
|
||||
store=True, readonly=False, default='steel',
|
||||
help='Auto-derived from the selected material. Drives pricing '
|
||||
'rule matching and density defaults.',
|
||||
)
|
||||
|
||||
@api.depends('material_id', 'material_id.category')
|
||||
def _compute_substrate_material(self):
|
||||
for rec in self:
|
||||
if rec.material_id:
|
||||
rec.substrate_material = rec.material_id.category
|
||||
elif not rec.substrate_material:
|
||||
rec.substrate_material = 'steel'
|
||||
|
||||
# ----- Options ----------------------------------------------------------
|
||||
rush_order = fields.Boolean(string='Rush Order')
|
||||
turnaround_days = fields.Integer(string='Turnaround (days)')
|
||||
delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'),
|
||||
('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
string='Delivery Method', default='shipping_partner',
|
||||
)
|
||||
|
||||
# ----- Pricing ----------------------------------------------------------
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
required=True, default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
shipping_fee = fields.Monetary(string='Shipping Fee', currency_field='currency_id')
|
||||
delivery_fee = fields.Monetary(string='Delivery Fee', currency_field='currency_id')
|
||||
calculated_price = fields.Monetary(
|
||||
string='Calculated Price', currency_field='currency_id',
|
||||
compute='_compute_price', store=True,
|
||||
)
|
||||
price_breakdown_html = fields.Html(
|
||||
string='Price Breakdown', compute='_compute_price', store=True,
|
||||
)
|
||||
estimator_override_price = fields.Monetary(
|
||||
string='Final Price', currency_field='currency_id',
|
||||
help='Estimator can override the calculated price.',
|
||||
)
|
||||
|
||||
# ----- SO link ----------------------------------------------------------
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True, copy=False)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Auto-population from catalog
|
||||
# -------------------------------------------------------------------------
|
||||
@api.onchange('part_catalog_id')
|
||||
def _onchange_part_catalog_id(self):
|
||||
if self.part_catalog_id:
|
||||
cat = self.part_catalog_id
|
||||
self.surface_area = cat.surface_area
|
||||
self.surface_area_uom = cat.surface_area_uom
|
||||
self.complexity = cat.complexity
|
||||
self.masking_zones = cat.masking_zones
|
||||
# Pull the m2o material from the part - substrate_material
|
||||
# auto-derives via the compute. Fall back to the legacy
|
||||
# Selection only if the part has no material_id yet.
|
||||
if cat.material_id:
|
||||
self.material_id = cat.material_id
|
||||
else:
|
||||
self.substrate_material = cat.substrate_material
|
||||
# Copy masking area too (for effective-area calculation)
|
||||
self.masking_area_sqin = cat.masking_area_sqin
|
||||
|
||||
@api.onchange('recipe_id')
|
||||
def _onchange_recipe_id(self):
|
||||
if self.recipe_id and self.recipe_id.thickness_min:
|
||||
self.thickness_requested = self.recipe_id.thickness_min
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Price calculation
|
||||
# -------------------------------------------------------------------------
|
||||
@api.depends(
|
||||
'surface_area', 'surface_area_uom', 'thickness_requested',
|
||||
'masking_zones', 'complexity', 'substrate_material',
|
||||
'quantity', 'batch_size', 'rush_order',
|
||||
'shipping_fee', 'delivery_fee',
|
||||
'recipe_id',
|
||||
)
|
||||
def _compute_price(self):
|
||||
for rec in self:
|
||||
if not rec.recipe_id or not rec.surface_area:
|
||||
rec.calculated_price = 0
|
||||
rec.price_breakdown_html = ''
|
||||
continue
|
||||
|
||||
rule = rec._find_matching_rule()
|
||||
if not rule:
|
||||
rec.calculated_price = 0
|
||||
rec.price_breakdown_html = '<p class="text-muted">No matching pricing rule found.</p>'
|
||||
continue
|
||||
|
||||
# --- Base calculation ---
|
||||
area = rec._normalize_surface_area_to_sqin()
|
||||
if rule.pricing_method == 'per_sqin':
|
||||
unit_price = area * rule.base_rate
|
||||
elif rule.pricing_method == 'per_sqft':
|
||||
unit_price = (area / 144.0) * rule.base_rate
|
||||
elif rule.pricing_method == 'per_piece':
|
||||
unit_price = rule.base_rate
|
||||
else: # flat_rate
|
||||
unit_price = rule.base_rate
|
||||
|
||||
# --- Thickness scaling ---
|
||||
# thickness_factor is a per-mil multiplier. A factor of 1.0
|
||||
# means linear scaling by thickness (e.g. 3 mils = 3x price).
|
||||
# A factor of 0.8 gives a volume discount (3 mils = 2.4x).
|
||||
thickness = rec.thickness_requested or 1.0
|
||||
unit_price *= thickness * rule.thickness_factor
|
||||
|
||||
# --- Complexity surcharge ---
|
||||
surcharge_pct = 0
|
||||
for line in rule.complexity_surcharge_ids:
|
||||
if line.complexity == rec.complexity:
|
||||
surcharge_pct = line.surcharge_percent
|
||||
break
|
||||
unit_price *= (1 + surcharge_pct / 100.0)
|
||||
|
||||
# --- Masking ---
|
||||
masking_cost = (rec.masking_zones or 0) * rule.masking_rate_per_zone
|
||||
|
||||
# --- Quantity + batch setup fees ---
|
||||
num_batches = (
|
||||
math.ceil(rec.quantity / rec.batch_size) if rec.batch_size
|
||||
else 1
|
||||
)
|
||||
total_setup = rule.setup_fee * num_batches
|
||||
subtotal = (unit_price * rec.quantity) + masking_cost + total_setup
|
||||
|
||||
# --- Rush surcharge ---
|
||||
rush_amount = 0
|
||||
if rec.rush_order and rule.rush_surcharge_percent:
|
||||
rush_amount = subtotal * (rule.rush_surcharge_percent / 100.0)
|
||||
subtotal += rush_amount
|
||||
|
||||
# --- Minimum charge ---
|
||||
if subtotal < rule.minimum_charge:
|
||||
subtotal = rule.minimum_charge
|
||||
|
||||
# --- Delivery/shipping fees ---
|
||||
total = subtotal + (rec.shipping_fee or 0) + (rec.delivery_fee or 0)
|
||||
|
||||
rec.calculated_price = total
|
||||
|
||||
# --- Build breakdown HTML ---
|
||||
sym = rec.currency_id.symbol or '$'
|
||||
lines = []
|
||||
method_label = dict(
|
||||
rule._fields['pricing_method'].selection
|
||||
).get(rule.pricing_method, '')
|
||||
lines.append(
|
||||
'<tr><td>Base (%s)</td><td class="text-end">%s%.2f x %d</td></tr>'
|
||||
% (method_label, sym, unit_price, rec.quantity)
|
||||
)
|
||||
if masking_cost:
|
||||
lines.append(
|
||||
'<tr><td>Masking (%d zones)</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (rec.masking_zones, sym, masking_cost)
|
||||
)
|
||||
if total_setup:
|
||||
lines.append(
|
||||
'<tr><td>Setup Fee (x%d batches)</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (num_batches, sym, total_setup)
|
||||
)
|
||||
if rush_amount:
|
||||
lines.append(
|
||||
'<tr><td>Rush Surcharge (%.0f%%)</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (rule.rush_surcharge_percent, sym, rush_amount)
|
||||
)
|
||||
if rec.shipping_fee:
|
||||
lines.append(
|
||||
'<tr><td>Shipping</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (sym, rec.shipping_fee)
|
||||
)
|
||||
if rec.delivery_fee:
|
||||
lines.append(
|
||||
'<tr><td>Delivery</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (sym, rec.delivery_fee)
|
||||
)
|
||||
lines.append(
|
||||
'<tr class="fw-bold"><td>Total</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (sym, total)
|
||||
)
|
||||
|
||||
rec.price_breakdown_html = (
|
||||
'<table class="table table-sm"><thead><tr>'
|
||||
'<th>Item</th><th class="text-end">Amount</th></tr></thead>'
|
||||
'<tbody>%s</tbody></table>'
|
||||
'<p class="text-muted small">Rule: %s (seq %d)</p>'
|
||||
% (''.join(lines), rule.name, rule.sequence)
|
||||
)
|
||||
|
||||
def _find_matching_rule(self):
|
||||
"""Find the best pricing rule matching this configurator's filters.
|
||||
|
||||
Scores rules by specificity - most specific match wins.
|
||||
If no rule matches filters, returns None.
|
||||
|
||||
When the chosen recipe has `pricing_rule_ids` configured, the
|
||||
search is constrained to those rules ("Use Price Builders"
|
||||
semantics). Otherwise the whole active rule set is considered.
|
||||
|
||||
Spec-tier scoring is added by an inherit in
|
||||
fusion_plating_quality (where customer.spec lives).
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
def _normalize_surface_area_to_sqin(self):
|
||||
"""Convert surface area to square inches for calculation."""
|
||||
area = self.surface_area or 0
|
||||
uom = self.surface_area_uom
|
||||
if uom == 'sq_ft':
|
||||
return area * 144.0
|
||||
elif uom == 'sq_cm':
|
||||
return area * 0.155
|
||||
elif uom == 'sq_m':
|
||||
return area * 1550.0
|
||||
return area # sq_in
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Actions
|
||||
# -------------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fp.quote.configurator') or 'New'
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_promote_to_direct_order(self):
|
||||
"""Sub 10 - push this quote onto a Direct Order draft.
|
||||
|
||||
Replaces the legacy 1-line-SO creation. The estimator picks an
|
||||
existing draft for the customer (consolidating multiple quotes
|
||||
onto one PO) or spawns a fresh draft. The quote stays in
|
||||
`draft` state until the Direct Order is confirmed; that confirm
|
||||
flips the quote to `won` and back-links the SO.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError(_('Only draft quotes can be promoted.'))
|
||||
if self.sale_order_id:
|
||||
raise UserError(_(
|
||||
'A sale order has already been created for this quote.'
|
||||
))
|
||||
if not self.part_catalog_id:
|
||||
raise UserError(_(
|
||||
'Pick a part catalog entry before promoting this quote.'
|
||||
))
|
||||
if not self.recipe_id:
|
||||
raise UserError(_(
|
||||
'Pick a recipe before promoting this quote.'
|
||||
))
|
||||
existing_line = self.env['fp.direct.order.line'].search([
|
||||
('quote_id', '=', self.id),
|
||||
('wizard_id.state', '=', 'draft'),
|
||||
], limit=1)
|
||||
if existing_line:
|
||||
raise UserError(_(
|
||||
'This quote is already on draft "%s". Open that draft '
|
||||
'and remove its line if you want to move it elsewhere.'
|
||||
) % existing_line.wizard_id.name)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Add Quote to Direct Order'),
|
||||
'res_model': 'fp.quote.promote.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_quote_id': self.id},
|
||||
}
|
||||
|
||||
def action_create_quotation(self):
|
||||
"""LEGACY (Sub 10): kept for backwards-compat with any in-flight
|
||||
records or external triggers. New flow is via
|
||||
action_promote_to_direct_order.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError(_('Only draft configurators can create quotations.'))
|
||||
if self.sale_order_id:
|
||||
raise UserError(_('A quotation has already been created for this configurator.'))
|
||||
|
||||
price = self.estimator_override_price or self.calculated_price
|
||||
|
||||
# Find or create a generic service product for plating
|
||||
product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'FP-SERVICE')], limit=1
|
||||
)
|
||||
if not product:
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Plating Service',
|
||||
'default_code': 'FP-SERVICE',
|
||||
'type': 'service',
|
||||
'list_price': 0,
|
||||
'sale_ok': True,
|
||||
'purchase_ok': False,
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
so_vals = {
|
||||
'partner_id': self.partner_id.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_rush_order': self.rush_order,
|
||||
'x_fc_delivery_method': self.delivery_method,
|
||||
# Transfer RFQ / PO documents from configurator (if any)
|
||||
'x_fc_rfq_attachment_id': self.rfq_attachment_id.id if self.rfq_attachment_id else False,
|
||||
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
|
||||
'x_fc_po_number': self.po_number_preliminary or False,
|
||||
'x_fc_po_received': bool(self.po_attachment_id),
|
||||
# Mirror the PO# into Odoo's standard client_order_ref so
|
||||
# the customer portal, every standard report, and every
|
||||
# third-party integration can read the PO without knowing
|
||||
# about our custom field.
|
||||
'client_order_ref': self.po_number_preliminary or False,
|
||||
'origin': self.name,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': '%s - %s (x%d)' % (recipe_name, part_name, self.quantity),
|
||||
'product_uom_qty': self.quantity,
|
||||
'price_unit': price / self.quantity if self.quantity else price,
|
||||
# Propagate part + recipe to the LINE.
|
||||
# fusion_plating_jobs._fp_auto_create_job filters lines
|
||||
# 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': (
|
||||
self.part_catalog_id.id if self.part_catalog_id else False
|
||||
),
|
||||
'x_fc_process_variant_id': (
|
||||
self.recipe_id.id if self.recipe_id else False
|
||||
),
|
||||
})],
|
||||
}
|
||||
so = self.env['sale.order'].create(so_vals)
|
||||
self.write({
|
||||
'sale_order_id': so.id,
|
||||
'state': 'confirmed',
|
||||
'won_date': fields.Date.today(),
|
||||
})
|
||||
self.message_post(
|
||||
body=Markup(_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.')) % (so.id, so.name),
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': so.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.onchange('upload_3d_file')
|
||||
def _onchange_upload_3d_file(self):
|
||||
"""When a 3D file is uploaded, auto-create/update part catalog entry."""
|
||||
if not self.upload_3d_file or not self.partner_id:
|
||||
return
|
||||
import base64
|
||||
import os
|
||||
|
||||
fname = self.upload_3d_filename or 'model.step'
|
||||
raw = base64.b64decode(self.upload_3d_file)
|
||||
|
||||
# Create attachment
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_3d_file,
|
||||
'mimetype': 'application/octet-stream',
|
||||
})
|
||||
|
||||
# Auto-create or update part catalog with revision tracking
|
||||
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
|
||||
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
|
||||
# Part already has a 3D model - create a new revision
|
||||
old_part = self.part_catalog_id
|
||||
old_part.is_latest_revision = False
|
||||
root = old_part.parent_part_id or old_part
|
||||
from .fp_part_catalog import _bump_revision_label
|
||||
new_label = _bump_revision_label(old_part.revision)
|
||||
new_part = old_part.copy({
|
||||
'revision': new_label,
|
||||
'revision_date': fields.Datetime.now(),
|
||||
'revision_note': f'Updated 3D model: {fname}',
|
||||
'parent_part_id': root.id,
|
||||
'is_latest_revision': True,
|
||||
'model_attachment_id': att.id,
|
||||
})
|
||||
self.part_catalog_id = new_part.id
|
||||
new_part._compute_surface_area_from_model()
|
||||
self.surface_area = new_part.surface_area
|
||||
self.surface_area_uom = new_part.surface_area_uom
|
||||
elif self.part_catalog_id:
|
||||
# Part exists but no 3D model yet - just attach
|
||||
self.part_catalog_id.model_attachment_id = att.id
|
||||
self.part_catalog_id._compute_surface_area_from_model()
|
||||
self.surface_area = self.part_catalog_id.surface_area
|
||||
self.surface_area_uom = self.part_catalog_id.surface_area_uom
|
||||
else:
|
||||
# No part catalog - create new entry
|
||||
part = self.env['fp.part.catalog'].create({
|
||||
'name': part_name,
|
||||
'partner_id': self.partner_id.id,
|
||||
'part_number': fname,
|
||||
'model_attachment_id': att.id,
|
||||
})
|
||||
self.part_catalog_id = part.id
|
||||
part._compute_surface_area_from_model()
|
||||
self.surface_area = part.surface_area
|
||||
self.surface_area_uom = part.surface_area_uom
|
||||
|
||||
# Post to chatter so user sees confirmation (only if record is saved)
|
||||
if self.id and not isinstance(self.id, models.NewId):
|
||||
self.sudo().message_post(
|
||||
body=Markup(_('3D model attached: <b>%s</b> - surface area: %.4f %s')) % (
|
||||
fname, self.surface_area, self.surface_area_uom or ''),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Clear the upload field (data is now on the part catalog)
|
||||
self.upload_3d_file = False
|
||||
self.upload_3d_filename = False
|
||||
|
||||
@api.onchange('upload_drawing')
|
||||
def _onchange_upload_drawing(self):
|
||||
"""When a drawing is uploaded, attach to part catalog entry."""
|
||||
if not self.upload_drawing or not self.partner_id:
|
||||
return
|
||||
|
||||
fname = self.upload_drawing_filename or 'drawing.pdf'
|
||||
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_drawing,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
|
||||
if self.part_catalog_id:
|
||||
self.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
part = self.part_catalog_id
|
||||
else:
|
||||
import os
|
||||
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
|
||||
part = self.env['fp.part.catalog'].create({
|
||||
'name': part_name,
|
||||
'partner_id': self.partner_id.id,
|
||||
'part_number': fname,
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
self.part_catalog_id = part.id
|
||||
|
||||
# Post to chatter so user sees confirmation (only if record is saved)
|
||||
if self.id and not isinstance(self.id, models.NewId):
|
||||
self.sudo().message_post(
|
||||
body=Markup(_('Drawing attached: <b>%s</b> (linked to part %s)')) % (
|
||||
fname, part.name),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
self.upload_drawing = False
|
||||
self.upload_drawing_filename = False
|
||||
|
||||
@api.onchange('upload_rfq_file')
|
||||
def _onchange_upload_rfq_file(self):
|
||||
"""When an RFQ file is uploaded, create attachment + link it."""
|
||||
if not self.upload_rfq_file:
|
||||
return
|
||||
fname = self.upload_rfq_filename or 'rfq.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_rfq_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.rfq_attachment_id = att.id
|
||||
self.upload_rfq_file = False
|
||||
self.upload_rfq_filename = False
|
||||
|
||||
@api.onchange('upload_po_file')
|
||||
def _onchange_upload_po_file(self):
|
||||
"""When a PO file is uploaded, create attachment + link it."""
|
||||
if not self.upload_po_file:
|
||||
return
|
||||
fname = self.upload_po_filename or 'po.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_po_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.po_attachment_id = att.id
|
||||
self.upload_po_file = False
|
||||
self.upload_po_filename = False
|
||||
|
||||
def action_view_rfq(self):
|
||||
self.ensure_one()
|
||||
if not self.rfq_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.rfq_attachment_id.id,
|
||||
'title': _('RFQ - %s') % (self.rfq_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_po(self):
|
||||
self.ensure_one()
|
||||
if not self.po_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.po_attachment_id.id,
|
||||
'title': _('PO - %s') % (self.po_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_recalculate_price(self):
|
||||
"""Recalculate surface area from 3D model and recompute price."""
|
||||
self.ensure_one()
|
||||
msg = _('No 3D model to calculate from.')
|
||||
# Recalculate surface area from part catalog's 3D model
|
||||
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
|
||||
result = self.part_catalog_id._compute_surface_area_from_model()
|
||||
if not result.get('error'):
|
||||
self.surface_area = self.part_catalog_id.surface_area
|
||||
self.surface_area_uom = self.part_catalog_id.surface_area_uom
|
||||
msg = _('Surface area: %.4f %s | Price: %.2f %s') % (
|
||||
self.surface_area, self.surface_area_uom or '',
|
||||
self.calculated_price, self.currency_id.symbol or '$',
|
||||
)
|
||||
else:
|
||||
msg = result['error']
|
||||
# Post result to chatter so user sees it after form reload
|
||||
self.message_post(
|
||||
body=msg,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return False
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
|
||||
def action_reset_draft(self):
|
||||
self.write({'state': 'draft', 'won_date': False, 'lost_date': False})
|
||||
|
||||
def action_mark_lost(self):
|
||||
"""Move this quote to 'lost' state. Caller should populate
|
||||
`lost_reason` first - a simple validation enforces that."""
|
||||
for rec in self:
|
||||
if not rec.lost_reason:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Please set a Lost Reason before marking this quote lost.'
|
||||
))
|
||||
rec.write({
|
||||
'state': 'lost',
|
||||
'lost_date': fields.Date.today(),
|
||||
})
|
||||
rec.message_post(
|
||||
body=_('Quote marked lost - reason: %s') % dict(
|
||||
rec._fields['lost_reason'].selection
|
||||
).get(rec.lost_reason, rec.lost_reason),
|
||||
)
|
||||
|
||||
def action_mark_expired(self):
|
||||
for rec in self:
|
||||
rec.write({'state': 'expired', 'lost_date': fields.Date.today()})
|
||||
|
||||
def action_open_3d_fullscreen(self):
|
||||
"""Open the 3D model viewer in a full-screen dialog (same window)."""
|
||||
self.ensure_one()
|
||||
att = self.model_attachment_id
|
||||
if not att:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_3d_viewer_open',
|
||||
'params': {
|
||||
'attachment_id': att.id,
|
||||
'name': att.name or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_sale_order(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_part_catalog(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_catalog_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_save_to_catalog(self):
|
||||
"""Push this quote's geometry/material edits back to the master part catalog.
|
||||
|
||||
Writes: material_id (preferred) / substrate_material (fallback),
|
||||
surface_area, surface_area_uom, masking_area_sqin,
|
||||
masking_zones, complexity.
|
||||
Only available when a part catalog entry is linked.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.part_catalog_id:
|
||||
raise UserError(_('No part catalog entry linked to this configurator.'))
|
||||
vals = {
|
||||
'surface_area': self.surface_area,
|
||||
'surface_area_uom': self.surface_area_uom,
|
||||
'masking_area_sqin': self.masking_area_sqin,
|
||||
'masking_zones': self.masking_zones,
|
||||
'complexity': self.complexity,
|
||||
}
|
||||
if self.material_id:
|
||||
vals['material_id'] = self.material_id.id
|
||||
else:
|
||||
vals['substrate_material'] = self.substrate_material
|
||||
self.part_catalog_id.write(vals)
|
||||
self.message_post(
|
||||
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Saved to Catalog'),
|
||||
'message': _('Part catalog updated with quote geometry and substrate.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_drawings(self):
|
||||
"""Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior)."""
|
||||
self.ensure_one()
|
||||
if self.first_drawing_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.first_drawing_id.id,
|
||||
'title': _('Drawing - %s') % (self.first_drawing_id.name or ''),
|
||||
},
|
||||
}
|
||||
# No drawing: fall back to part catalog
|
||||
if not self.part_catalog_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Drawings - %s') % self.part_catalog_id.name,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_catalog_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -1,69 +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 FpSaleAssembly(models.Model):
|
||||
"""Hierarchical kit / assembly on a sale order line.
|
||||
|
||||
A sale.order.line can carry child parts that make up an assembly.
|
||||
Useful when the customer sends a kit (e.g. housing + cover + two
|
||||
bolts) and each sub-part needs its own receive count + processing
|
||||
status but they all bill as one kit.
|
||||
|
||||
Phase D11 shipped minimal: just the data model. Full UX (hierarchy
|
||||
kanban, procurement tracking) is a follow-on.
|
||||
"""
|
||||
_name = 'fp.sale.assembly'
|
||||
_description = 'Fusion Plating - Sales Order Assembly'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Assembly Name', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Parent SO Line',
|
||||
required=True, ondelete='cascade',
|
||||
)
|
||||
order_id = fields.Many2one(
|
||||
'sale.order', related='sale_order_line_id.order_id',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='order_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.sale.assembly.line', 'assembly_id',
|
||||
string='Assembly Lines',
|
||||
)
|
||||
ship_to = fields.Char(string='Ship To')
|
||||
count = fields.Integer(string='Count', default=1)
|
||||
procured_count = fields.Integer(
|
||||
string='Procured Count',
|
||||
compute='_compute_procured_count',
|
||||
)
|
||||
completed_at = fields.Datetime(string='Completed At')
|
||||
|
||||
@api.depends('line_ids.procured_qty')
|
||||
def _compute_procured_count(self):
|
||||
for rec in self:
|
||||
rec.procured_count = sum(rec.line_ids.mapped('procured_qty'))
|
||||
|
||||
|
||||
class FpSaleAssemblyLine(models.Model):
|
||||
_name = 'fp.sale.assembly.line'
|
||||
_description = 'Fusion Plating - Assembly Line'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Part Number', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
assembly_id = fields.Many2one(
|
||||
'fp.sale.assembly', required=True, ondelete='cascade',
|
||||
)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
)
|
||||
qty_per_assembly = fields.Float(string='Qty / Assembly', default=1.0)
|
||||
procured_qty = fields.Float(string='Procured Qty', default=0.0)
|
||||
@@ -1,82 +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 FpSaleDescriptionTemplate(models.Model):
|
||||
"""Saved description snippets - most often attached to a specific part.
|
||||
|
||||
Real-world usage: a plating shop keeps 3-5 canned descriptions PER
|
||||
PART because the same customer part runs with different masking,
|
||||
packaging, or spec-callout variations. With 3,500 parts and 5
|
||||
variants each, that's ~17,500 rows - so descriptions are scoped
|
||||
primarily by part, with optional fallback to customer / coating /
|
||||
global.
|
||||
|
||||
When a user creates a new order:
|
||||
1. If a part is picked, show templates for that part first.
|
||||
2. Else show templates for the customer.
|
||||
3. Else show templates for the coating.
|
||||
4. Else show global (generic) templates.
|
||||
"""
|
||||
_name = 'fp.sale.description.template'
|
||||
_description = 'Fusion Plating - Sale Order Line Description Template'
|
||||
_order = 'part_catalog_id, sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Template Name', required=True,
|
||||
help='Short name shown in the picker (e.g. "Standard masking", "With threaded holes masked").',
|
||||
)
|
||||
# Sub 2 - dual descriptions. Replaces the legacy `description` field
|
||||
# (dropped in Phase C / Task 27). Migration Step 3 duplicated the old
|
||||
# value into both columns; Step 6 drops the old column.
|
||||
internal_description = fields.Text(
|
||||
string='Internal Description',
|
||||
required=True,
|
||||
help='What the shop floor sees on the WO / traveler. Never on '
|
||||
'customer documents.',
|
||||
)
|
||||
customer_facing_description = fields.Text(
|
||||
string='Customer-Facing Description',
|
||||
required=True,
|
||||
help='Prints on the SO, invoice, packing slip, and BoL.',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
ondelete='cascade', index=True,
|
||||
help='If set, this description belongs to one specific customer '
|
||||
'part - it only appears in the picker when this part is on '
|
||||
'the order. Leave blank for generic fallback templates.',
|
||||
)
|
||||
# Related fields - surface the part's partner for search & grouping
|
||||
# without writing it twice.
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
related='part_catalog_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
# coating_config_id removed; templates can be customer- or part-
|
||||
# scoped. Spec-scoped templates are a future enhancement.
|
||||
tag = fields.Selection(
|
||||
[('standard', 'Standard'),
|
||||
('masking', 'Masking / Selective'),
|
||||
('rework', 'Rework / Strip'),
|
||||
('aerospace', 'Aerospace'),
|
||||
('nuclear', 'Nuclear'),
|
||||
('packaging', 'Special Packaging'),
|
||||
('other', 'Other')],
|
||||
string='Category', default='standard',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
usage_count = fields.Integer(
|
||||
string='Used', default=0, readonly=True,
|
||||
help='Bumped each time this template is applied on an order line.',
|
||||
)
|
||||
|
||||
def _register_usage(self):
|
||||
"""Called by the wizard when the template is applied."""
|
||||
for rec in self:
|
||||
rec.usage_count = (rec.usage_count or 0) + 1
|
||||
@@ -1,315 +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 FpSerial(models.Model):
|
||||
"""Serial number registry.
|
||||
|
||||
One record per "occurrence of a part on an order line". The same part
|
||||
ordered six months later gets a different serial. The serial is the
|
||||
common thread linking the SO line to the MO, Delivery, and Invoice
|
||||
records it spawns downstream.
|
||||
|
||||
Most serials are customer-supplied (pass-through from the customer's
|
||||
own end-user); a smaller share are shop-generated via the sequence.
|
||||
The registry is optional - SO lines can carry no serial at all.
|
||||
"""
|
||||
_name = 'fp.serial'
|
||||
_description = 'Fusion Plating - Serial Number'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Customer-supplied serial (most common) or shop-generated '
|
||||
'sequence value. Typed-in values are accepted as-is.',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda s: s.env.company,
|
||||
)
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line',
|
||||
string='Source Sale Order Line',
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
sale_order_id = fields.Many2one(
|
||||
related='sale_order_line_id.order_id',
|
||||
store=True,
|
||||
string='Sale Order',
|
||||
)
|
||||
customer_id = fields.Many2one(
|
||||
related='sale_order_line_id.order_id.partner_id',
|
||||
store=True,
|
||||
string='Customer',
|
||||
)
|
||||
part_id = fields.Many2one(
|
||||
related='sale_order_line_id.x_fc_part_catalog_id',
|
||||
store=True,
|
||||
string='Part',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# ==================================================================
|
||||
# Phase 2 (2026-04-28) - per-serial state machine
|
||||
# ==================================================================
|
||||
# Each physical part owns its own state independent of the parent
|
||||
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
|
||||
# serials are independently trackable through the shop. State
|
||||
# auto-promotes from job-step transitions (see fp.job.button_*
|
||||
# overrides in fusion_plating_jobs); operator can also flip a
|
||||
# single serial manually (e.g. mark serial #5 scrapped after a
|
||||
# plating defect).
|
||||
state = fields.Selection(
|
||||
[
|
||||
('received', 'Received'),
|
||||
('racked', 'Racked'),
|
||||
('in_process', 'In Process'),
|
||||
('inspected', 'Inspected'),
|
||||
('packed', 'Packed'),
|
||||
('shipped', 'Shipped'),
|
||||
('returned', 'Returned'),
|
||||
('scrapped', 'Scrapped'),
|
||||
('on_hold', 'On Hold'),
|
||||
],
|
||||
string='Status',
|
||||
default='received',
|
||||
required=True,
|
||||
tracking=True,
|
||||
index=True,
|
||||
help='Per-serial workflow state. Transitions auto-promote from '
|
||||
'parent job step events; supervisors can also flip a single '
|
||||
'serial manually (e.g. scrap one part out of a 30-part rack).',
|
||||
)
|
||||
state_color = fields.Integer(
|
||||
string='Status Color',
|
||||
compute='_compute_state_color',
|
||||
help='Kanban / many2many_tags color index derived from state.',
|
||||
)
|
||||
last_state_change = fields.Datetime(
|
||||
string='Last Status Change',
|
||||
readonly=True,
|
||||
help='Timestamp of the most recent state transition. Auto-stamped '
|
||||
'by every state-changing action.',
|
||||
)
|
||||
scrap_reason = fields.Text(
|
||||
string='Scrap / Return Reason',
|
||||
help='Captured when state transitions to scrapped or returned. '
|
||||
'Surfaces on per-serial CoC entries (Phase 4).',
|
||||
)
|
||||
|
||||
# Reverse from move log - Phase 3 will populate this directly when
|
||||
# operators record per-serial moves on the tablet. Defined here so
|
||||
# views can already render the count column.
|
||||
move_count = fields.Integer(
|
||||
compute='_compute_move_count',
|
||||
string='# Moves',
|
||||
)
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_state_color(self):
|
||||
# Odoo color-index mapping aligned with the standard kanban palette.
|
||||
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
|
||||
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
|
||||
mapping = {
|
||||
'received': 8, # blue - fresh
|
||||
'racked': 7, # sky - staged
|
||||
'in_process': 3, # yellow - running
|
||||
'inspected': 11, # olive - passed QC, ready to ship
|
||||
'packed': 4, # green - boxed
|
||||
'shipped': 4, # green - out the door
|
||||
'returned': 2, # orange - back from customer
|
||||
'scrapped': 1, # red
|
||||
'on_hold': 1, # red - quality issue
|
||||
}
|
||||
for rec in self:
|
||||
rec.state_color = mapping.get(rec.state, 0)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_move_count(self):
|
||||
# Phase 3 will replace this with a real reverse link via
|
||||
# fp.job.step.move.serial_ids (M2M added next phase).
|
||||
# Defined here as 0-stub so views don't break on upgrade.
|
||||
for rec in self:
|
||||
rec.move_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State transitions - log each one to chatter and stamp last_state_change
|
||||
# ------------------------------------------------------------------
|
||||
def _set_state(self, new_state, message=None):
|
||||
"""Internal helper. Validates the source state, flips, stamps,
|
||||
chatters. Raises UserError on illegal transitions."""
|
||||
labels = dict(self._fields['state'].selection)
|
||||
for rec in self:
|
||||
old = rec.state
|
||||
if old == new_state:
|
||||
continue
|
||||
# Terminal states are write-protected (operator must explicitly
|
||||
# un-set via action_reopen if they really need to).
|
||||
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Serial %(name)s is %(old)s - cannot transition to '
|
||||
'%(new)s. Use Reopen if this is a correction.'
|
||||
) % {
|
||||
'name': rec.name,
|
||||
'old': labels.get(old, old),
|
||||
'new': labels.get(new_state, new_state),
|
||||
})
|
||||
rec.state = new_state
|
||||
rec.last_state_change = fields.Datetime.now()
|
||||
body = message or _('Status %(old)s → %(new)s by %(user)s') % {
|
||||
'old': labels.get(old, old),
|
||||
'new': labels.get(new_state, new_state),
|
||||
'user': self.env.user.name,
|
||||
}
|
||||
rec.message_post(body=body)
|
||||
return True
|
||||
|
||||
def action_mark_racked(self):
|
||||
return self._set_state('racked')
|
||||
|
||||
def action_mark_in_process(self):
|
||||
return self._set_state('in_process')
|
||||
|
||||
def action_mark_inspected(self):
|
||||
return self._set_state('inspected')
|
||||
|
||||
def action_mark_packed(self):
|
||||
return self._set_state('packed')
|
||||
|
||||
def action_mark_shipped(self):
|
||||
return self._set_state('shipped')
|
||||
|
||||
def action_mark_returned(self):
|
||||
return self._set_state('returned')
|
||||
|
||||
def action_mark_on_hold(self):
|
||||
return self._set_state('on_hold')
|
||||
|
||||
def action_release_hold(self):
|
||||
"""Lift on_hold and return the serial to in_process. Used when a
|
||||
hold is resolved without scrap (e.g. visual blemish was actually
|
||||
within tolerance after re-inspection)."""
|
||||
return self._set_state('in_process')
|
||||
|
||||
def action_mark_scrapped(self):
|
||||
"""Scrap a single serial. Operator should fill scrap_reason next
|
||||
- view enforces it via a wizard form. Phase 3 hooks this into
|
||||
the move log so the parent job's qty_scrapped auto-increments."""
|
||||
return self._set_state('scrapped')
|
||||
|
||||
def action_reopen(self):
|
||||
"""Manager-only override - un-pin a terminal state when a
|
||||
correction is needed (e.g. wrong serial marked shipped). Audit
|
||||
trail preserved via chatter; never silently rewrites history."""
|
||||
for rec in self:
|
||||
if not self.env.user.has_group('fusion_plating.group_fp_manager'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Only the Plating Manager group can reopen a terminal '
|
||||
'serial state. Contact your shop manager.'
|
||||
))
|
||||
return self._set_state('in_process', message=_(
|
||||
'Serial reopened by %s - terminal state reverted for correction.'
|
||||
) % self.env.user.name)
|
||||
|
||||
# Reverse link to invoice lines - safe here because account.move.line
|
||||
# lives in this same module. Production (mrp) and delivery (logistics)
|
||||
# reverse links are defined in their own modules' fp_serial inherits
|
||||
# to keep module load order consistent.
|
||||
invoice_line_ids = fields.One2many(
|
||||
'account.move.line', 'x_fc_serial_id',
|
||||
string='Invoice Lines',
|
||||
)
|
||||
invoice_ids = fields.Many2many(
|
||||
'account.move',
|
||||
compute='_compute_invoice_ids',
|
||||
string='Invoices',
|
||||
)
|
||||
|
||||
invoice_count = fields.Integer(compute='_compute_counts')
|
||||
# production_count / delivery_count are declared in the inheriting
|
||||
# modules (bridge_mrp / logistics) so the O2Ms exist alongside them.
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_serial_name_company_uniq',
|
||||
'unique(company_id, name)',
|
||||
'Serial number must be unique within the company.'),
|
||||
]
|
||||
|
||||
# ---- Computes ------------------------------------------------------------
|
||||
|
||||
@api.depends('invoice_line_ids.move_id')
|
||||
def _compute_counts(self):
|
||||
# Base compute sets invoice_count only. bridge_mrp + logistics
|
||||
# override this to also populate production_count / delivery_count.
|
||||
for rec in self:
|
||||
rec.invoice_count = len(rec.invoice_line_ids.mapped('move_id'))
|
||||
|
||||
@api.depends('invoice_line_ids.move_id')
|
||||
def _compute_invoice_ids(self):
|
||||
for rec in self:
|
||||
rec.invoice_ids = rec.invoice_line_ids.mapped('move_id')
|
||||
|
||||
# ---- Actions -------------------------------------------------------------
|
||||
|
||||
def action_view_sale_order(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
|
||||
def action_view_productions(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Manufacturing Orders'),
|
||||
'res_model': 'mrp.production',
|
||||
'domain': [('id', 'in', self.production_ids.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_deliveries(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Deliveries'),
|
||||
'res_model': 'fusion.plating.delivery',
|
||||
'domain': [('id', 'in', self.delivery_ids.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Invoices'),
|
||||
'res_model': 'account.move',
|
||||
'domain': [('id', 'in', self.invoice_ids.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_part(self):
|
||||
self.ensure_one()
|
||||
if not self.part_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
@@ -1,58 +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 FpSoJobSort(models.Model):
|
||||
"""A user-defined grouping bucket for sale orders ("Job Sorting").
|
||||
|
||||
Same pattern as `fusion.plating.tank.section` - every shop slices its
|
||||
SO backlog differently (by customer programme, by priority, by
|
||||
fabricator group, by week, etc.). Sections are free-form, renameable,
|
||||
quick-creatable from the M2O dropdown, and let users group the SO
|
||||
list with fold/expand sections.
|
||||
"""
|
||||
_name = 'fp.so.job.sort'
|
||||
_description = 'Fusion Plating - Sale Order Job Sort'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Job Sorting',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
color = fields.Integer(string='Color', default=0)
|
||||
fold = fields.Boolean(
|
||||
string='Folded by Default',
|
||||
help='When set, this section appears collapsed in the grouped '
|
||||
'SO list so the body rows are hidden until expanded.',
|
||||
)
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
sale_order_ids = fields.One2many(
|
||||
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
|
||||
)
|
||||
sale_order_count = fields.Integer(
|
||||
compute='_compute_sale_order_count',
|
||||
)
|
||||
|
||||
@api.depends('sale_order_ids')
|
||||
def _compute_sale_order_count(self):
|
||||
for rec in self:
|
||||
rec.sale_order_count = len(rec.sale_order_ids)
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_job_sort_id', '=', self.id)],
|
||||
'context': {'default_x_fc_job_sort_id': self.id},
|
||||
}
|
||||
@@ -1,32 +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, models
|
||||
|
||||
|
||||
class ProductPricelist(models.Model):
|
||||
"""Express Orders currency-picker enhancement (C1 / 2026-05-26).
|
||||
|
||||
When rendered with `fp_express_currency_picker=True` in context (set
|
||||
by the Express Orders form's pricelist_id field), prefix the display
|
||||
name with the currency code so the dropdown reads:
|
||||
|
||||
CAD - Public Pricelist (CAD)
|
||||
USD - Westin USA Pricelist
|
||||
EUR - Public Pricelist (EUR)
|
||||
|
||||
Elsewhere in Odoo (partner form, sale.order, settings), the standard
|
||||
pricelist display name is unchanged.
|
||||
"""
|
||||
_inherit = 'product.pricelist'
|
||||
|
||||
@api.depends('name', 'currency_id')
|
||||
@api.depends_context('fp_express_currency_picker')
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
if self.env.context.get('fp_express_currency_picker'):
|
||||
for pl in self:
|
||||
if pl.currency_id and pl.currency_id.name not in (pl.display_name or ''):
|
||||
pl.display_name = f"{pl.currency_id.name} - {pl.name}"
|
||||
@@ -1,76 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
x_fc_part_catalog_ids = fields.One2many(
|
||||
'fp.part.catalog', 'partner_id',
|
||||
string='Part Catalog',
|
||||
)
|
||||
x_fc_part_count = fields.Integer(
|
||||
string='Parts',
|
||||
compute='_compute_part_count',
|
||||
)
|
||||
# Default lead-time band for new Express Orders. Set once per
|
||||
# customer in their Plating profile; auto-copies onto every new
|
||||
# Express Order via the partner-onchange on fp.direct.order.wizard.
|
||||
x_fc_default_lead_time_min_days = fields.Integer(
|
||||
string='Default Lead Time Min (days)',
|
||||
default=0,
|
||||
help='Pre-fills the Lead Time Min field on new Express Orders '
|
||||
'for this customer. Operator can override per-order.',
|
||||
)
|
||||
x_fc_default_lead_time_max_days = fields.Integer(
|
||||
string='Default Lead Time Max (days)',
|
||||
default=0,
|
||||
help='Pre-fills the Lead Time Max field on new Express Orders '
|
||||
'for this customer. Operator can override per-order.',
|
||||
)
|
||||
|
||||
def _compute_part_count(self):
|
||||
for partner in self:
|
||||
partner.x_fc_part_count = self.env['fp.part.catalog'].search_count([
|
||||
('partner_id', '=', partner.id),
|
||||
('is_latest_revision', '=', True),
|
||||
])
|
||||
|
||||
def action_view_parts(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Parts - {self.name}',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', '=', self.id), ('is_latest_revision', '=', True)],
|
||||
'context': {'default_partner_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_fp_import_parts(self):
|
||||
"""Open the CSV import wizard with this partner pre-selected."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Import Parts from CSV'),
|
||||
'res_model': 'fp.part.catalog.import.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_partner_id': self.id},
|
||||
}
|
||||
|
||||
def action_fp_new_direct_order(self):
|
||||
"""Open the Direct Order wizard with this partner pre-selected."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('New Direct Order'),
|
||||
'res_model': 'fp.direct.order.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_partner_id': self.id},
|
||||
}
|
||||
@@ -1,904 +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
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
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_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_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='PO Document', tracking=True,
|
||||
)
|
||||
x_fc_po_received = fields.Boolean(string='PO Received', tracking=True)
|
||||
x_fc_rfq_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='RFQ Document', tracking=True,
|
||||
help="Customer's original Request for Quote document.",
|
||||
)
|
||||
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
|
||||
upload_rfq_filename = fields.Char(string='RFQ Filename')
|
||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||
upload_po_filename = fields.Char(string='PO Filename')
|
||||
x_fc_po_override = fields.Boolean(string='PO Override',
|
||||
help='Manager override - proceed without formal PO (handshake deal).')
|
||||
x_fc_po_override_reason = fields.Text(string='Override Reason')
|
||||
# Estimator-level "PO is coming later" flag. Unlike PO Override
|
||||
# (permanent, manager-only), this one is time-boxed: the order
|
||||
# confirms with no PO yet, but a chase activity is scheduled for
|
||||
# po_expected_date so sales chases the customer for the paperwork.
|
||||
x_fc_po_pending = fields.Boolean(
|
||||
string='PO Pending',
|
||||
tracking=True,
|
||||
help='Customer will provide the PO later. Confirms the order '
|
||||
'without a PO number, schedules a chase activity, and '
|
||||
'shows a "PO Pending" ribbon on the form. Toggle off once '
|
||||
'the real PO arrives and you\'ve entered it below.',
|
||||
)
|
||||
x_fc_po_expected_date = fields.Date(
|
||||
string='PO Expected By',
|
||||
tracking=True,
|
||||
help='Date the customer promised to send the PO. A follow-up '
|
||||
'activity is scheduled for this date when the order is '
|
||||
'confirmed with PO Pending set.',
|
||||
)
|
||||
x_fc_invoice_strategy = fields.Selection(
|
||||
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
|
||||
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
|
||||
string='Invoice Strategy', tracking=True,
|
||||
)
|
||||
x_fc_deposit_percent = fields.Float(string='Deposit %',
|
||||
help='Deposit percentage if strategy is Deposit.')
|
||||
x_fc_progress_initial_percent = fields.Float(
|
||||
string='Progress - Initial %',
|
||||
default=50.0,
|
||||
help='First-phase percentage for Progress Billing strategy. '
|
||||
'Billed on SO confirmation; remainder billed on delivery.',
|
||||
)
|
||||
x_fc_final_invoice_id = fields.Many2one(
|
||||
'account.move', string='Final Invoice', copy=False, readonly=True,
|
||||
help='Final invoice auto-created on delivery for Progress Billing / '
|
||||
'Net Terms strategies.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
||||
|
||||
# Lead Time (Phase D11) - promised production window in business
|
||||
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
|
||||
# so we render a proper expectation on the SO confirmation instead
|
||||
# of the binary Standard/Rush we had before. Both fields default to
|
||||
# 0 - `x_fc_lead_time_display` computes the right human-readable
|
||||
# string (range / single value / Rush / Standard) for the PDF.
|
||||
x_fc_lead_time_min_days = fields.Integer(
|
||||
string='Lead Time Min (days)', tracking=True,
|
||||
help='Lower bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_max_days = fields.Integer(
|
||||
string='Lead Time Max (days)', tracking=True,
|
||||
help='Upper bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_display = fields.Char(
|
||||
string='Lead Time',
|
||||
compute='_compute_lead_time_display',
|
||||
help='Human-readable lead time string for the SO confirmation PDF.',
|
||||
)
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
string='Delivery Method', tracking=True,
|
||||
)
|
||||
x_fc_receiving_status = fields.Selection(
|
||||
[('not_received', 'Not Received'), ('partial', 'Partial'),
|
||||
('received', 'Received')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
help='State of the linked fp.receiving record(s). Inspection is '
|
||||
"no longer a receiving state - Sub 8 moved part inspection "
|
||||
'into the recipe (racking step), so receiving stops at '
|
||||
'"received" (boxes counted, staged, closed).',
|
||||
)
|
||||
|
||||
# ---- Direct Order rewrite (Phase A) ----
|
||||
x_fc_customer_job_number = fields.Char(
|
||||
string='Customer Job #',
|
||||
help="Customer's internal job number for cross-referencing.",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
help='Free-form bucket that groups this SO in the "Sale Orders '
|
||||
'by Sorting" list view. Quick-create from the dropdown - '
|
||||
'each shop slices its backlog differently (customer programme, '
|
||||
'priority, week, etc.).',
|
||||
)
|
||||
|
||||
# ---- Express Orders header-level (2026-05-26) ----
|
||||
# 2026-05-27: changed from Char to Many2One - Material/Process Tag
|
||||
# IS the order's recipe. Auto-applies to every line at confirm time.
|
||||
x_fc_material_process = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Material / Process Tag',
|
||||
domain="[('node_type', '=', 'recipe')]",
|
||||
help='Order-level recipe - auto-applies to every line. Individual '
|
||||
'lines can still override via x_fc_process_variant_id.',
|
||||
)
|
||||
x_fc_internal_notes = fields.Text(
|
||||
string='Order-Level Internal Notes',
|
||||
help='Notes visible only to the estimator / planner / shop. Never '
|
||||
'prints on customer-facing PDFs. Distinct from sale.order.note '
|
||||
'which IS customer-facing (Terms & Conditions).',
|
||||
)
|
||||
x_fc_print_terms = fields.Boolean(
|
||||
string='Print Terms on Customer Documents',
|
||||
default=True,
|
||||
help='When False, the Terms & Conditions (sale.order.note) is '
|
||||
'suppressed on quote / SO / invoice / packing slip PDFs.',
|
||||
)
|
||||
x_fc_tooling_charge = fields.Monetary(
|
||||
string='Tooling Charge',
|
||||
currency_field='currency_id',
|
||||
help='Optional one-time tooling fee from the Express Orders form. '
|
||||
'Surfaced on the invoice as a separate line.',
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
string='Planned Start Date', tracking=True,
|
||||
)
|
||||
x_fc_internal_deadline = fields.Date(
|
||||
string='Internal Deadline', tracking=True,
|
||||
)
|
||||
x_fc_is_blanket_order = fields.Boolean(
|
||||
string='Is Blanket Sales Order',
|
||||
help='Blanket orders release parts in quantities over time, '
|
||||
'often with a negotiated price and a fixed expiry.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_block_partial_shipments = fields.Boolean(
|
||||
string='Block Partial Shipments',
|
||||
help='If set, the order must ship all-or-nothing. '
|
||||
'Partial pickings are blocked.',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Phase D: SO detail view polish ----
|
||||
x_fc_external_note = fields.Html(
|
||||
string='External Notes',
|
||||
help='Customer-visible notes. Appear on the SO acknowledgement '
|
||||
'and customer portal.',
|
||||
)
|
||||
x_fc_internal_note = fields.Html(
|
||||
string='Internal Notes',
|
||||
help='Internal-only notes for the estimator / planner / shop floor.',
|
||||
)
|
||||
x_fc_ship_via = fields.Char(
|
||||
string='Ship Via',
|
||||
help='Carrier or delivery method name (UPS, FedEx, customer pickup, etc.).',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_contact_phone = fields.Char(
|
||||
related='partner_id.phone', string='Contact Phone', readonly=True,
|
||||
)
|
||||
x_fc_deadline_countdown = fields.Char(
|
||||
string='Deadline',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
# Drives the colour of the Deadline column. Computed in the same pass
|
||||
# as x_fc_deadline_countdown so the buckets always agree with the
|
||||
# human-readable countdown string.
|
||||
x_fc_deadline_urgency = fields.Selection(
|
||||
[('overdue', 'Overdue'),
|
||||
('urgent', 'Due within 2 days'),
|
||||
('safe', 'More than 2 days')],
|
||||
string='Deadline Urgency',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
x_fc_order_completion_date = fields.Date(
|
||||
string='Order Completion Date',
|
||||
compute='_compute_order_completion_date',
|
||||
store=True,
|
||||
help='When the LATEST line is actually due. Auto-rolled up from '
|
||||
'each line\'s effective deadline. Distinct from Customer '
|
||||
'Deadline (what we promised) - this reflects shop reality.',
|
||||
)
|
||||
x_fc_is_late_forecast = fields.Boolean(
|
||||
string='Late Forecast',
|
||||
compute='_compute_is_late_forecast',
|
||||
store=True,
|
||||
help='True when the rolled-up Order Completion Date sits past the '
|
||||
'Customer Deadline. Suppressed on blanket orders since their '
|
||||
'spans are intentionally long.',
|
||||
)
|
||||
x_fc_margin_amount = fields.Monetary(
|
||||
string='Margin',
|
||||
compute='_compute_margin', currency_field='currency_id',
|
||||
)
|
||||
x_fc_margin_percent = fields.Float(
|
||||
string='Margin %',
|
||||
compute='_compute_margin',
|
||||
)
|
||||
x_fc_margin_available = fields.Boolean(
|
||||
string='Margin Available',
|
||||
compute='_compute_margin',
|
||||
help='False when no order line has a costed coating - the '
|
||||
'margin fields should render "n/a" in the UI.',
|
||||
)
|
||||
|
||||
# NB. The compute lives in fusion_plating_bridge_mrp. We keep a
|
||||
# stub field here so configurator's SO view (loaded before
|
||||
# bridge_mrp on `-u`) can reference the field by name. bridge_mrp's
|
||||
# `fields.Integer(compute=…)` redeclaration fills in the compute on
|
||||
# top of this stub during its own load pass.
|
||||
x_fc_workorder_count = fields.Integer(string='Work Orders')
|
||||
|
||||
# Smart-button visibility helpers (post-Sub 11). The BOM Items kanban
|
||||
# is only useful when the SO carries 2+ distinct parts; the By Job
|
||||
# Group kanban is only useful when at least one line is tagged with
|
||||
# x_fc_wo_group_tag. Default-hidden otherwise so the smart-button
|
||||
# row stays clean for the typical single-part SO.
|
||||
x_fc_distinct_part_count = fields.Integer(
|
||||
string='# Distinct Parts',
|
||||
compute='_compute_smart_button_visibility',
|
||||
)
|
||||
x_fc_has_wo_group_tag = fields.Boolean(
|
||||
string='Has Job Group Tag',
|
||||
compute='_compute_smart_button_visibility',
|
||||
)
|
||||
x_fc_wo_group_count = fields.Integer(
|
||||
string='# Job Groups',
|
||||
compute='_compute_smart_button_visibility',
|
||||
help='Distinct x_fc_wo_group_tag values across this SO\'s lines.',
|
||||
)
|
||||
|
||||
@api.depends('order_line.x_fc_part_catalog_id',
|
||||
'order_line.x_fc_wo_group_tag')
|
||||
def _compute_smart_button_visibility(self):
|
||||
for rec in self:
|
||||
parts = rec.order_line.mapped('x_fc_part_catalog_id')
|
||||
rec.x_fc_distinct_part_count = len(parts)
|
||||
tags = {
|
||||
t for t in rec.order_line.mapped('x_fc_wo_group_tag') if t
|
||||
}
|
||||
rec.x_fc_has_wo_group_tag = bool(tags)
|
||||
rec.x_fc_wo_group_count = len(tags)
|
||||
|
||||
# Sub 9 - process variant summary across order lines. Renders one
|
||||
# variant label when all lines share one, otherwise "Mixed (N)".
|
||||
x_fc_process_summary = fields.Char(
|
||||
string='Process',
|
||||
compute='_compute_process_summary',
|
||||
help='Process variant(s) used by this order. Drives WO generation.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_process_variant_id',
|
||||
'order_line.x_fc_part_catalog_id.default_process_id',
|
||||
)
|
||||
def _compute_process_summary(self):
|
||||
for so in self:
|
||||
variants = []
|
||||
for line in so.order_line:
|
||||
if not line.x_fc_part_catalog_id:
|
||||
continue # non-plating line
|
||||
variant = (line.x_fc_process_variant_id
|
||||
or line.x_fc_part_catalog_id.default_process_id)
|
||||
if variant and variant not in variants:
|
||||
variants.append(variant)
|
||||
if not variants:
|
||||
so.x_fc_process_summary = False
|
||||
elif len(variants) == 1:
|
||||
v = variants[0]
|
||||
so.x_fc_process_summary = (
|
||||
v.variant_label or v.name or 'Default'
|
||||
)
|
||||
else:
|
||||
so.x_fc_process_summary = 'Mixed (%d variants)' % len(variants)
|
||||
|
||||
# ---- Phase E: list view helpers ----
|
||||
x_fc_wo_completion = fields.Char(
|
||||
string='WO Progress',
|
||||
compute='_compute_wo_completion',
|
||||
help='Ratio of completed work orders, shown as "3/5 done".',
|
||||
)
|
||||
x_fc_invoiced_amount = fields.Monetary(
|
||||
string='Invoiced',
|
||||
compute='_compute_invoiced_amount',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
# Single "Job Status" pill rendered in the SO list. Pipeline order:
|
||||
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
|
||||
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
|
||||
# Delivered → Invoiced → Paid → Cancelled.
|
||||
# Rendered as an Html field so each kind can carry its own tint via
|
||||
# an .fp-kind-* class - Bootstrap's 5 decoration-* slots aren't
|
||||
# enough to give every phase a distinct colour. SCSS bundle at
|
||||
# static/src/scss/fp_job_status_pill.scss owns the colour map.
|
||||
x_fc_fp_job_status = fields.Html(
|
||||
string='Job Status',
|
||||
compute='_compute_fp_job_status',
|
||||
sanitize=False,
|
||||
help='Single at-a-glance pill that advances through the order '
|
||||
'lifecycle: receiving → WO progress → shipping → invoicing.',
|
||||
)
|
||||
x_fc_fp_job_status_kind = fields.Selection(
|
||||
[('muted', 'Draft (grey)'),
|
||||
('warning', 'Awaiting / Partial (amber)'),
|
||||
('primary', 'Ready / Milestone (purple)'),
|
||||
('info', 'Active Work (blue)'),
|
||||
('shipping', 'Shipping (cyan)'),
|
||||
('delivered', 'Delivered (teal)'),
|
||||
('invoiced', 'Invoiced (lime)'),
|
||||
('paid', 'Paid (green bold)'),
|
||||
('danger', 'Cancelled (red)')],
|
||||
string='Job Status Kind',
|
||||
compute='_compute_fp_job_status',
|
||||
help='Colour category that backs the Job Status pill - also '
|
||||
'usable for filtering / grouping in the list search panel.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'x_fc_receiving_status',
|
||||
'x_fc_wo_completion',
|
||||
'invoice_ids.state',
|
||||
'invoice_ids.payment_state',
|
||||
'invoice_ids.move_type',
|
||||
)
|
||||
def _compute_fp_job_status(self):
|
||||
from markupsafe import Markup as _Markup
|
||||
from markupsafe import escape as _escape
|
||||
for so in self:
|
||||
label, kind = self._fp_resolve_job_status(so)
|
||||
so.x_fc_fp_job_status_kind = kind
|
||||
so.x_fc_fp_job_status = _Markup(
|
||||
'<span class="fp-job-status fp-kind-%s">%s</span>'
|
||||
) % (_Markup(kind), _escape(label))
|
||||
|
||||
@staticmethod
|
||||
def _fp_resolve_job_status(so):
|
||||
# Terminal SO states first.
|
||||
if so.state == 'cancel':
|
||||
return ('Cancelled', 'danger')
|
||||
if so.state in ('draft', 'sent'):
|
||||
return ('Draft', 'muted')
|
||||
|
||||
# Invoice phase (terminal positive states).
|
||||
posted = so.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted'
|
||||
and m.move_type in ('out_invoice', 'out_refund')
|
||||
)
|
||||
if posted and all(
|
||||
m.payment_state in ('paid', 'in_payment') for m in posted
|
||||
):
|
||||
return ('Paid', 'paid')
|
||||
|
||||
# Shipping phase signals - read once.
|
||||
ship_status = None
|
||||
if 'x_fc_receiving_ids' in so._fields:
|
||||
for r in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
r.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in r._fields else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
# Latch the most-advanced status across all receivings.
|
||||
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
|
||||
cur = (
|
||||
'delivered' if ship.status == 'delivered'
|
||||
else 'in_transit' if ship.status == 'shipped'
|
||||
else 'booked' if ship.status in ('confirmed', 'draft')
|
||||
else None
|
||||
)
|
||||
if rank[cur] > rank[ship_status]:
|
||||
ship_status = cur
|
||||
|
||||
if posted and ship_status == 'delivered':
|
||||
return ('Invoiced', 'invoiced')
|
||||
if ship_status == 'delivered':
|
||||
return ('Delivered', 'delivered')
|
||||
if ship_status == 'in_transit':
|
||||
return ('In Transit', 'shipping')
|
||||
|
||||
# WO phase - figure out total steps and the current step name.
|
||||
tot = 0
|
||||
current_step_name = None
|
||||
Job = so.env.get('fp.job')
|
||||
if Job is not None and so.name:
|
||||
jobs = Job.sudo().search([('origin', '=', so.name)])
|
||||
if jobs:
|
||||
steps = jobs.mapped('step_ids').sorted(
|
||||
lambda s: (s.job_id.id, s.sequence)
|
||||
)
|
||||
tot = len(steps)
|
||||
# Priority: in_progress → paused → next ready/pending.
|
||||
current = (
|
||||
steps.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
or steps.filtered(lambda s: s.state == 'paused')[:1]
|
||||
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
|
||||
)
|
||||
current_step_name = current.name if current else None
|
||||
|
||||
all_steps_done = tot > 0 and current_step_name is None
|
||||
|
||||
if all_steps_done:
|
||||
if ship_status == 'booked':
|
||||
return ('Ship Booked', 'shipping')
|
||||
return ('Ready to Ship', 'primary')
|
||||
if current_step_name:
|
||||
return (current_step_name, 'info')
|
||||
|
||||
# Receiving phase (no WO yet).
|
||||
recv = so.x_fc_receiving_status or 'not_received'
|
||||
if recv == 'received':
|
||||
return ('Ready to Start', 'primary')
|
||||
if recv == 'partial':
|
||||
return ('Parts Partial', 'warning')
|
||||
return ('Awaiting Parts', 'warning')
|
||||
|
||||
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
|
||||
def _compute_lead_time_display(self):
|
||||
"""Render the lead time as a human-readable string for reports.
|
||||
|
||||
Priority order:
|
||||
- Real min/max range set → "X-Y days" or "X days"
|
||||
- Range not set, rush_order on → "Rush"
|
||||
- Otherwise → "Standard"
|
||||
"""
|
||||
for so in self:
|
||||
mn = so.x_fc_lead_time_min_days or 0
|
||||
mx = so.x_fc_lead_time_max_days or 0
|
||||
if mn and mx and mn != mx:
|
||||
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
|
||||
elif mx or mn:
|
||||
so.x_fc_lead_time_display = '%d days' % (mx or mn)
|
||||
elif so.x_fc_rush_order:
|
||||
so.x_fc_lead_time_display = 'Rush'
|
||||
else:
|
||||
so.x_fc_lead_time_display = 'Standard'
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_wo_completion(self):
|
||||
"""Batched: one grouped query across all records in self.
|
||||
|
||||
Sub 11 - MRP is gone; we count fp.job.step completion instead of
|
||||
mrp.workorder. The selection is the same shape: completed steps
|
||||
out of total steps across every fp.job for this SO.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.x_fc_wo_completion = '0/0'
|
||||
names = [so.name for so in self if so.name]
|
||||
if not names:
|
||||
return
|
||||
if 'fp.job.step' not in self.env or 'fp.job' not in self.env:
|
||||
return
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Step = self.env['fp.job.step'].sudo()
|
||||
jobs = Job.search([('origin', 'in', names)])
|
||||
if not jobs:
|
||||
return
|
||||
job_to_origin = {j.id: j.origin for j in jobs}
|
||||
# Odoo 19 - use _read_group with aggregates=['__count'].
|
||||
rows = Step._read_group(
|
||||
domain=[('job_id', 'in', jobs.ids)],
|
||||
groupby=['job_id', 'state'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
totals = {} # {origin: [total, done]}
|
||||
for job_rec, state_val, count in rows:
|
||||
origin = job_to_origin.get(job_rec.id)
|
||||
if not origin:
|
||||
continue
|
||||
bucket = totals.setdefault(origin, [0, 0])
|
||||
bucket[0] += count
|
||||
if state_val == 'done':
|
||||
bucket[1] += count
|
||||
for rec in self:
|
||||
if not rec.name:
|
||||
continue
|
||||
tot, done = totals.get(rec.name, [0, 0])
|
||||
rec.x_fc_wo_completion = f'{done}/{tot}' if tot else '0/0'
|
||||
|
||||
# ---- Phase F: quotes list view polish ----
|
||||
x_fc_follow_up_date = fields.Date(
|
||||
string='Follow-Up Date',
|
||||
help='Date to chase the customer for a decision on this quote.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_follow_up_user_id = fields.Many2one(
|
||||
'res.users', string='Follow-Up Owner',
|
||||
help='Who should chase the customer on the follow-up date.',
|
||||
)
|
||||
x_fc_email_status = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('sent', 'Sent'),
|
||||
('opened', 'Opened'),
|
||||
('won', 'Order Received')],
|
||||
string='Email Status',
|
||||
compute='_compute_email_status',
|
||||
store=True,
|
||||
)
|
||||
x_fc_part_numbers_summary = fields.Char(
|
||||
string='Part Numbers',
|
||||
compute='_compute_part_numbers_summary',
|
||||
)
|
||||
x_fc_signed_at = fields.Datetime(
|
||||
string='Signed On', tracking=True,
|
||||
help='When the customer signed / accepted this quote.',
|
||||
)
|
||||
x_fc_signed_by = fields.Char(
|
||||
string='Signed By', tracking=True,
|
||||
help='Name of the customer signatory.',
|
||||
)
|
||||
x_fc_is_signed = fields.Boolean(
|
||||
string='Signed', compute='_compute_is_signed', store=True,
|
||||
)
|
||||
|
||||
@api.depends('x_fc_signed_at')
|
||||
def _compute_is_signed(self):
|
||||
for rec in self:
|
||||
rec.x_fc_is_signed = bool(rec.x_fc_signed_at)
|
||||
|
||||
def action_mark_signed(self):
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'x_fc_signed_at': fields.Datetime.now(),
|
||||
'x_fc_signed_by': self.partner_id.name,
|
||||
})
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_email_status(self):
|
||||
"""Map state + mail tracking to a single visible pill.
|
||||
|
||||
- state draft => draft
|
||||
- state sent => sent (or 'opened' if the customer partner has
|
||||
a read notification for any email message on this SO)
|
||||
- state sale / done => won
|
||||
|
||||
'Opened' is scoped to the CUSTOMER partner's notifications -
|
||||
not internal CCs - to avoid false positives from sales-ops
|
||||
viewing the thread.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.state in ('sale', 'done'):
|
||||
rec.x_fc_email_status = 'won'
|
||||
continue
|
||||
if rec.state == 'draft':
|
||||
rec.x_fc_email_status = 'draft'
|
||||
continue
|
||||
# state == 'sent'
|
||||
opened = False
|
||||
if rec.id and rec.partner_id:
|
||||
# Look for any read notification on any email message
|
||||
# of this SO that targeted the customer.
|
||||
notif_count = self.env['mail.notification'].sudo().search_count([
|
||||
('mail_message_id.model', '=', 'sale.order'),
|
||||
('mail_message_id.res_id', '=', rec.id),
|
||||
('mail_message_id.message_type', '=', 'email'),
|
||||
('res_partner_id', '=', rec.partner_id.id),
|
||||
('is_read', '=', True),
|
||||
])
|
||||
opened = notif_count > 0
|
||||
rec.x_fc_email_status = 'opened' if opened else 'sent'
|
||||
|
||||
@api.depends('order_line.x_fc_part_catalog_id.part_number')
|
||||
def _compute_part_numbers_summary(self):
|
||||
for rec in self:
|
||||
parts = rec.order_line.mapped('x_fc_part_catalog_id.part_number')
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
rec.x_fc_part_numbers_summary = False
|
||||
continue
|
||||
if len(parts) <= 2:
|
||||
rec.x_fc_part_numbers_summary = ', '.join(parts)
|
||||
else:
|
||||
rec.x_fc_part_numbers_summary = '%s, %s (+%d more)' % (
|
||||
parts[0], parts[1], len(parts) - 2,
|
||||
)
|
||||
|
||||
@api.depends('invoice_ids.amount_total', 'invoice_ids.state',
|
||||
'invoice_ids.move_type')
|
||||
def _compute_invoiced_amount(self):
|
||||
for rec in self:
|
||||
posted = rec.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted' and m.move_type == 'out_invoice'
|
||||
)
|
||||
refunds = rec.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted' and m.move_type == 'out_refund'
|
||||
)
|
||||
rec.x_fc_invoiced_amount = (
|
||||
sum(posted.mapped('amount_total'))
|
||||
- sum(refunds.mapped('amount_total'))
|
||||
)
|
||||
|
||||
def action_view_workorders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Work Orders',
|
||||
'res_model': 'mrp.workorder',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('production_id.origin', '=', self.name)],
|
||||
'context': {'search_default_group_production_id': 1},
|
||||
}
|
||||
|
||||
# ---- Quick-nav counts for smart buttons (Phase D9 / D14) ----
|
||||
x_fc_ncr_count = fields.Integer(
|
||||
string='NCRs', compute='_compute_nav_counts',
|
||||
)
|
||||
x_fc_picking_count = fields.Integer(
|
||||
string='Transfer Count', compute='_compute_nav_counts',
|
||||
)
|
||||
|
||||
@api.depends('picking_ids')
|
||||
def _compute_nav_counts(self):
|
||||
for rec in self:
|
||||
rec.x_fc_picking_count = len(rec.picking_ids)
|
||||
|
||||
# NCR counts - only if the module is installed.
|
||||
ids = self.ids
|
||||
NCR = self.env.get('fusion.plating.ncr')
|
||||
ncr_counts = {}
|
||||
if ids and NCR is not None and 'sale_order_id' in NCR._fields:
|
||||
rows = NCR.sudo().read_group(
|
||||
[('sale_order_id', 'in', ids)],
|
||||
['sale_order_id'], ['sale_order_id'], lazy=False,
|
||||
)
|
||||
ncr_counts = {
|
||||
(r['sale_order_id'][0] if r['sale_order_id'] else False):
|
||||
r['__count']
|
||||
for r in rows
|
||||
}
|
||||
for rec in self:
|
||||
rec.x_fc_ncr_count = ncr_counts.get(rec.id, 0)
|
||||
|
||||
def action_view_pickings(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Transfers',
|
||||
'res_model': 'stock.picking',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.picking_ids.ids)],
|
||||
}
|
||||
|
||||
def action_view_ncrs(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'NCRs',
|
||||
'res_model': 'fusion.plating.ncr',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
}
|
||||
|
||||
|
||||
def action_view_bom_items(self):
|
||||
"""Open SO lines grouped by part catalog (Phase D2)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'BOM Items - %s' % self.name,
|
||||
'res_model': 'sale.order.line',
|
||||
'view_mode': 'kanban,list,form',
|
||||
'views': [
|
||||
(self.env.ref('fusion_plating_configurator.view_sale_order_line_bom_kanban').id, 'kanban'),
|
||||
(False, 'list'),
|
||||
(False, 'form'),
|
||||
],
|
||||
'domain': [('order_id', '=', self.id)],
|
||||
}
|
||||
|
||||
def action_view_wo_perspective(self):
|
||||
"""Open SO lines grouped by WO tag (Phase D10)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Lines by WO - %s' % self.name,
|
||||
'res_model': 'sale.order.line',
|
||||
'view_mode': 'kanban,list',
|
||||
'views': [
|
||||
(self.env.ref('fusion_plating_configurator.view_sale_order_line_wo_kanban').id, 'kanban'),
|
||||
(False, 'list'),
|
||||
],
|
||||
'domain': [('order_id', '=', self.id)],
|
||||
}
|
||||
|
||||
@api.depends('commitment_date')
|
||||
def _compute_deadline_countdown(self):
|
||||
from datetime import datetime
|
||||
now = fields.Datetime.now()
|
||||
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||
for rec in self:
|
||||
if not rec.commitment_date:
|
||||
rec.x_fc_deadline_countdown = False
|
||||
rec.x_fc_deadline_urgency = False
|
||||
continue
|
||||
target = rec.commitment_date
|
||||
if isinstance(target, datetime):
|
||||
delta = target - now
|
||||
else:
|
||||
from datetime import datetime as _dt
|
||||
delta = _dt.combine(target, _dt.min.time()) - now
|
||||
secs = int(delta.total_seconds())
|
||||
if secs == 0:
|
||||
rec.x_fc_deadline_countdown = 'due now'
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
continue
|
||||
past = secs < 0
|
||||
abs_secs = abs(secs)
|
||||
days = abs_secs // 86400
|
||||
hours = (abs_secs % 86400) // 3600
|
||||
mins = (abs_secs % 3600) // 60
|
||||
bits = []
|
||||
if days:
|
||||
bits.append('%dd' % days)
|
||||
if hours:
|
||||
bits.append('%dh' % hours)
|
||||
if mins and not days:
|
||||
bits.append('%dm' % mins)
|
||||
phrase = ' '.join(bits) or '<1m'
|
||||
rec.x_fc_deadline_countdown = (
|
||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||
)
|
||||
if past:
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
elif secs <= TWO_DAYS:
|
||||
rec.x_fc_deadline_urgency = 'urgent'
|
||||
else:
|
||||
rec.x_fc_deadline_urgency = 'safe'
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_effective_part_deadline',
|
||||
'order_line.x_fc_archived',
|
||||
)
|
||||
def _compute_order_completion_date(self):
|
||||
"""Roll up = max(line.x_fc_effective_part_deadline) over non-
|
||||
archived lines. Empty / all-archived order returns False."""
|
||||
for rec in self:
|
||||
dates = [
|
||||
line.x_fc_effective_part_deadline
|
||||
for line in rec.order_line
|
||||
if line.x_fc_effective_part_deadline and not line.x_fc_archived
|
||||
]
|
||||
rec.x_fc_order_completion_date = max(dates) if dates else False
|
||||
|
||||
@api.depends(
|
||||
'x_fc_order_completion_date',
|
||||
'commitment_date',
|
||||
'x_fc_is_blanket_order',
|
||||
)
|
||||
def _compute_is_late_forecast(self):
|
||||
for rec in self:
|
||||
if rec.x_fc_is_blanket_order:
|
||||
rec.x_fc_is_late_forecast = False
|
||||
continue
|
||||
commit = rec.commitment_date.date() if rec.commitment_date else False
|
||||
rec.x_fc_is_late_forecast = bool(
|
||||
rec.x_fc_order_completion_date
|
||||
and commit
|
||||
and rec.x_fc_order_completion_date > commit
|
||||
)
|
||||
|
||||
@api.depends('order_line.price_subtotal', 'amount_untaxed')
|
||||
def _compute_margin(self):
|
||||
"""Margin computation - stub.
|
||||
|
||||
Pre-promote-customer-spec, this rolled up cost from
|
||||
fp.coating.config.unit_cost. Coating Config is retired; cost
|
||||
data on the recipe is a future enhancement (backlog). Until
|
||||
then, margin is "not available" and the UI hides the fields.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.x_fc_margin_available = False
|
||||
rec.x_fc_margin_amount = 0.0
|
||||
rec.x_fc_margin_percent = 0.0
|
||||
|
||||
@api.onchange('upload_rfq_file')
|
||||
def _onchange_upload_rfq_file(self):
|
||||
"""Create attachment from uploaded binary and link it."""
|
||||
if not self.upload_rfq_file:
|
||||
return
|
||||
fname = self.upload_rfq_filename or 'rfq.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_rfq_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.x_fc_rfq_attachment_id = att.id
|
||||
self.upload_rfq_file = False
|
||||
self.upload_rfq_filename = False
|
||||
|
||||
@api.onchange('upload_po_file')
|
||||
def _onchange_upload_po_file(self):
|
||||
"""Create attachment from uploaded binary, link it, and mark PO received."""
|
||||
if not self.upload_po_file:
|
||||
return
|
||||
fname = self.upload_po_filename or 'po.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_po_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.x_fc_po_attachment_id = att.id
|
||||
if not self.x_fc_po_received:
|
||||
self.x_fc_po_received = True
|
||||
self.upload_po_file = False
|
||||
self.upload_po_filename = False
|
||||
|
||||
def action_view_rfq(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_rfq_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.x_fc_rfq_attachment_id.id,
|
||||
'title': 'RFQ - %s' % (self.x_fc_rfq_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_po(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_po_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.x_fc_po_attachment_id.id,
|
||||
'title': 'PO - %s' % (self.x_fc_po_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
# ---- Sub 5 - auto-assign Job # on confirm -------------------------------
|
||||
# Job # is the shop-floor reference that prints on travellers and WOs.
|
||||
# Auto-assigned once at confirm so every confirmed line has one; still
|
||||
# editable afterwards (clearable, overridable to match a customer scheme).
|
||||
def action_confirm(self):
|
||||
# Phase G of permissions overhaul: only Sales Manager+ can confirm
|
||||
# Sale Orders. Sales Rep can save drafts but cannot move them to
|
||||
# 'sale' state. The has_group() check resolves True for Sales Manager,
|
||||
# Manager (implies Sales Manager via diamond), Quality Manager
|
||||
# (implies Manager), and Owner (implies Quality Manager) - see
|
||||
# spec Section 2.B.
|
||||
if not self.env.user.has_group('fusion_plating.group_fp_sales_manager'):
|
||||
raise UserError(_(
|
||||
'Only Sales Manager or higher can confirm Sale Orders. '
|
||||
'Please ask a Sales Manager to confirm this quote.'
|
||||
))
|
||||
res = super().action_confirm()
|
||||
Sequence = self.env['ir.sequence']
|
||||
for so in self:
|
||||
for line in so.order_line:
|
||||
if line.display_type:
|
||||
continue
|
||||
if not line.x_fc_job_number:
|
||||
line.x_fc_job_number = Sequence.next_by_code(
|
||||
'fp.job.number'
|
||||
) or False
|
||||
# Per-part description history (spec 2026-05-29). After super() +
|
||||
# the parent-number rename, so.name is the final order number.
|
||||
for so in self:
|
||||
for line in so.order_line:
|
||||
if line.display_type:
|
||||
continue
|
||||
part = (line.x_fc_part_catalog_id
|
||||
if 'x_fc_part_catalog_id' in line._fields else False)
|
||||
if not part:
|
||||
continue
|
||||
part._fp_save_description_version(
|
||||
internal_desc=line.x_fc_internal_description or '',
|
||||
customer_desc=line.name or '',
|
||||
order=so, line=line,
|
||||
)
|
||||
return res
|
||||
@@ -1,957 +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 datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
def fp_customer_description(self):
|
||||
"""Return line.name with the leading "[code] product_name" stripped.
|
||||
|
||||
Odoo's _compute_name re-prepends the product code + name on save,
|
||||
polluting customer-facing PDFs with internal-product noise like
|
||||
"[FP-SERVICE] Plating Service". This helper peels that prefix
|
||||
off so the QWeb macros print only what the estimator actually
|
||||
typed for the customer to see. Same logic mirrored on
|
||||
account.move.line for invoice rendering.
|
||||
"""
|
||||
self.ensure_one()
|
||||
name = (self.name or '').strip()
|
||||
if not self.product_id or not name:
|
||||
return name
|
||||
code = self.product_id.default_code or ''
|
||||
pname = self.product_id.name or ''
|
||||
# Try the bracketed form first ("[CODE] Name"), then bare name.
|
||||
# Whichever matches gets stripped along with any trailing
|
||||
# newline / dash / em-dash separator.
|
||||
prefixes = []
|
||||
if code and pname:
|
||||
prefixes.append(f'[{code}] {pname}')
|
||||
if pname:
|
||||
prefixes.append(pname)
|
||||
for prefix in prefixes:
|
||||
if name.startswith(prefix):
|
||||
tail = name[len(prefix):]
|
||||
return tail.lstrip(' \t\r\n---:').strip()
|
||||
return name
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _fp_onchange_part_load_description(self):
|
||||
"""Pre-fill name (customer-facing) + x_fc_internal_description from
|
||||
the part's latest description version, when empty (spec 2026-05-29)."""
|
||||
for line in self:
|
||||
part = line.x_fc_part_catalog_id
|
||||
if not part:
|
||||
continue
|
||||
descs = part._fp_resolve_line_descriptions()
|
||||
if not line.name and descs['customer_facing']:
|
||||
line.name = descs['customer_facing']
|
||||
if not line.x_fc_internal_description and descs['internal']:
|
||||
line.x_fc_internal_description = descs['internal']
|
||||
|
||||
x_fc_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
)
|
||||
# Sub 2 - dual descriptions captured from a template row at order
|
||||
# entry. `name` remains Odoo's standard customer-facing line
|
||||
# description; x_fc_internal_description is ops-only (prints on WO).
|
||||
# Nullable during Phase A; flipped to required in Phase C.
|
||||
x_fc_internal_description = fields.Text(
|
||||
string='Internal Description',
|
||||
required=True,
|
||||
help='Shop-floor instructions. Prints on WO / traveler. Never on customer docs.',
|
||||
)
|
||||
x_fc_description_template_id = fields.Many2one(
|
||||
'fp.sale.description.template',
|
||||
string='Description Template',
|
||||
help='Which template row populated this line. Informational.',
|
||||
)
|
||||
# Specification picker (x_fc_customer_spec_id) is added by
|
||||
# fusion_plating_quality. Legacy x_fc_coating_config_id +
|
||||
# x_fc_treatment_ids removed.
|
||||
x_fc_part_deadline = fields.Date(
|
||||
string='Part Deadline Override',
|
||||
help='Absolute-date manual override. When set, beats the days-offset '
|
||||
'and the part\'s default lead time. Leave blank to fall through '
|
||||
'to the offset, then part default, then the order\'s customer '
|
||||
'deadline.',
|
||||
)
|
||||
x_fc_part_deadline_offset_days = fields.Integer(
|
||||
string='Days Offset',
|
||||
help='Manual override expressed as "+N days from the order\'s '
|
||||
'customer deadline". Use this when you think in days rather '
|
||||
'than absolute dates. Ignored if Part Deadline Override is set.',
|
||||
)
|
||||
x_fc_effective_part_deadline = fields.Date(
|
||||
string='Effective Deadline',
|
||||
compute='_compute_effective_part_deadline',
|
||||
store=True,
|
||||
help='Computed deadline that actually drives shop scheduling. '
|
||||
'Resolution: explicit override → days offset → part default '
|
||||
'lead time → order customer deadline.',
|
||||
)
|
||||
x_fc_effective_internal_deadline = fields.Date(
|
||||
string='Shop Target',
|
||||
compute='_compute_effective_internal_deadline',
|
||||
store=True,
|
||||
help='Internal deadline for this line - effective customer '
|
||||
'deadline minus the order\'s shop buffer (commitment_date − '
|
||||
'internal_deadline gap). Clamped so it never exceeds the '
|
||||
'effective customer deadline.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush')
|
||||
x_fc_wo_group_tag = fields.Char(
|
||||
string='Work Order Group',
|
||||
help='Lines sharing a tag (e.g. "WO#1") will be batched into one '
|
||||
'manufacturing order when bridge_mrp generates MOs.',
|
||||
)
|
||||
x_fc_part_wo_description = fields.Text(
|
||||
string='On Work Order',
|
||||
help='Extra detail printed on the work order travelling sheet. '
|
||||
'Separate from the customer-facing line description.',
|
||||
)
|
||||
x_fc_start_at_node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Start at Node',
|
||||
help='For re-work jobs: pick the recipe step where this job '
|
||||
'should begin. bridge_mrp skips ancestor steps.',
|
||||
)
|
||||
x_fc_is_one_off = fields.Boolean(
|
||||
string='One-off Part',
|
||||
help='Flag for prototype / non-catalog parts that should not be '
|
||||
'reused after this order.',
|
||||
)
|
||||
x_fc_quote_id = fields.Many2one(
|
||||
'fp.quote.configurator',
|
||||
string='Linked Quote',
|
||||
help='Quote that seeded this line. Links back for audit trail.',
|
||||
)
|
||||
# Sub 9 (polished 2026-04-28) - process variant per line. The picker
|
||||
# now lets the estimator pick ANY root recipe in the system: the
|
||||
# part's own variants, another customer's variants, or a template
|
||||
# marked is_template. Cross-part picks auto-clone onto this part on
|
||||
# save (see _onchange_process_variant_clone) so per-line edits never
|
||||
# bleed across customers.
|
||||
x_fc_process_variant_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Variant',
|
||||
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
ondelete='set null',
|
||||
help='Pick any recipe - the part\'s own variant, another part\'s '
|
||||
'recipe, or a template from the library. If the chosen recipe '
|
||||
'doesn\'t belong to this part, it will be cloned onto the part '
|
||||
'when the order saves so per-line edits stay scoped. Use the '
|
||||
'Customize button on the line to open the Process Composer.',
|
||||
)
|
||||
x_fc_save_as_default_process = fields.Boolean(
|
||||
string='Save as Default for Part',
|
||||
default=False,
|
||||
help='When ticked, the chosen process variant becomes this part\'s '
|
||||
'default on order save - future orders for the same part '
|
||||
'pre-fill with this variant.',
|
||||
)
|
||||
x_fc_archived = fields.Boolean(
|
||||
string='Archived',
|
||||
default=False,
|
||||
help='Archived lines are hidden from the default list view but '
|
||||
'preserved for audit. Useful when a part is cancelled mid-order.',
|
||||
)
|
||||
|
||||
# ---- Sub 5 - Order-line fields (serial / job# / thickness / revision) ---
|
||||
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
|
||||
# inherited fields - Odoo emits a warning and ignores it. Audit trail
|
||||
# for these values lives on fp.serial.mail.thread instead.
|
||||
#
|
||||
# 2026-04-28 Phase 1 - multi-serial support. Customer can ship 30 parts
|
||||
# with 30 distinct serials on a single line. The M2M is the source of
|
||||
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
|
||||
# serial so existing reports / smart buttons / downstream code that
|
||||
# still reads the singular keep working unchanged.
|
||||
x_fc_serial_ids = fields.Many2many(
|
||||
'fp.serial',
|
||||
relation='fp_sale_order_line_serial_rel',
|
||||
column1='line_id',
|
||||
column2='serial_id',
|
||||
string='Serial Numbers',
|
||||
copy=False,
|
||||
help='Customer-supplied serial numbers for the parts on this line. '
|
||||
'Use the Bulk Add Serials button to paste a list, range-fill '
|
||||
'(SN-001..SN-030), or scan barcodes. Count must not exceed '
|
||||
'the line quantity.',
|
||||
)
|
||||
x_fc_serial_id = fields.Many2one(
|
||||
'fp.serial',
|
||||
string='Primary Serial',
|
||||
compute='_compute_primary_serial',
|
||||
inverse='_inverse_primary_serial',
|
||||
search='_search_primary_serial',
|
||||
store=False,
|
||||
copy=False,
|
||||
help='First of the line\'s serials - back-compat alias kept so '
|
||||
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
|
||||
'reverse links) keeps working. Setting this prepends the '
|
||||
'serial to the M2M.',
|
||||
)
|
||||
x_fc_serial_count = fields.Integer(
|
||||
string='# Serials',
|
||||
compute='_compute_serial_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_serial_ids')
|
||||
def _compute_primary_serial(self):
|
||||
for line in self:
|
||||
line.x_fc_serial_id = line.x_fc_serial_ids[:1]
|
||||
|
||||
def _inverse_primary_serial(self):
|
||||
for line in self:
|
||||
if not line.x_fc_serial_id:
|
||||
continue
|
||||
if line.x_fc_serial_id not in line.x_fc_serial_ids:
|
||||
line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
|
||||
|
||||
def _search_primary_serial(self, operator, value):
|
||||
return [('x_fc_serial_ids', operator, value)]
|
||||
|
||||
@api.depends('x_fc_serial_ids')
|
||||
def _compute_serial_count(self):
|
||||
for line in self:
|
||||
line.x_fc_serial_count = len(line.x_fc_serial_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Effective deadlines (Sub 12d)
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends(
|
||||
'x_fc_part_deadline',
|
||||
'x_fc_part_deadline_offset_days',
|
||||
'x_fc_part_catalog_id',
|
||||
'x_fc_part_catalog_id.x_fc_default_lead_time_days',
|
||||
'order_id.commitment_date',
|
||||
'order_id.x_fc_planned_start_date',
|
||||
)
|
||||
def _compute_effective_part_deadline(self):
|
||||
"""Resolution chain (first match wins):
|
||||
1. explicit absolute-date override (x_fc_part_deadline)
|
||||
2. days offset from commitment_date (x_fc_part_deadline_offset_days)
|
||||
3. part's default lead time from planned_start_date
|
||||
4. order's commitment_date (= customer profile cascade)
|
||||
5. planned_start_date as last resort (orphan order with no deadline)
|
||||
"""
|
||||
for line in self:
|
||||
order = line.order_id
|
||||
# commitment_date is a Datetime in Odoo standard; coerce to
|
||||
# date for arithmetic with our Date fields.
|
||||
commit_dt = order.commitment_date if order else False
|
||||
commit = commit_dt.date() if commit_dt else False
|
||||
start = (
|
||||
order.x_fc_planned_start_date if order
|
||||
else False
|
||||
) or fields.Date.context_today(line)
|
||||
|
||||
# 1. absolute-date override
|
||||
if line.x_fc_part_deadline:
|
||||
line.x_fc_effective_part_deadline = line.x_fc_part_deadline
|
||||
continue
|
||||
# 2. days offset from commitment
|
||||
if line.x_fc_part_deadline_offset_days and commit:
|
||||
line.x_fc_effective_part_deadline = (
|
||||
commit + timedelta(days=line.x_fc_part_deadline_offset_days)
|
||||
)
|
||||
continue
|
||||
# 3. part default lead time from planned_start
|
||||
part_lead = (
|
||||
line.x_fc_part_catalog_id
|
||||
and line.x_fc_part_catalog_id.x_fc_default_lead_time_days
|
||||
)
|
||||
if part_lead:
|
||||
line.x_fc_effective_part_deadline = (
|
||||
start + timedelta(days=part_lead)
|
||||
)
|
||||
continue
|
||||
# 4. order commitment (which itself derives from customer profile)
|
||||
if commit:
|
||||
line.x_fc_effective_part_deadline = commit
|
||||
continue
|
||||
# 5. last resort - planned start so the field is never null
|
||||
line.x_fc_effective_part_deadline = start
|
||||
|
||||
@api.depends(
|
||||
'x_fc_effective_part_deadline',
|
||||
'order_id.commitment_date',
|
||||
'order_id.x_fc_internal_deadline',
|
||||
)
|
||||
def _compute_effective_internal_deadline(self):
|
||||
"""Apply the order's customer-vs-internal buffer to the line's
|
||||
effective customer deadline. Buffer = commitment_date −
|
||||
x_fc_internal_deadline (the gap implied by customer profile).
|
||||
Clamp result so it never exceeds the customer deadline.
|
||||
"""
|
||||
for line in self:
|
||||
eff = line.x_fc_effective_part_deadline
|
||||
if not eff:
|
||||
line.x_fc_effective_internal_deadline = False
|
||||
continue
|
||||
order = line.order_id
|
||||
commit_dt = order.commitment_date if order else False
|
||||
commit = commit_dt.date() if commit_dt else False
|
||||
internal = order.x_fc_internal_deadline if order else False
|
||||
if commit and internal and commit >= internal:
|
||||
buffer_days = (commit - internal).days
|
||||
target = eff - timedelta(days=buffer_days)
|
||||
# Clamp: internal can never sit after customer date
|
||||
line.x_fc_effective_internal_deadline = (
|
||||
target if target <= eff else eff
|
||||
)
|
||||
else:
|
||||
# No buffer info → fall back to the customer date itself
|
||||
line.x_fc_effective_internal_deadline = eff
|
||||
x_fc_job_number = fields.Char(
|
||||
string='Job #',
|
||||
copy=False,
|
||||
index=True,
|
||||
help='Shop-floor reference for this line. Auto-sequenced on sale '
|
||||
'order confirmation; editable. Blank is allowed.',
|
||||
)
|
||||
x_fc_thickness_range = fields.Char(
|
||||
string='Thickness',
|
||||
help='Target thickness range as the operator types it, e.g. '
|
||||
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text - '
|
||||
'auto-fills from the last order for this (part, customer) '
|
||||
'pair, falling back to the part\'s default range. Prints '
|
||||
'verbatim on the cert, packing slip, and invoice.',
|
||||
)
|
||||
x_fc_is_lot_priced = fields.Boolean(string='Lot Priced')
|
||||
x_fc_lot_total = fields.Monetary(
|
||||
string='Lot Total', currency_field='currency_id')
|
||||
|
||||
# ---- Express Orders per-line flags (2026-05-26) ----
|
||||
# Mirror fp.direct.order.line.{customer_line_ref, masking_enabled, bake_instructions}
|
||||
# and persist past wizard confirm so _fp_apply_express_overrides_to_job can read them.
|
||||
x_fc_customer_line_ref = fields.Char(
|
||||
string='Customer Line Job #',
|
||||
help='Per-line customer sub-reference (e.g. ABC, DEF). '
|
||||
'Prints on customer docs (quote, SO, invoice, packing slip).',
|
||||
)
|
||||
x_fc_masking_enabled = fields.Boolean(
|
||||
string='Masking Enabled',
|
||||
default=True,
|
||||
help='When False, the job-creation hook spawns fp.job.node.override '
|
||||
'(included=False) for every masking + de_masking node on the recipe.',
|
||||
)
|
||||
x_fc_bake_instructions = fields.Text(
|
||||
string='Bake Instructions',
|
||||
help='Empty = bake steps are opted out of the job. Non-empty = bake '
|
||||
'steps run, with this text shown on the operator tablet under '
|
||||
'fp.job.step.instructions.',
|
||||
)
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'sale_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Masking reference image(s)/PDF(s) captured at Express order '
|
||||
'entry; applied to the job\'s masking step at job creation so '
|
||||
'the operator sees what to mask.',
|
||||
)
|
||||
x_fc_revision_snapshot = fields.Char(
|
||||
string='Revision (snapshot)',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
help='Revision letter / number at the moment the line was saved. '
|
||||
'Preserved even if the part catalog revision is later edited '
|
||||
'or the catalog row is removed.',
|
||||
)
|
||||
|
||||
# Revision picker - non-stored compute that re-points x_fc_part_catalog_id
|
||||
# to any revision of the same part number. The Part M2O itself is domain-
|
||||
# filtered to latest revisions only, so the picker is what surfaces
|
||||
# earlier revisions when the estimator needs one.
|
||||
x_fc_revision_pick_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Revision',
|
||||
compute='_compute_revision_pick_id',
|
||||
inverse='_inverse_revision_pick_id',
|
||||
store=False,
|
||||
help='Switch to a different revision of the same part number.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_part_catalog_id')
|
||||
def _compute_revision_pick_id(self):
|
||||
for line in self:
|
||||
line.x_fc_revision_pick_id = line.x_fc_part_catalog_id
|
||||
|
||||
def _inverse_revision_pick_id(self):
|
||||
for line in self:
|
||||
if line.x_fc_revision_pick_id:
|
||||
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
|
||||
|
||||
def _fp_apply_recipe_polish(self):
|
||||
"""Post-write step: auto-clone any cross-part recipe pick and
|
||||
honour the Save-as-Default toggle.
|
||||
|
||||
Called from create() and write() so the polish runs on every
|
||||
save path - onchange alone doesn't cover programmatic creates
|
||||
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
|
||||
"""
|
||||
for line in self:
|
||||
if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
|
||||
continue
|
||||
recipe = line.x_fc_process_variant_id
|
||||
if (not recipe.part_catalog_id
|
||||
or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
|
||||
clone = line._fp_clone_recipe_to_part()
|
||||
if clone and clone.id != recipe.id:
|
||||
line.x_fc_process_variant_id = clone.id
|
||||
recipe = clone
|
||||
if line.x_fc_save_as_default_process and recipe.part_catalog_id:
|
||||
line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Default `x_fc_internal_description` from `name` when a caller
|
||||
creates a line programmatically without supplying the internal
|
||||
description.
|
||||
|
||||
Sub 2 made `x_fc_internal_description` required. The UI-side
|
||||
onchange fills it when the user picks a description template,
|
||||
but programmatic creators (sale_mrp bridge, migration scripts,
|
||||
external integrations, demo seeders) may not know about this
|
||||
field. Instead of forcing every call site to update, fall back
|
||||
to `name` - same rule the upgrade migration used when it
|
||||
back-filled historical lines.
|
||||
"""
|
||||
Product = self.env['product.product']
|
||||
Part = self.env['fp.part.catalog']
|
||||
for vals in vals_list:
|
||||
if not vals.get('x_fc_internal_description'):
|
||||
# Try the explicit `name` first. If the caller didn't pass
|
||||
# one (sale_mrp + some Odoo internals don't - they let the
|
||||
# name compute from product_id later), fall back to the
|
||||
# product's display_name so we have SOMETHING non-empty.
|
||||
fallback = vals.get('name')
|
||||
if not fallback and vals.get('product_id'):
|
||||
prod = Product.browse(vals['product_id']).exists()
|
||||
if prod:
|
||||
fallback = prod.display_name or prod.name
|
||||
vals['x_fc_internal_description'] = fallback or '-'
|
||||
|
||||
# Sub 5 - freeze the revision letter on the line at save time.
|
||||
# Protects historical SOs from later edits to the catalog row.
|
||||
if not vals.get('x_fc_revision_snapshot') and vals.get('x_fc_part_catalog_id'):
|
||||
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||
if part and 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._fp_apply_recipe_polish()
|
||||
return lines
|
||||
|
||||
def write(self, vals):
|
||||
# Sub 5 - keep the revision snapshot in lockstep with the line's
|
||||
# part catalog pointer. Only refresh when the part changes; never
|
||||
# overwrite a snapshot that's already been set on a historical line.
|
||||
if 'x_fc_part_catalog_id' in vals:
|
||||
Part = self.env['fp.part.catalog']
|
||||
new_part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||
if new_part and new_part.revision:
|
||||
vals.setdefault('x_fc_revision_snapshot', new_part.revision)
|
||||
# Blank overrides keep their snapshot; explicit changes do too.
|
||||
for line in self:
|
||||
if line.x_fc_part_catalog_id.id != new_part.id:
|
||||
line.x_fc_revision_snapshot = new_part.revision
|
||||
result = super().write(vals)
|
||||
# Only run the polish when something relevant actually changed -
|
||||
# avoids re-running on every unrelated write (e.g. price updates).
|
||||
if any(k in vals for k in (
|
||||
'x_fc_process_variant_id',
|
||||
'x_fc_part_catalog_id',
|
||||
'x_fc_save_as_default_process',
|
||||
)):
|
||||
self._fp_apply_recipe_polish()
|
||||
return result
|
||||
|
||||
@api.onchange('x_fc_description_template_id')
|
||||
def _onchange_description_template(self):
|
||||
"""When estimator picks a template, auto-fill both descriptions.
|
||||
|
||||
The customer-facing text goes into `name` (Odoo's line description,
|
||||
prints on customer docs). The internal text goes into
|
||||
x_fc_internal_description (prints on WO / traveler only).
|
||||
Estimator can edit either field after the template is applied.
|
||||
"""
|
||||
if self.x_fc_description_template_id:
|
||||
tpl = self.x_fc_description_template_id
|
||||
if tpl.customer_facing_description:
|
||||
self.name = tpl.customer_facing_description
|
||||
if tpl.internal_description:
|
||||
self.x_fc_internal_description = tpl.internal_description
|
||||
|
||||
def action_archive_line(self):
|
||||
self.write({'x_fc_archived': True})
|
||||
return True
|
||||
|
||||
def action_unarchive_line(self):
|
||||
self.write({'x_fc_archived': False})
|
||||
return True
|
||||
|
||||
def _prepare_invoice_line(self, **optional_values):
|
||||
"""Carry x_fc_part_catalog_id + Sub 5 fields from SO line to invoice line.
|
||||
|
||||
Sub 2 Task 19 - lets the customer-facing invoice PDF render the
|
||||
customer's part number via the shared customer_line_header macro
|
||||
instead of the internal service SKU.
|
||||
|
||||
Sub 5 - also carry serial / job# / thickness / revision snapshot so
|
||||
the same macro can print them unchanged on invoices.
|
||||
"""
|
||||
vals = super()._prepare_invoice_line(**optional_values)
|
||||
if self.x_fc_part_catalog_id:
|
||||
vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id
|
||||
if self.x_fc_serial_ids:
|
||||
# Carry the full M2M to the invoice line. Back-compat alias
|
||||
# x_fc_serial_id will still resolve to the first one if any
|
||||
# downstream code only reads the singular.
|
||||
vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
|
||||
elif self.x_fc_serial_id:
|
||||
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
|
||||
if self.x_fc_job_number:
|
||||
vals['x_fc_job_number'] = self.x_fc_job_number
|
||||
if self.x_fc_thickness_range:
|
||||
vals['x_fc_thickness_range'] = self.x_fc_thickness_range
|
||||
if 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
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _onchange_part_default_variant(self):
|
||||
"""When the part changes, pre-fill the variant from the part's
|
||||
default_process_id (if set) so the line carries a sensible
|
||||
starting point. The estimator can override after.
|
||||
|
||||
Previously cleared the variant entirely when the part changed
|
||||
(because the variant picker was scoped to the part). Now that
|
||||
the picker is system-wide, we instead pre-fill from the part's
|
||||
default - much more useful.
|
||||
"""
|
||||
for line in self:
|
||||
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
|
||||
|
||||
# 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):
|
||||
"""Deep-copy the picked recipe onto this line's part if it isn't
|
||||
already scoped there. Returns the cloned (or unchanged) variant.
|
||||
|
||||
Edge cases handled:
|
||||
* No recipe picked → no-op, return False.
|
||||
* No part on the line → no-op (we need a part to scope the clone).
|
||||
* Recipe already belongs to this part → no-op, return as-is.
|
||||
* Recipe belongs to a different part / is a template / is unscoped
|
||||
→ deep-copy via Odoo's standard recursive copy(), reparent the
|
||||
clone onto this part, name-stamp it for traceability.
|
||||
"""
|
||||
self.ensure_one()
|
||||
recipe = self.x_fc_process_variant_id
|
||||
part = self.x_fc_part_catalog_id
|
||||
if not recipe or not part:
|
||||
return recipe
|
||||
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
|
||||
return recipe # already scoped - nothing to do
|
||||
# Clone - Odoo's default copy() recurses through child_ids when the
|
||||
# field has copy=True. fp.process.node sets that on its tree, so
|
||||
# one call gets us a full sub-tree clone.
|
||||
clone_name = recipe.name or _('Untitled Recipe')
|
||||
# If the source carried a part scope, preface the clone name with
|
||||
# the customer's part number for quick identification on the
|
||||
# variant dropdown later.
|
||||
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
|
||||
clone_name = '%s - %s' % (clone_name, part.part_number or part.display_name)
|
||||
clone = recipe.copy({
|
||||
'name': clone_name,
|
||||
'part_catalog_id': part.id,
|
||||
'is_template': False, # never propagate template flag
|
||||
'is_default_variant': False, # estimator opts in via toggle
|
||||
})
|
||||
return clone
|
||||
|
||||
def action_customize_process(self):
|
||||
"""Open the Process Composer for this line's process variant.
|
||||
|
||||
Auto-clones first if the variant isn't yet scoped to this part -
|
||||
the operator should never edit a recipe that's shared across
|
||||
customers (their edits would bleed). After cloning, the line
|
||||
ends up pointing at the fresh per-part copy.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Pick a part on this line before customizing the process - '
|
||||
'the recipe needs a part to scope the variant.'
|
||||
))
|
||||
if not self.x_fc_process_variant_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Pick a process variant on this line first. To start from '
|
||||
'scratch, use the part\'s Compose button instead.'
|
||||
))
|
||||
clone_or_existing = self._fp_clone_recipe_to_part()
|
||||
if clone_or_existing.id != self.x_fc_process_variant_id.id:
|
||||
self.x_fc_process_variant_id = clone_or_existing.id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_part_process_composer',
|
||||
'name': _('Customize Process - %s') % (
|
||||
self.x_fc_part_catalog_id.display_name
|
||||
or self.x_fc_part_catalog_id.part_number
|
||||
or '?'
|
||||
),
|
||||
'params': {
|
||||
'part_id': self.x_fc_part_catalog_id.id,
|
||||
'part_display': self.x_fc_part_catalog_id.display_name
|
||||
or self.x_fc_part_catalog_id.part_number,
|
||||
'focus_variant_id': clone_or_existing.id,
|
||||
},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _onchange_part_default_thickness(self):
|
||||
"""Auto-fill thickness range from last-used or part default.
|
||||
|
||||
Resolution order (first match wins):
|
||||
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:
|
||||
if line.x_fc_thickness_range:
|
||||
continue
|
||||
if not line.x_fc_part_catalog_id:
|
||||
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):
|
||||
"""Generate one new auto-sequenced serial and append it to the M2M.
|
||||
|
||||
Phase 1 polish: the legacy single-serial behaviour was "create one
|
||||
serial and pin it to x_fc_serial_id". Now we append to the M2M so
|
||||
repeated clicks add more serials (handy when the customer didn't
|
||||
send any and the shop wants to assign N).
|
||||
"""
|
||||
self.ensure_one()
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
|
||||
serial = self.env['fp.serial'].create({
|
||||
'name': seq,
|
||||
'sale_order_line_id': self.id,
|
||||
})
|
||||
self.x_fc_serial_ids = [(4, serial.id)]
|
||||
return False
|
||||
|
||||
def action_open_serial_bulk_add(self):
|
||||
"""Open the Bulk Add Serials wizard for this line."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.serial.bulk.add.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'name': _('Bulk Add Serials'),
|
||||
'context': {
|
||||
'default_target_model': 'sale.order.line',
|
||||
'default_target_id': self.id,
|
||||
'default_qty_expected': int(self.product_uom_qty or 0),
|
||||
},
|
||||
}
|
||||
|
||||
@api.constrains('x_fc_serial_ids', 'product_uom_qty')
|
||||
def _check_serial_count_against_qty(self):
|
||||
"""Block save when the operator has attached more serials than
|
||||
the line quantity. Under-count is allowed (some customers ship
|
||||
with serials only on a subset of parts).
|
||||
"""
|
||||
for line in self:
|
||||
if line.x_fc_serial_ids and line.product_uom_qty:
|
||||
n = len(line.x_fc_serial_ids)
|
||||
if n > int(line.product_uom_qty):
|
||||
raise ValidationError(_(
|
||||
'Line "%(part)s": %(n)s serials attached but only '
|
||||
'%(qty)s parts ordered. Either reduce the serial '
|
||||
'list, increase the quantity, or split the line.'
|
||||
) % {
|
||||
'part': (line.x_fc_part_catalog_id.display_name
|
||||
or line.product_id.display_name or ''),
|
||||
'n': n,
|
||||
'qty': int(line.product_uom_qty),
|
||||
})
|
||||
|
||||
|
||||
# ---- Customer references mirrored from parent sale.order ----------
|
||||
# Related (not stored) - display-only on the line list so shipping /
|
||||
# invoicing operators see the customer's job/PO ref per-line without
|
||||
# navigating up to the order header.
|
||||
x_fc_customer_job_number = fields.Char(
|
||||
related='order_id.x_fc_customer_job_number',
|
||||
string='Customer Job #',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_po_number = fields.Char(
|
||||
related='order_id.x_fc_po_number',
|
||||
string='Customer PO #',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
|
||||
x_fc_internal_deadline = fields.Date(
|
||||
related='order_id.x_fc_internal_deadline',
|
||||
string='Internal Deadline',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
related='order_id.x_fc_planned_start_date',
|
||||
string='Planned Start',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_internal_note = fields.Html(
|
||||
related='order_id.x_fc_internal_note',
|
||||
string='Internal Note',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_external_note = fields.Html(
|
||||
related='order_id.x_fc_external_note',
|
||||
string='External Note',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
related='order_id.x_fc_delivery_method',
|
||||
string='Delivery Method',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_ship_via = fields.Char(
|
||||
related='order_id.x_fc_ship_via',
|
||||
string='Ship Via',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_invoice_strategy = fields.Selection(
|
||||
related='order_id.x_fc_invoice_strategy',
|
||||
string='Invoice Strategy',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Express Orders backend helpers (Phase B - 2026-05-26)
|
||||
# ============================================================
|
||||
|
||||
def _fp_apply_express_overrides_to_job(self, job):
|
||||
"""Convert Express per-line flags into fp.job.node.override + step instructions.
|
||||
|
||||
Called from sale_order._fp_auto_create_job() immediately after Job.create
|
||||
(creates override rows; step instructions skipped if no steps yet), and
|
||||
again from fp.job.action_confirm() after _generate_steps_from_recipe()
|
||||
(override rows recreate identically; step instructions land this time).
|
||||
|
||||
Idempotent: pre-deletes prior masking/bake override rows on each call.
|
||||
|
||||
Algorithm:
|
||||
- x_fc_masking_enabled=False → opt out of masking + de_masking nodes
|
||||
- x_fc_bake_instructions empty → opt out of baking nodes
|
||||
- x_fc_bake_instructions non-empty → keep baking + write text to step.instructions
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not job or not job.recipe_id:
|
||||
return
|
||||
|
||||
recipe = job.recipe_id
|
||||
Override = self.env['fp.job.node.override'].sudo()
|
||||
|
||||
# Idempotency: clear prior masking/bake override rows on this job
|
||||
prior = Override.search([
|
||||
('job_id', '=', job.id),
|
||||
('node_id.default_kind', 'in', ('mask', 'demask', 'bake')),
|
||||
])
|
||||
if prior:
|
||||
prior.unlink()
|
||||
|
||||
msgs = []
|
||||
|
||||
# 1. Masking - opt out of masking + de_masking AS A PAIR
|
||||
if not self.x_fc_masking_enabled:
|
||||
nodes = recipe._fp_all_nodes_with_kind(('mask', 'demask'))
|
||||
for node in nodes:
|
||||
Override.create({
|
||||
'job_id': job.id,
|
||||
'node_id': node.id,
|
||||
'included': False,
|
||||
})
|
||||
if nodes:
|
||||
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
|
||||
elif self.x_fc_masking_attachment_ids:
|
||||
# Masking ON + Express reference file(s) attached → surface them on
|
||||
# the mask step so the operator sees what to mask. Lands on the
|
||||
# second call (after steps exist), same as bake below.
|
||||
mask_steps = job.step_ids.filtered(
|
||||
lambda s: s.recipe_node_id.default_kind == 'mask'
|
||||
)
|
||||
if mask_steps:
|
||||
mask_steps.sudo().write({
|
||||
'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)],
|
||||
})
|
||||
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
|
||||
% len(self.x_fc_masking_attachment_ids))
|
||||
|
||||
# 2. Bake - empty = opt out; non-empty = keep + write step.instructions
|
||||
bake_text = (self.x_fc_bake_instructions or '').strip()
|
||||
bake_nodes = recipe._fp_all_nodes_with_kind(('bake',))
|
||||
if not bake_text:
|
||||
for node in bake_nodes:
|
||||
Override.create({
|
||||
'job_id': job.id,
|
||||
'node_id': node.id,
|
||||
'included': False,
|
||||
})
|
||||
if bake_nodes:
|
||||
msgs.append(_('Baking steps opted out (per SO line)'))
|
||||
else:
|
||||
# Step instructions write only succeeds if steps exist. The
|
||||
# helper is called twice - first call (before action_confirm)
|
||||
# finds no steps and skips; second call (after step gen) lands.
|
||||
bake_steps = job.step_ids.filtered(
|
||||
lambda s: s.recipe_node_id.default_kind == 'bake'
|
||||
)
|
||||
if bake_steps:
|
||||
bake_steps.sudo().write({'instructions': bake_text})
|
||||
msgs.append(_('Bake step instructions set to: %s') % bake_text)
|
||||
|
||||
# 3. Audit chatter post on the job (only on the call that actually wrote)
|
||||
if msgs:
|
||||
job.sudo().message_post(body='\n'.join('• ' + m for m in msgs))
|
||||
|
||||
def action_open_serial_bulk_add(self):
|
||||
"""Open the existing fp.serial.bulk.add.wizard targeting this SO line.
|
||||
|
||||
Express Orders surfaces this as the inline '+ bulk' button on the
|
||||
Part cell's serial row (post-confirm). The wizard model and its
|
||||
action already handle both sale.order.line and fp.direct.order.line.
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env.ref(
|
||||
'fusion_plating_configurator.action_fp_serial_bulk_add_wizard'
|
||||
).read()[0]
|
||||
action['context'] = {
|
||||
'default_target_model': 'sale.order.line',
|
||||
'default_target_id': self.id,
|
||||
'default_qty_expected': int(self.product_uom_qty or 0),
|
||||
}
|
||||
return action
|
||||
|
||||
def action_open_part(self):
|
||||
"""Open the linked fp.part.catalog form in a modal."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_part_catalog_id.display_name,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'views': [[False, 'form']],
|
||||
'view_mode': 'form',
|
||||
'res_id': self.x_fc_part_catalog_id.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_upload_drawing(self):
|
||||
"""Attach a file (via context) to the line's part as a drawing.
|
||||
|
||||
Frontend calling pattern: read file picker → base64-encode →
|
||||
set context['fp_drawing_file'] + context['fp_drawing_filename'] →
|
||||
call this method. The drawing lives on the PART (not the line)
|
||||
so future orders for the same part reuse it.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
raise UserError(_('Pick or create a part on this line first.'))
|
||||
file_data = self.env.context.get('fp_drawing_file')
|
||||
filename = self.env.context.get('fp_drawing_filename', 'drawing.pdf')
|
||||
if not file_data:
|
||||
raise UserError(_('No file data received.'))
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'datas': file_data,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.x_fc_part_catalog_id.id,
|
||||
})
|
||||
self.x_fc_part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
self.x_fc_part_catalog_id.sudo().message_post(body=_(
|
||||
'Drawing "%(name)s" uploaded by %(user)s from line %(seq)s on SO %(so)s.'
|
||||
) % {
|
||||
'name': filename,
|
||||
'user': self.env.user.display_name,
|
||||
'seq': self.sequence or self.id,
|
||||
'so': self.order_id.name,
|
||||
})
|
||||
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
||||
@@ -1,33 +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>
|
||||
|
||||
<record id="group_fp_estimator" model="res.groups">
|
||||
<field name="name">[DEPRECATED] Estimator</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_plating.group_fusion_plating_supervisor'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_fp_shop_manager" model="res.groups">
|
||||
<field name="name">[DEPRECATED] Shop Manager</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[
|
||||
(4, ref('fusion_plating.group_fusion_plating_manager')),
|
||||
(4, ref('group_fp_estimator')),
|
||||
]"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Backward-compat: new Sales Rep role implies old Estimator group so existing ACLs still resolve.
|
||||
Lives here (not in fusion_plating core) to avoid fresh-install forward-ref. -->
|
||||
<record id="fusion_plating.group_fp_sales_rep" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_plating_configurator.group_fp_estimator'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,52 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_pricing_surcharge_operator,fp.pricing.complexity.surcharge.operator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_pricing_surcharge_estimator,fp.pricing.complexity.surcharge.estimator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,model_fp_pricing_complexity_surcharge,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_fp_direct_order_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_direct_order_line_estimator,fp.direct.order.line.estimator,model_fp_direct_order_line,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_direct_order_line_manager,fp.direct.order.line.manager,model_fp_direct_order_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_add_from_so_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_quote_promote_wizard_estimator,fp.quote.promote.wizard.estimator,model_fp_quote_promote_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_quote_promote_wizard_manager,fp.quote.promote.wizard.manager,model_fp_quote_promote_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
|
||||
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_sale_assembly_line_user,fp.sale.assembly.line.user,model_fp_sale_assembly_line,base.group_user,1,0,0,0
|
||||
access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_sale_assembly_line,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fp_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.group_fp_sales_rep,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_fp_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_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0
|
||||
access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fp_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_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
|
||||
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_part_description_version_user,fp.part.description.version.user,model_fp_part_description_version,base.group_user,1,0,0,0
|
||||
access_fp_part_description_version_estimator,fp.part.description.version.estimator,model_fp_part_description_version,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_part_description_version_manager,fp.part.description.version.manager,model_fp_part_description_version,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_additional_charge_type_user,fp.additional.charge.type.user,model_fp_additional_charge_type,base.group_user,1,0,0,0
|
||||
access_fp_additional_charge_type_estimator,fp.additional.charge.type.estimator,model_fp_additional_charge_type,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_additional_charge_type_manager,fp.additional.charge.type.manager,model_fp_additional_charge_type,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 75 KiB |
@@ -1,13 +0,0 @@
|
||||
importScripts ('occt-import-js.js');
|
||||
|
||||
onmessage = async function (ev)
|
||||
{
|
||||
let modulOverrides = {
|
||||
locateFile: function (path) {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
let occt = await occtimportjs (modulOverrides);
|
||||
let result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);
|
||||
postMessage (result);
|
||||
};
|
||||
@@ -1,237 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>3D Part Viewer</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;background:#f0f2f5}
|
||||
#viewer-container{width:100%;height:100%}
|
||||
#loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#6c757d;z-index:100}
|
||||
#loading .spinner{width:44px;height:44px;border:3px solid #dee2e6;border-top-color:#0d6efd;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
#error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:20px 28px;color:#664d03;max-width:80%;text-align:center;font-size:13px;z-index:100;display:none}
|
||||
#format-badge{position:absolute;top:10px;right:10px;font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;z-index:100;backdrop-filter:blur(4px)}
|
||||
.fmt-step{background:rgba(33,150,243,.15);color:#1565c0}
|
||||
.fmt-iges{background:rgba(156,39,176,.15);color:#7b1fa2}
|
||||
.fmt-stl{background:rgba(76,175,80,.15);color:#2e7d32}
|
||||
.fmt-brep{background:rgba(255,152,0,.15);color:#e65100}
|
||||
.fmt-other{background:rgba(158,158,158,.15);color:#616161}
|
||||
#toolbar{position:absolute;top:10px;left:10px;display:flex;gap:4px;z-index:100;flex-wrap:wrap;background:rgba(255,255,255,.85);padding:4px;border-radius:6px;backdrop-filter:blur(4px);box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||||
#toolbar button{background:#fff;border:1px solid #ced4da;border-radius:4px;padding:4px 8px;font-size:11px;font-weight:500;cursor:pointer;color:#495057;transition:all .15s;min-width:40px}
|
||||
#toolbar button:hover{background:#0d6efd;color:#fff;border-color:#0d6efd}
|
||||
#toolbar .btn-divider{width:1px;background:#dee2e6;margin:2px 4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer-container"></div>
|
||||
<div id="toolbar">
|
||||
<button onclick="setView('top')" title="Top view">Top</button>
|
||||
<button onclick="setView('bottom')" title="Bottom view">Btm</button>
|
||||
<button onclick="setView('front')" title="Front view">Front</button>
|
||||
<button onclick="setView('back')" title="Back view">Back</button>
|
||||
<button onclick="setView('left')" title="Left view">Left</button>
|
||||
<button onclick="setView('right')" title="Right view">Right</button>
|
||||
<button onclick="setView('iso')" title="Isometric view">Iso</button>
|
||||
<span class="btn-divider"></span>
|
||||
<button onclick="fitToView()" title="Fit to view">Fit</button>
|
||||
<button onclick="takeScreenshot()" title="Take screenshot (PNG)">📷</button>
|
||||
</div>
|
||||
<div id="format-badge"></div>
|
||||
<div id="loading"><div class="spinner"></div><div id="loading-msg">Loading 3D model...</div></div>
|
||||
<div id="error"></div>
|
||||
|
||||
<script src="/fusion_plating_configurator/static/lib/o3dv/o3dv.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById('viewer-container');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const loadingMsg = document.getElementById('loading-msg');
|
||||
const errorEl = document.getElementById('error');
|
||||
const fmtBadge = document.getElementById('format-badge');
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const attachmentId = params.get('id');
|
||||
const fileName = params.get('name') || 'model.stl';
|
||||
|
||||
function detectFormat(name) {
|
||||
if (!name) return 'other';
|
||||
const n = name.toLowerCase();
|
||||
if (n.match(/\.(step|stp)$/)) return 'step';
|
||||
if (n.match(/\.(iges|igs)$/)) return 'iges';
|
||||
if (n.match(/\.(brep|brp)$/)) return 'brep';
|
||||
if (n.match(/\.stl$/)) return 'stl';
|
||||
if (n.match(/\.(obj)$/)) return 'other';
|
||||
if (n.match(/\.(gltf|glb)$/)) return 'other';
|
||||
if (n.match(/\.(3ds|fbx|dae|3mf|ply|off|wrl|3dm)$/)) return 'other';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function showFormat(fmt) {
|
||||
fmtBadge.className = 'fmt-' + fmt;
|
||||
fmtBadge.textContent = fmt.toUpperCase();
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (!attachmentId) {
|
||||
showError('No model specified (missing ?id= parameter)');
|
||||
return;
|
||||
}
|
||||
|
||||
showFormat(detectFormat(fileName));
|
||||
|
||||
// Initialize the embedded viewer
|
||||
// Note: v0.18.0 loads WASM (occt-import-js) from CDN automatically
|
||||
const viewer = new OV.EmbeddedViewer(container, {
|
||||
backgroundColor: new OV.RGBAColor(240, 242, 245, 255),
|
||||
defaultColor: new OV.RGBColor(33, 150, 243),
|
||||
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
|
||||
});
|
||||
|
||||
// Fetch the file ourselves (with session credentials) then load as blob
|
||||
loadingMsg.textContent = 'Downloading ' + fileName + '...';
|
||||
const modelUrl = '/fp/3d-model/' + attachmentId + '/' + encodeURIComponent(fileName);
|
||||
|
||||
fetch(modelUrl, { credentials: 'same-origin' })
|
||||
.then(function(resp) {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
|
||||
return resp.arrayBuffer();
|
||||
})
|
||||
.then(function(buffer) {
|
||||
loadingMsg.textContent = 'Parsing ' + fileName + '...';
|
||||
// Create a File object so O3DV can detect format from the name
|
||||
var file = new File([buffer], fileName, { type: 'application/octet-stream' });
|
||||
viewer.LoadModelFromFileList([file]);
|
||||
|
||||
// Poll for completion
|
||||
var checkCount = 0;
|
||||
var checkInterval = setInterval(function() {
|
||||
checkCount++;
|
||||
try {
|
||||
var model = viewer.GetModel();
|
||||
if (model && model.MeshCount() > 0) {
|
||||
loadingEl.style.display = 'none';
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
} catch(e) {}
|
||||
if (checkCount > 600) {
|
||||
clearInterval(checkInterval);
|
||||
if (loadingEl.style.display !== 'none') {
|
||||
showError('Timeout parsing model. STEP files may take a minute on first load (WASM engine init).');
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
})
|
||||
.catch(function(err) {
|
||||
showError('Failed to load model: ' + err.message);
|
||||
});
|
||||
|
||||
// ---- View preset functions (Top/Front/Side/Iso) ----
|
||||
// Online3DViewer's internal viewer exposes a Camera object we can manipulate.
|
||||
window.setView = function(view) {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (!v) return;
|
||||
const camera = v.GetCamera();
|
||||
if (!camera) return;
|
||||
// Compute distance from current camera to keep zoom roughly consistent
|
||||
const eye = camera.eye;
|
||||
const center = camera.center;
|
||||
const dx = eye.x - center.x, dy = eye.y - center.y, dz = eye.z - center.z;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 100;
|
||||
let newEye, newUp;
|
||||
switch (view) {
|
||||
case 'top':
|
||||
newEye = new OV.Coord3D(center.x, center.y, center.z + dist);
|
||||
newUp = new OV.Coord3D(0, 1, 0);
|
||||
break;
|
||||
case 'bottom':
|
||||
newEye = new OV.Coord3D(center.x, center.y, center.z - dist);
|
||||
newUp = new OV.Coord3D(0, 1, 0);
|
||||
break;
|
||||
case 'front':
|
||||
newEye = new OV.Coord3D(center.x, center.y - dist, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'back':
|
||||
newEye = new OV.Coord3D(center.x, center.y + dist, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'left':
|
||||
newEye = new OV.Coord3D(center.x - dist, center.y, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'right':
|
||||
newEye = new OV.Coord3D(center.x + dist, center.y, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'iso':
|
||||
default:
|
||||
const d = dist / Math.sqrt(3);
|
||||
newEye = new OV.Coord3D(center.x + d, center.y - d, center.z + d);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
}
|
||||
const newCam = new OV.Camera(newEye, center, newUp, camera.fov);
|
||||
v.SetCamera(newCam);
|
||||
v.Render();
|
||||
} catch(e) {
|
||||
console.warn('setView failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.fitToView = function() {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (v && v.FitSphereToWindow) {
|
||||
// FitSphereToWindow uses the model's bounding sphere
|
||||
v.FitSphereToWindow(v.GetBoundingSphere(() => true), false);
|
||||
v.Render();
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('fitToView failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.takeScreenshot = function() {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (!v) return;
|
||||
// Get the renderer's canvas and convert to PNG
|
||||
const canvas = v.GetCanvas ? v.GetCanvas() : null;
|
||||
if (!canvas) {
|
||||
// Fallback: find canvas inside container
|
||||
const c = container.querySelector('canvas');
|
||||
if (!c) return;
|
||||
downloadCanvas(c);
|
||||
return;
|
||||
}
|
||||
downloadCanvas(canvas);
|
||||
} catch(e) {
|
||||
console.warn('screenshot failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
function downloadCanvas(canvas) {
|
||||
canvas.toBlob(function(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
a.download = (fileName.replace(/\.[^.]+$/, '') || 'model') + '-' + stamp + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 'image/png');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,162 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
// Express Orders - stacked DWG / OPEN action buttons widget (2026-05-26)
|
||||
//
|
||||
// Renders BOTH the upload-drawing and open-part buttons stacked
|
||||
// vertically in one list cell, saving horizontal width. The widget
|
||||
// binds to part_catalog_id (read-only - picker is owned by the
|
||||
// FpExpressPartCell widget on the same field; this widget is
|
||||
// declared on a separate dummy column).
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
|
||||
export class FpExpressActionBtns extends Component {
|
||||
static template = "fusion_plating_configurator.FpExpressActionBtns";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
}
|
||||
|
||||
get hasPart() {
|
||||
return !!this.props.record.data.part_catalog_id;
|
||||
}
|
||||
|
||||
async _ensureSaved() {
|
||||
if (!this.props.record.resId) {
|
||||
await this.props.record.save();
|
||||
}
|
||||
return !!this.props.record.resId;
|
||||
}
|
||||
|
||||
async onUpload(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!this.hasPart) {
|
||||
this.notification.add("Pick a part first.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
// Trigger native file picker via hidden input
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".pdf,.dwg,.dxf,.png,.jpg,.jpeg,application/pdf,image/*";
|
||||
input.onchange = async () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result.split(",")[1];
|
||||
if (!(await this._ensureSaved())) return;
|
||||
try {
|
||||
await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_upload_drawing",
|
||||
[[this.props.record.resId]],
|
||||
{
|
||||
context: {
|
||||
fp_drawing_file: base64,
|
||||
fp_drawing_filename: file.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
this.notification.add(`Drawing "${file.name}" uploaded.`, { type: "success" });
|
||||
await this.props.record.load();
|
||||
} catch (e) {
|
||||
this.notification.add(`Upload failed: ${e.message || e}`, { type: "danger" });
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async onOpen(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!this.hasPart) {
|
||||
this.notification.add("Pick a part first.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
if (!(await this._ensureSaved())) return;
|
||||
const action = await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_open_part",
|
||||
[[this.props.record.resId]],
|
||||
);
|
||||
if (action) await this.action.doAction(action);
|
||||
}
|
||||
|
||||
// ---- Masking reference upload (2026-06-03) ----
|
||||
// Visible only when masking is toggled ON for this line. Accepts MULTIPLE
|
||||
// image/PDF files; each is attached to the line and (on order confirm)
|
||||
// copied onto the job's masking step so the operator sees it in the
|
||||
// workstation. Mirrors onUpload but loops over the file list.
|
||||
get maskingEnabled() {
|
||||
return !!this.props.record.data.masking_enabled;
|
||||
}
|
||||
|
||||
get maskCount() {
|
||||
const m = this.props.record.data.masking_attachment_ids;
|
||||
return (m && m.count) || 0;
|
||||
}
|
||||
|
||||
async onUploadMask(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = ".pdf,.png,.jpg,.jpeg,application/pdf,image/*";
|
||||
input.onchange = async () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (!files.length) return;
|
||||
if (!(await this._ensureSaved())) return;
|
||||
let ok = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
const base64 = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_upload_masking_ref",
|
||||
[[this.props.record.resId]],
|
||||
{
|
||||
context: {
|
||||
fp_masking_file: base64,
|
||||
fp_masking_filename: file.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
ok += 1;
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
`Masking upload failed for "${file.name}": ${e.message || e}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
this.notification.add(`${ok} masking reference(s) added.`, { type: "success" });
|
||||
await this.props.record.load();
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
export const fpExpressActionBtns = {
|
||||
component: FpExpressActionBtns,
|
||||
supportedTypes: ["many2one"],
|
||||
};
|
||||
|
||||
registry.category("fields").add("fp_express_action_btns", fpExpressActionBtns);
|
||||
@@ -1,86 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
// Express Orders - Bake pill widget (2026-05-26)
|
||||
//
|
||||
// Renders the `bake_instructions` Text field as a coloured pill:
|
||||
// - Non-empty → amber pill showing the text ("350°F × 4 hr")
|
||||
// - Empty → italic muted pill "no bake"
|
||||
//
|
||||
// Click → swaps to inline edit (small textarea + Save / Clear / Cancel
|
||||
// buttons). Save persists to the field; Clear empties it (so the
|
||||
// override flow at SO confirm will opt out of baking nodes).
|
||||
|
||||
import { Component, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
|
||||
export class FpExpressBakePill extends Component {
|
||||
static template = "fusion_plating_configurator.FpExpressBakePill";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
editing: false,
|
||||
draft: this.value,
|
||||
});
|
||||
this.textareaRef = useRef("textarea");
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.props.record.data[this.props.name] || "";
|
||||
}
|
||||
|
||||
get hasBake() {
|
||||
return !!(this.value && this.value.trim());
|
||||
}
|
||||
|
||||
get pillLabel() {
|
||||
return this.hasBake ? this.value : "no bake";
|
||||
}
|
||||
|
||||
openEditor(ev) {
|
||||
ev?.stopPropagation();
|
||||
this.state.draft = this.value;
|
||||
this.state.editing = true;
|
||||
// Focus the textarea on next tick
|
||||
Promise.resolve().then(() => {
|
||||
if (this.textareaRef.el) {
|
||||
this.textareaRef.el.focus();
|
||||
this.textareaRef.el.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async save() {
|
||||
const v = (this.state.draft || "").trim();
|
||||
await this.props.record.update({ [this.props.name]: v || false });
|
||||
this.state.editing = false;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.props.record.update({ [this.props.name]: false });
|
||||
this.state.draft = "";
|
||||
this.state.editing = false;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.state.draft = this.value;
|
||||
this.state.editing = false;
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === "Escape") {
|
||||
this.cancel();
|
||||
} else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fpExpressBakePill = {
|
||||
component: FpExpressBakePill,
|
||||
supportedTypes: ["text", "char"],
|
||||
};
|
||||
|
||||
registry.category("fields").add("fp_express_bake_pill", fpExpressBakePill);
|
||||
@@ -1,104 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
// Express Orders - multi-row Part cell widget (2026-05-26, revised 2026-05-27)
|
||||
//
|
||||
// Row 1: Many2OneField picker (shows part_catalog_id.display_name -
|
||||
// which is just the part_number when fp_express_part_picker
|
||||
// context flag is set).
|
||||
// Row 2: editable input bound to part_name_editable (writable compute
|
||||
// with inverse that writes part.name on the linked catalog
|
||||
// record - see fp_direct_order_line.py).
|
||||
// Row 3: editable input bound to serials_text (parses comma-separated
|
||||
// names, finds-or-creates fp.serial records, updates the line's
|
||||
// serial_ids M2M) + small "+ bulk" button that opens the existing
|
||||
// fp.serial.bulk.add.wizard for paste-list / range-fill entry.
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Many2OneField, many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
|
||||
export class FpExpressPartCell extends Component {
|
||||
static template = "fusion_plating_configurator.FpExpressPartCell";
|
||||
static components = { Many2OneField };
|
||||
static props = { ...Many2OneField.props };
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
}
|
||||
|
||||
get hasPart() {
|
||||
return !!this.props.record.data.part_catalog_id;
|
||||
}
|
||||
|
||||
get partNumber() {
|
||||
return this.props.record.data.part_number_display || "";
|
||||
}
|
||||
|
||||
get partRev() {
|
||||
return this.props.record.data.part_revision_display || "";
|
||||
}
|
||||
|
||||
get partName() {
|
||||
return this.props.record.data.part_name_editable || "";
|
||||
}
|
||||
|
||||
get serialsText() {
|
||||
return this.props.record.data.serials_text || "";
|
||||
}
|
||||
|
||||
async onNameChange(ev) {
|
||||
const newName = (ev.target.value || "").trim();
|
||||
if (newName === this.partName.trim()) return;
|
||||
await this.props.record.update({ part_name_editable: newName });
|
||||
}
|
||||
|
||||
async onSerialsChange(ev) {
|
||||
const newVal = (ev.target.value || "").trim();
|
||||
if (newVal === this.serialsText.trim()) return;
|
||||
await this.props.record.update({ serials_text: newVal });
|
||||
}
|
||||
|
||||
async _ensureSaved() {
|
||||
if (!this.props.record.resId) {
|
||||
await this.props.record.save();
|
||||
}
|
||||
return !!this.props.record.resId;
|
||||
}
|
||||
|
||||
async onBulkClick(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!this.hasPart) {
|
||||
this.notification.add(
|
||||
"Pick a part first, then click + bulk to add serials.",
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!(await this._ensureSaved())) {
|
||||
this.notification.add(
|
||||
"Save the order first before bulk-adding serials.",
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const action = await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_open_serial_bulk_add",
|
||||
[[this.props.record.resId]],
|
||||
);
|
||||
await this.action.doAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
export const fpExpressPartCell = {
|
||||
...many2OneField,
|
||||
component: FpExpressPartCell,
|
||||
supportedTypes: ["many2one"],
|
||||
};
|
||||
|
||||
registry.category("fields").add("fp_express_part_cell", fpExpressPartCell);
|
||||
@@ -1,118 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- 3D CAD Viewer (iframe wrapper)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Simple OWL field widget that embeds the standalone 3D viewer page
|
||||
// in an iframe. The viewer page uses Online3DViewer (o3dv) which
|
||||
// supports STEP, IGES, BREP, STL, OBJ, glTF, and 20+ more formats.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class Fp3dViewer extends Component {
|
||||
static template = "fusion_plating_configurator.Fp3dViewer";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({ hasAttachment: false, iframeSrc: "" });
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
get attachmentId() {
|
||||
const v = this.rawValue;
|
||||
if (!v) return 0;
|
||||
if (Array.isArray(v)) return v[0] || 0;
|
||||
if (typeof v === "object" && v.id) return v.id;
|
||||
return typeof v === "number" ? v : 0;
|
||||
}
|
||||
|
||||
get attachmentName() {
|
||||
const v = this.rawValue;
|
||||
if (!v) return "";
|
||||
if (Array.isArray(v)) return v[1] || "";
|
||||
if (typeof v === "object" && v.display_name) return v.display_name;
|
||||
return "";
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
const aid = this.attachmentId;
|
||||
this.state.hasAttachment = !!aid;
|
||||
if (aid) {
|
||||
const name = encodeURIComponent(this.attachmentName);
|
||||
this.state.iframeSrc = `/fp/3d-viewer?id=${aid}&name=${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
onPatched() {
|
||||
this._updateState();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fp_3d_preview", {
|
||||
component: Fp3dViewer,
|
||||
supportedTypes: ["many2one"],
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// 3D Viewer Dialog component (full-screen embedded viewer)
|
||||
// =============================================================================
|
||||
export class Fp3dViewerDialog extends Component {
|
||||
static template = "fusion_plating_configurator.Fp3dViewerDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
attachmentId: Number,
|
||||
name: { type: String, optional: true },
|
||||
close: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ isMaximized: true });
|
||||
}
|
||||
|
||||
get iframeSrc() {
|
||||
const name = encodeURIComponent(this.props.name || "");
|
||||
return `/fp/3d-viewer?id=${this.props.attachmentId}&name=${name}`;
|
||||
}
|
||||
|
||||
get dialogSize() {
|
||||
return this.state.isMaximized ? "fullscreen" : "xl";
|
||||
}
|
||||
|
||||
get frameStyle() {
|
||||
if (this.state.isMaximized) {
|
||||
return "height: calc(98vh - 100px) !important;";
|
||||
}
|
||||
return "height: calc(85vh - 100px) !important;";
|
||||
}
|
||||
|
||||
toggleSize() {
|
||||
this.state.isMaximized = !this.state.isMaximized;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("dialog").add("Fp3dViewerDialog", Fp3dViewerDialog);
|
||||
|
||||
|
||||
// Client action handler - opens the 3D viewer in a dialog within the same window.
|
||||
// Triggered by Python returning:
|
||||
// { type: 'ir.actions.client', tag: 'fp_3d_viewer_open',
|
||||
// params: { attachment_id: N, name: "..." } }
|
||||
function fp3dViewerOpenAction(env, action) {
|
||||
const params = action.params || {};
|
||||
if (!params.attachment_id) return Promise.resolve();
|
||||
env.services.dialog.add(Fp3dViewerDialog, {
|
||||
attachmentId: params.attachment_id,
|
||||
name: params.name || "",
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_3d_viewer_open", fp3dViewerOpenAction);
|
||||
@@ -1,81 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- PDF Drawing Preview Widget
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
//
|
||||
// Custom many2many_binary widget that opens PDFs in the fusion_pdf_preview
|
||||
// dialog instead of downloading them.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import {
|
||||
Many2ManyBinaryField,
|
||||
many2ManyBinaryField,
|
||||
} from "@web/views/fields/many2many_binary/many2many_binary_field";
|
||||
|
||||
export class FpPdfPreviewBinary extends Many2ManyBinaryField {
|
||||
static template = "fusion_plating_configurator.FpPdfPreviewBinary";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
onFileClick(ev, file) {
|
||||
const isPdf = (file.mimetype === "application/pdf") ||
|
||||
(file.name || "").toLowerCase().endsWith(".pdf");
|
||||
const dialogs = registry.category("dialog");
|
||||
|
||||
if (isPdf && dialogs.contains("PDFViewerDialog")) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
|
||||
const url = `/web/content/${file.id}?download=false`;
|
||||
this.dialogService.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: file.name || "Drawing",
|
||||
reportName: "",
|
||||
recordIds: "",
|
||||
modelName: "ir.attachment",
|
||||
});
|
||||
}
|
||||
// For non-PDF or when preview not available, default browser behavior
|
||||
// (the <a href> with download attribute) kicks in because we don't
|
||||
// prevent default.
|
||||
}
|
||||
}
|
||||
|
||||
export const fpPdfPreviewBinary = {
|
||||
...many2ManyBinaryField,
|
||||
component: FpPdfPreviewBinary,
|
||||
};
|
||||
|
||||
registry.category("fields").add("fp_pdf_preview_binary", fpPdfPreviewBinary);
|
||||
|
||||
|
||||
// Client action handler: open a PDF attachment in the fusion_pdf_preview dialog.
|
||||
// Triggered by Python methods returning:
|
||||
// { type: 'ir.actions.client', tag: 'fp_pdf_preview_open',
|
||||
// params: { attachment_id: N, title: "..." } }
|
||||
function fpPdfPreviewOpenAction(env, action) {
|
||||
const params = action.params || {};
|
||||
const attId = params.attachment_id;
|
||||
if (!attId) return Promise.resolve();
|
||||
const dialogs = registry.category("dialog");
|
||||
const PDFViewerDialog = dialogs.contains("PDFViewerDialog") ? dialogs.get("PDFViewerDialog") : null;
|
||||
if (!PDFViewerDialog) {
|
||||
window.open(`/web/content/${attId}?download=false`, '_blank');
|
||||
return Promise.resolve();
|
||||
}
|
||||
const url = `/web/content/${attId}?download=false`;
|
||||
env.services.dialog.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: params.title || 'Document',
|
||||
reportName: '',
|
||||
recordIds: '',
|
||||
modelName: 'ir.attachment',
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_pdf_preview_open", fpPdfPreviewOpenAction);
|
||||
@@ -1,244 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating - Part-Scoped Process Composer (OWL client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Sub 9 - multi-variant Composer. Each part can carry several recipe trees
|
||||
// (e.g. "Standard ENP", "Selective Masking", "Rework"). One is the default;
|
||||
// estimators may pick a non-default variant on a per-order basis.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL: static template + static props = ["*"]
|
||||
// * RPC: standalone rpc() from @web/core/network/rpc
|
||||
// * Registered under registry.category("actions") → "fp_part_process_composer"
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FpPartProcessComposer extends Component {
|
||||
static template = "fusion_plating_configurator.FpPartProcessComposer";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
|
||||
const params = (this.props.action && this.props.action.params) || {};
|
||||
this.partId = params.part_id || null;
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: null,
|
||||
part: null,
|
||||
hasTree: false,
|
||||
rootId: null,
|
||||
variants: [],
|
||||
templates: [],
|
||||
selectedTemplateId: null,
|
||||
newVariantLabel: "",
|
||||
busy: false,
|
||||
});
|
||||
|
||||
onMounted(() => this.refresh());
|
||||
}
|
||||
|
||||
// ---- Data loading -------------------------------------------------------
|
||||
|
||||
async refresh() {
|
||||
if (!this.partId) {
|
||||
this.state.error = "No part specified.";
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
try {
|
||||
const [stateRes, tplRes] = await Promise.all([
|
||||
rpc("/fp/part/composer/state", { part_id: this.partId }),
|
||||
rpc("/fp/part/composer/templates", {}),
|
||||
]);
|
||||
if (!stateRes.ok) throw new Error(stateRes.error || "Failed to load part state.");
|
||||
if (!tplRes.ok) throw new Error(tplRes.error || "Failed to load templates.");
|
||||
|
||||
this.state.part = stateRes.part;
|
||||
this.state.hasTree = stateRes.has_tree;
|
||||
this.state.rootId = stateRes.root_id || null;
|
||||
this.state.variants = stateRes.variants || [];
|
||||
this.state.templates = tplRes.templates || [];
|
||||
|
||||
if (this.state.templates.length > 0 && !this.state.selectedTemplateId) {
|
||||
this.state.selectedTemplateId = this.state.templates[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.error = err.message || String(err);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Handlers -----------------------------------------------------------
|
||||
|
||||
onSelectTemplate(ev) {
|
||||
this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null;
|
||||
}
|
||||
|
||||
onNewLabelInput(ev) {
|
||||
this.state.newVariantLabel = ev.target.value || "";
|
||||
}
|
||||
|
||||
async onAddVariantFromTemplate() {
|
||||
if (!this.state.selectedTemplateId) {
|
||||
this.notification.add("Pick a template first.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
const label = (this.state.newVariantLabel || "").trim()
|
||||
|| (this.state.templates.find(t => t.id === this.state.selectedTemplateId)?.name)
|
||||
|| "Variant";
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/load_template", {
|
||||
part_id: this.partId,
|
||||
template_id: this.state.selectedTemplateId,
|
||||
variant_label: label,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Add variant failed.");
|
||||
this.notification.add(
|
||||
`Variant "${label}" added (${res.node_count} nodes).`,
|
||||
{ type: "success" },
|
||||
);
|
||||
this.state.newVariantLabel = "";
|
||||
await this.refresh();
|
||||
this.openRecipeEditor(res.root_id);
|
||||
});
|
||||
}
|
||||
|
||||
async onDuplicateVariant(variantId) {
|
||||
const src = this.state.variants.find(v => v.id === variantId);
|
||||
const proposed = window.prompt(
|
||||
"Name for the duplicated variant:",
|
||||
(src?.label || "Variant") + " (copy)",
|
||||
);
|
||||
if (!proposed) return;
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/duplicate_variant", {
|
||||
part_id: this.partId,
|
||||
source_variant_id: variantId,
|
||||
variant_label: proposed,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Duplicate failed.");
|
||||
this.notification.add(`Variant "${proposed}" created.`, { type: "success" });
|
||||
await this.refresh();
|
||||
this.openRecipeEditor(res.root_id);
|
||||
});
|
||||
}
|
||||
|
||||
async onRenameVariant(variantId) {
|
||||
const v = this.state.variants.find(x => x.id === variantId);
|
||||
const proposed = window.prompt("New label:", v?.label || "");
|
||||
if (!proposed) return;
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/rename_variant", {
|
||||
part_id: this.partId,
|
||||
variant_id: variantId,
|
||||
variant_label: proposed,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Rename failed.");
|
||||
await this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async onSetDefaultVariant(variantId) {
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/set_default_variant", {
|
||||
part_id: this.partId,
|
||||
variant_id: variantId,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Set default failed.");
|
||||
this.notification.add("Default variant updated.", { type: "success" });
|
||||
await this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async onDeleteVariant(variantId) {
|
||||
const v = this.state.variants.find(x => x.id === variantId);
|
||||
if (!window.confirm(`Delete variant "${v?.label || ""}"? This removes its tree.`)) return;
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/delete_variant", {
|
||||
part_id: this.partId,
|
||||
variant_id: variantId,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Delete failed.");
|
||||
this.notification.add("Variant deleted.", { type: "success" });
|
||||
await this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async _busy(fn) {
|
||||
this.state.busy = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
} finally {
|
||||
this.state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
openRecipeEditor(rootId) {
|
||||
// Tree editor - the original drag-and-drop hierarchy view.
|
||||
const id = rootId || this.state.rootId;
|
||||
if (!id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_recipe_tree_editor",
|
||||
name: `Process Editor - ${(this.state.part && this.state.part.display) || ""}`,
|
||||
context: { recipe_id: id, part_id: this.partId },
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
openRecipeSimpleEditor(rootId) {
|
||||
// Simple Recipe Editor (Sub 12a) - flat 2-pane drag-drop layout.
|
||||
// Lives alongside the tree editor; the user picks per-variant
|
||||
// which one to open. Both edit the same underlying tree, so
|
||||
// changes flow back-and-forth without conflict.
|
||||
const id = rootId || this.state.rootId;
|
||||
if (!id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_simple_recipe_editor",
|
||||
name: `Process Editor (Simple) - ${(this.state.part && this.state.part.display) || ""}`,
|
||||
context: { recipe_id: id, part_id: this.partId },
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
backToPart() {
|
||||
// Pop this composer off the action stack and restore the
|
||||
// previous controller (the part form the user came from).
|
||||
// Preserves the full breadcrumb trail - clearBreadcrumbs: true
|
||||
// would wipe parent crumbs (e.g. "Parts > 2144A6201-105").
|
||||
// Falls back to the part form only when restore() throws (e.g.
|
||||
// composer opened directly via URL with no prior crumb).
|
||||
try {
|
||||
this.action.restore();
|
||||
return;
|
||||
} catch (e) {
|
||||
// No prior controller - fall through to the part form.
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
res_id: this.partId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
}, {
|
||||
clearBreadcrumbs: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_part_process_composer", FpPartProcessComposer);
|
||||
@@ -1,80 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- Inline PDF Preview field widget
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
//
|
||||
// Field widget for Many2one(ir.attachment) fields that embeds the
|
||||
// PDF.js viewer inline at a fixed height (one page at a time).
|
||||
// A "Full Screen" button below opens the fusion_pdf_preview dialog.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
export class FpPdfInlinePreview extends Component {
|
||||
static template = "fusion_plating_configurator.FpPdfInlinePreview";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
this.state = useState({ hasAttachment: false, iframeSrc: "", attId: 0, name: "" });
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
const v = this.rawValue;
|
||||
let attId = 0;
|
||||
let name = "";
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
attId = v[0] || 0;
|
||||
name = v[1] || "";
|
||||
} else if (typeof v === "object" && v.id) {
|
||||
attId = v.id;
|
||||
name = v.display_name || "";
|
||||
} else if (typeof v === "number") {
|
||||
attId = v;
|
||||
}
|
||||
}
|
||||
this.state.hasAttachment = !!attId;
|
||||
this.state.attId = attId;
|
||||
this.state.name = name;
|
||||
if (attId) {
|
||||
const fileUrl = `/web/content/${attId}?download=false`;
|
||||
// PDF.js URL params: zoom=page-fit, no thumbs sidebar, single-page mode
|
||||
this.state.iframeSrc =
|
||||
`/fusion_pdf_preview/static/lib/pdfjs/web/viewer.html` +
|
||||
`?file=${encodeURIComponent(fileUrl)}` +
|
||||
`#zoom=page-fit&pagemode=none&scrollmode=3`;
|
||||
}
|
||||
}
|
||||
|
||||
onPatched() {
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
openFullScreen() {
|
||||
if (!this.state.attId) return;
|
||||
const dialogs = registry.category("dialog");
|
||||
if (!dialogs.contains("PDFViewerDialog")) return;
|
||||
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
|
||||
const url = `/web/content/${this.state.attId}?download=false`;
|
||||
this.dialogService.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: this.state.name || "Drawing",
|
||||
reportName: "",
|
||||
recordIds: "",
|
||||
modelName: "ir.attachment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fp_pdf_inline_preview", {
|
||||
component: FpPdfInlinePreview,
|
||||
supportedTypes: ["many2one"],
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
// Express Orders - colour tokens (C3 / 2026-05-26)
|
||||
//
|
||||
// Per the Odoo-Modules CLAUDE.md "Dark Mode" rule: branch on
|
||||
// $o-webclient-color-scheme at SCSS compile time. Odoo compiles this
|
||||
// SCSS into BOTH web.assets_backend (light) and web.assets_web_dark
|
||||
// (dark); the @if below makes each bundle pick the right hex.
|
||||
//
|
||||
// Tokens are wrapped in CSS custom properties so a downstream module
|
||||
// can override per-shop branding without recompiling - e.g.
|
||||
// --xpr-accent: #d4af37; /* gold for premium plating shops */
|
||||
// on a global :root rule.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---- Light defaults ----
|
||||
$_xpr-page-hex: #f3f4f6;
|
||||
$_xpr-card-hex: #ffffff;
|
||||
$_xpr-card-soft-hex: #fafafa;
|
||||
$_xpr-row-hover-hex: #fafafa;
|
||||
$_xpr-cell-focus-hex: #fef9e7;
|
||||
$_xpr-table-head-hex: #f9fafb;
|
||||
$_xpr-section-bg-hex: #f9fafb;
|
||||
$_xpr-text-hex: #1f2937;
|
||||
$_xpr-text-muted-hex: #6b7280;
|
||||
$_xpr-text-dim-hex: #9ca3af;
|
||||
$_xpr-border-hex: #e5e7eb;
|
||||
$_xpr-border-strong-hex: #d1d5db;
|
||||
$_xpr-border-table-hex: #e5e7eb;
|
||||
$_xpr-accent-hex: #714b67;
|
||||
$_xpr-accent-hover-hex: #875a7b;
|
||||
$_xpr-accent-bg-hex: #faf5f8;
|
||||
$_xpr-accent-tint-hex: #f1e6ee; // a touch deeper than accent-bg, for visible washes
|
||||
$_xpr-bake-bg-hex: #fff7ed;
|
||||
$_xpr-bake-text-hex: #9a3412;
|
||||
$_xpr-bake-border-hex: #fdba74;
|
||||
$_xpr-good-hex: #059669;
|
||||
$_xpr-bad-hex: #dc2626;
|
||||
|
||||
// ---- Dark overrides (compile-time branch) ----
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_xpr-page-hex: #1a1d21 !global;
|
||||
$_xpr-card-hex: #22262d !global;
|
||||
$_xpr-card-soft-hex: #1e2128 !global;
|
||||
$_xpr-row-hover-hex: #2a2f37 !global;
|
||||
$_xpr-cell-focus-hex: #3d3920 !global;
|
||||
$_xpr-table-head-hex: #1c1f25 !global;
|
||||
$_xpr-section-bg-hex: #1c1f25 !global;
|
||||
$_xpr-text-hex: #e5e7eb !global;
|
||||
$_xpr-text-muted-hex: #9ca3af !global;
|
||||
$_xpr-text-dim-hex: #6b7280 !global;
|
||||
$_xpr-border-hex: #374151 !global;
|
||||
$_xpr-border-strong-hex: #4b5563 !global;
|
||||
$_xpr-border-table-hex: #2d333b !global;
|
||||
$_xpr-accent-hex: #b88fb5 !global;
|
||||
$_xpr-accent-hover-hex: #a07a9d !global;
|
||||
$_xpr-accent-bg-hex: #2d2330 !global;
|
||||
$_xpr-accent-tint-hex: #322636 !global;
|
||||
$_xpr-bake-bg-hex: #3d2818 !global;
|
||||
$_xpr-bake-text-hex: #fed7aa !global;
|
||||
$_xpr-bake-border-hex: #c2410c !global;
|
||||
$_xpr-good-hex: #34d399 !global;
|
||||
$_xpr-bad-hex: #f87171 !global;
|
||||
}
|
||||
|
||||
// ---- Token vars consumed by component partials ----
|
||||
$xpr-page: var(--xpr-page-bg, #{$_xpr-page-hex});
|
||||
$xpr-card: var(--xpr-card-bg, #{$_xpr-card-hex});
|
||||
$xpr-card-soft: var(--xpr-card-soft-bg, #{$_xpr-card-soft-hex});
|
||||
$xpr-row-hover: var(--xpr-row-hover, #{$_xpr-row-hover-hex});
|
||||
$xpr-cell-focus: var(--xpr-cell-focus, #{$_xpr-cell-focus-hex});
|
||||
$xpr-table-head: var(--xpr-table-head, #{$_xpr-table-head-hex});
|
||||
$xpr-section-bg: var(--xpr-section-bg, #{$_xpr-section-bg-hex});
|
||||
$xpr-text: var(--xpr-text, #{$_xpr-text-hex});
|
||||
$xpr-text-muted: var(--xpr-text-muted, #{$_xpr-text-muted-hex});
|
||||
$xpr-text-dim: var(--xpr-text-dim, #{$_xpr-text-dim-hex});
|
||||
$xpr-border: var(--xpr-border, #{$_xpr-border-hex});
|
||||
$xpr-border-strong: var(--xpr-border-strong, #{$_xpr-border-strong-hex});
|
||||
$xpr-border-table: var(--xpr-border-table, #{$_xpr-border-table-hex});
|
||||
$xpr-accent: var(--xpr-accent, #{$_xpr-accent-hex});
|
||||
$xpr-accent-hover: var(--xpr-accent-hover, #{$_xpr-accent-hover-hex});
|
||||
$xpr-accent-bg: var(--xpr-accent-bg, #{$_xpr-accent-bg-hex});
|
||||
$xpr-bake-bg: var(--xpr-bake-bg, #{$_xpr-bake-bg-hex});
|
||||
$xpr-bake-text: var(--xpr-bake-text, #{$_xpr-bake-text-hex});
|
||||
$xpr-bake-border: var(--xpr-bake-border, #{$_xpr-bake-border-hex});
|
||||
$xpr-good: var(--xpr-good, #{$_xpr-good-hex});
|
||||
$xpr-bad: var(--xpr-bad, #{$_xpr-bad-hex});
|
||||
$xpr-accent-tint: var(--xpr-accent-tint, #{$_xpr-accent-tint-hex});
|
||||
|
||||
// ---- Subtle decorative gradients (composed from the tokens above,
|
||||
// so they branch light/dark automatically) ----
|
||||
$xpr-grad-surface: linear-gradient(135deg, #{$xpr-accent-tint}, #{$xpr-card}); // faint plum wash → card
|
||||
$xpr-grad-head: linear-gradient(180deg, #{$xpr-table-head}, #{$xpr-card-soft}); // header depth
|
||||
$xpr-grad-accent: linear-gradient(135deg, #{$xpr-accent}, #{$xpr-accent-hover}); // filled accent pill
|
||||
$xpr-grad-grand: linear-gradient(160deg, #{$xpr-card}, #{$xpr-accent-tint}); // total-bar weight
|
||||
@@ -1,904 +0,0 @@
|
||||
// Express Orders - styles for the CSS Grid rebuild
|
||||
// (matches .claude/mockups/express_orders.html line-for-line where Odoo allows)
|
||||
|
||||
.o_fp_xpr {
|
||||
|
||||
// ============================================================
|
||||
// Title pill
|
||||
// ============================================================
|
||||
.o_fp_xpr_pill {
|
||||
background: $xpr-grad-accent;
|
||||
color: #fff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HEADER GRID - 4-column CSS Grid (mirrors mockup .header-grid)
|
||||
// ============================================================
|
||||
.o_fp_xpr_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px 20px; // tighter row gap (was 14px) - denser vertical packing
|
||||
align-items: start;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
// Each grid cell - label on top, field below
|
||||
.o_fp_xpr_cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
cursor: text;
|
||||
|
||||
> label {
|
||||
font-size: 10px;
|
||||
color: $xpr-text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// The actual field input - kill Odoo's default block-level chrome.
|
||||
// Aggressive width 100% on all known wrappers so the click target
|
||||
// matches the cell's visible width.
|
||||
> .o_field_widget,
|
||||
> .o_field_widget > div,
|
||||
> .o_field_widget > .o-dropdown,
|
||||
> .o_field_widget > .o-autocomplete,
|
||||
> .o_field_many2one,
|
||||
> .o_field_char,
|
||||
> .o_field_date,
|
||||
> .o_field_text,
|
||||
> .o_field_integer,
|
||||
> .o_field_selection {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.o-dropdown, .o-autocomplete {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Field input visual - underline style like the mockup.
|
||||
// EXCLUDES checkboxes / radios / file inputs (they have their own
|
||||
// visual treatment and would disappear under this style).
|
||||
.o_input,
|
||||
.o_field_widget input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
|
||||
.o_field_widget select {
|
||||
border: none;
|
||||
border-bottom: 1px solid $xpr-border-strong;
|
||||
background: transparent;
|
||||
padding: 2px 4px;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
color: $xpr-text;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
box-sizing: border-box;
|
||||
cursor: text;
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: $xpr-accent;
|
||||
border-bottom-width: 2px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: $xpr-cell-focus;
|
||||
}
|
||||
}
|
||||
.o_field_widget select { cursor: pointer; }
|
||||
|
||||
// Native (non-switch) checkboxes inside Express cells - keep
|
||||
// them visible at a comfortable size. EXCLUDES the boolean_toggle
|
||||
// widget (.o_field_boolean_toggle) which uses Bootstrap's
|
||||
// .form-switch slider styling that breaks if we set a fixed
|
||||
// width on the input.
|
||||
.o_field_widget:not(.o_field_boolean_toggle) input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
margin: 6px 0;
|
||||
cursor: pointer;
|
||||
accent-color: $xpr-accent;
|
||||
}
|
||||
|
||||
// Boolean toggle widget - preserve Bootstrap's slider proportions
|
||||
// (2em × 1em). Just centre vertically with the row height.
|
||||
.o_field_boolean_toggle {
|
||||
padding: 5px 0;
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
// Bootstrap form-switch styling - accent the slider
|
||||
.form-check-input {
|
||||
width: 2em !important;
|
||||
height: 1.2em !important;
|
||||
min-height: 1.2em !important;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:checked {
|
||||
background-color: $xpr-accent;
|
||||
border-color: $xpr-accent;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.15rem $xpr-accent-bg;
|
||||
border-color: $xpr-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-switch Boolean
|
||||
.o_field_boolean:not(.o_field_boolean_toggle) {
|
||||
padding: 5px 0;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline pair (Blanket SO toggle + Block partial checkbox)
|
||||
.o_fp_xpr_inline_pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 5px 0;
|
||||
min-height: 30px;
|
||||
|
||||
.o_field_widget { width: auto !important; }
|
||||
}
|
||||
.o_fp_xpr_inline_help {
|
||||
font-size: 11px;
|
||||
color: $xpr-text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.o_fp_xpr_cell.span-2 { grid-column: span 2; }
|
||||
.o_fp_xpr_cell.row-span-2 { grid-row: span 2; }
|
||||
.o_fp_xpr_cell.row-span-4 { grid-row: span 4; }
|
||||
.o_fp_xpr_cell.row-span-6 { grid-row: span 6; }
|
||||
|
||||
// Cells with a toggle-style field - label sits inline with the
|
||||
// toggle instead of stacked above. Used by Blanket Sales Order
|
||||
// (matches the mockup's compact toggle row). Toggle anchored
|
||||
// next to the label so layout doesn't shift when the secondary
|
||||
// "Block partial" toggle appears.
|
||||
.o_fp_xpr_cell.o_fp_xpr_inline_label {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
|
||||
> label {
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
> .o_fp_xpr_inline_pair {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_cell.required > label::after {
|
||||
content: " *";
|
||||
color: $xpr-bad;
|
||||
}
|
||||
|
||||
// Lead Time range - inline X to Y
|
||||
.o_fp_xpr_range {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.o_fp_xpr_range .o_field_widget,
|
||||
.o_fp_xpr_range input {
|
||||
width: 3.5em !important;
|
||||
text-align: center;
|
||||
}
|
||||
.o_fp_xpr_range_sep {
|
||||
color: $xpr-text-dim;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PO BLOCK - consolidated card-within-card
|
||||
// ============================================================
|
||||
.o_fp_xpr_po_block {
|
||||
background: $xpr-grad-surface;
|
||||
border: 1px solid lighten(#d8b4d4, 5%);
|
||||
border-left: 4px solid $xpr-accent;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px 8px;
|
||||
gap: 2px;
|
||||
|
||||
.o_fp_xpr_po_head {
|
||||
color: $xpr-accent;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.o_fp_xpr_po_row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
min-height: 28px;
|
||||
|
||||
> label {
|
||||
font-size: 12px;
|
||||
color: $xpr-text;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
// Make the field widget + its inner controls fill the right column
|
||||
> .o_field_widget,
|
||||
> .o_field_widget > div,
|
||||
> .o_field_widget > .o-dropdown,
|
||||
> .o_field_widget > .o-autocomplete,
|
||||
> .o_field_binary,
|
||||
> .o_field_char,
|
||||
> .o_field_date {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
|
||||
select {
|
||||
border: none;
|
||||
border-bottom: 1px solid $xpr-border-strong;
|
||||
background: transparent;
|
||||
padding: 4px 4px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: $xpr-accent;
|
||||
background: $xpr-cell-focus;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
// File-upload widget: make the button look consistent
|
||||
.o_field_binary {
|
||||
.o_select_file_button,
|
||||
button {
|
||||
background: $xpr-accent;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 4px 14px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover { background: $xpr-accent-hover; }
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_po_chase {
|
||||
color: $xpr-bake-text;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
padding: 2px 0 0;
|
||||
border-top: 1px dashed $xpr-bake-border;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION TITLE - between header and lines
|
||||
// ============================================================
|
||||
.o_fp_xpr_section_title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: $xpr-accent;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 16px 0 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 2px solid $xpr-border;
|
||||
border-image: linear-gradient(90deg, $xpr-accent, $xpr-border) 1;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LINES TABLE - tight bordered spreadsheet
|
||||
// ============================================================
|
||||
.o_fp_xpr_lines .o_list_view table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid $xpr-border-table;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
// Column widths are set via `width="Npx"` ARCH ATTRIBUTES on the
|
||||
// <field> elements (the proper Odoo 19 mechanism). The
|
||||
// column_width_hook.js dynamically applies inline widths after
|
||||
// render and overrides any CSS-only width, so we don't fight it
|
||||
// here. We only add a few visual tweaks per-column:
|
||||
.o_fp_xpr_lines .o_list_view {
|
||||
td[name="masking_enabled"] { text-align: center; }
|
||||
td[name="line_subtotal"] { font-weight: 600; }
|
||||
td[name="action_btns_anchor"] { padding: 2px !important; text-align: center; }
|
||||
}
|
||||
.o_fp_xpr_lines .o_list_view thead th {
|
||||
background: $xpr-grad-head;
|
||||
color: $xpr-text-muted;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid $xpr-border-table;
|
||||
border-right: 1px solid $xpr-border-table;
|
||||
padding: 6px 8px;
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
}
|
||||
.o_fp_xpr_lines .o_list_view tbody td {
|
||||
border-bottom: 1px solid $xpr-border-table;
|
||||
border-right: 1px solid $xpr-border-table;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
}
|
||||
.o_fp_xpr_lines .o_list_view tbody tr:hover { background: $xpr-row-hover; }
|
||||
.o_fp_xpr_lines .o_list_view tbody tr.o_data_row:focus-within { background: $xpr-cell-focus; }
|
||||
|
||||
// Bake column - coloured pill input
|
||||
.o_fp_xpr_lines td[name="bake_instructions"] input[type="text"] {
|
||||
background: $xpr-bake-bg;
|
||||
color: $xpr-bake-text;
|
||||
border: 1px solid $xpr-bake-border;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&::placeholder {
|
||||
color: $xpr-text-dim;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
&:not(:focus):placeholder-shown {
|
||||
background: $xpr-card-soft;
|
||||
border-color: $xpr-border-strong;
|
||||
color: $xpr-text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
// Line Job # - bold uppercase narrow
|
||||
.o_fp_xpr_lines td[name="customer_line_ref"] input {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
max-width: 70px;
|
||||
}
|
||||
// Masking toggle - bigger
|
||||
.o_fp_xpr_lines td[name="masking_enabled"] .form-check-input { transform: scale(1.15); }
|
||||
// Inline buttons
|
||||
.o_fp_xpr_lines .o_fp_xpr_inline_btn {
|
||||
font-size: 10px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px !important;
|
||||
border: 1px solid $xpr-border-strong;
|
||||
border-radius: 3px;
|
||||
color: $xpr-text-muted !important;
|
||||
background: transparent;
|
||||
margin: 0 2px;
|
||||
|
||||
&:hover {
|
||||
color: $xpr-accent !important;
|
||||
border-color: $xpr-accent;
|
||||
background: $xpr-accent-bg;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Stacked DWG / OPEN buttons widget
|
||||
.o_fp_xpr_action_stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
|
||||
.o_fp_xpr_action_stack_btn {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid $xpr-border-strong;
|
||||
border-radius: 3px;
|
||||
color: $xpr-text-muted;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
line-height: 1.3;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $xpr-accent;
|
||||
border-color: $xpr-accent;
|
||||
background: $xpr-accent-bg;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// MASK upload - amber so order-entry notices the "attach reference"
|
||||
// affordance the moment masking is toggled on. Solid amber works on
|
||||
// both the light and dark backend bundles (dark text on amber fill).
|
||||
.o_fp_xpr_mask_btn {
|
||||
color: #1f2937;
|
||||
border-color: #d97706;
|
||||
background: #fbbf24;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #1f2937;
|
||||
border-color: #b45309;
|
||||
background: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FOOTER - Notes/Terms left + Totals right (CSS Grid)
|
||||
// ============================================================
|
||||
.o_fp_xpr_footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.o_fp_xpr_footer_left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Cards
|
||||
.o_fp_xpr_card {
|
||||
background: $xpr-card;
|
||||
border: 1px solid $xpr-border;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
|
||||
.o_fp_xpr_card_title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: $xpr-text;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.o_fp_xpr_chip {
|
||||
background: $xpr-accent-bg;
|
||||
color: $xpr-accent;
|
||||
padding: 1px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_card_sub {
|
||||
font-size: 11px;
|
||||
color: $xpr-text-muted;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
// Textareas inside cards
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
border: 1px solid $xpr-border;
|
||||
border-radius: 3px;
|
||||
background: $xpr-card-soft;
|
||||
color: $xpr-text;
|
||||
padding: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
border-color: $xpr-accent;
|
||||
background: $xpr-card;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Totals card - rendered as a clean bordered "summary table"
|
||||
.o_fp_xpr_card.o_fp_xpr_totals {
|
||||
padding: 0; // rows carry their own padding
|
||||
overflow: hidden; // clip header/footer fills to the radius
|
||||
border-color: $xpr-border-strong;
|
||||
}
|
||||
.o_fp_xpr_totals {
|
||||
// Caption bar so the block reads as a titled table
|
||||
.o_fp_xpr_totals_head {
|
||||
background: $xpr-grad-surface;
|
||||
border-bottom: 1px solid $xpr-accent;
|
||||
padding: 8px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: $xpr-accent;
|
||||
}
|
||||
|
||||
.o_fp_xpr_total_row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr; // label | value - fixed split keeps the divider aligned
|
||||
align-items: stretch;
|
||||
font-size: 13px;
|
||||
color: $xpr-text;
|
||||
border-bottom: 1px solid $xpr-border-table; // horizontal divider per row
|
||||
|
||||
// col 1 - label (+ optional inline picker), carrying the
|
||||
// vertical column divider on its right edge
|
||||
.o_fp_xpr_total_label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
padding: 8px 14px;
|
||||
border-right: 1px solid $xpr-border-table;
|
||||
color: $xpr-text-muted;
|
||||
font-weight: 500;
|
||||
|
||||
// charge-type / tax pickers sit under the label text and
|
||||
// fill the column width so the dropdown never collapses
|
||||
.o_field_widget {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// col 2 - value amount, top-aligned with the label text and
|
||||
// right-aligned so every amount lines up in one column
|
||||
> :not(.o_fp_xpr_total_label) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer row - emphasised + tinted, closes the table
|
||||
.o_fp_xpr_total_row.o_fp_xpr_grand {
|
||||
border-bottom: 0;
|
||||
border-top: 2px solid $xpr-accent;
|
||||
background: $xpr-grad-grand;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
|
||||
.o_fp_xpr_total_label {
|
||||
color: $xpr-text;
|
||||
border-right-color: $xpr-border-strong; // divider matches the footer rule weight
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
> :not(.o_fp_xpr_total_label) {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_currency_pill {
|
||||
background: $xpr-grad-accent !important;
|
||||
color: #fff !important;
|
||||
padding: 2px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LEGEND bar above the lines table
|
||||
// ============================================================
|
||||
.o_fp_xpr_legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 8px 16px;
|
||||
background: $xpr-grad-surface;
|
||||
border: 1px solid $xpr-border;
|
||||
border-left: 3px solid $xpr-accent;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: $xpr-text-muted;
|
||||
|
||||
strong { color: $xpr-accent; }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PO Block - status pill (received / pending / missing)
|
||||
// ============================================================
|
||||
.o_fp_xpr_po_block .o_fp_xpr_po_head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
> span:first-child {
|
||||
color: $xpr-accent;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART CELL - multi-row content (FpExpressPartCell widget)
|
||||
// ============================================================
|
||||
.o_fp_xpr_part_cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 220px;
|
||||
margin: -4px -8px; // hug the td edges so internal borders extend full width
|
||||
|
||||
> .o_fp_xpr_part_row {
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
> .o_fp_xpr_part_row:not(:last-child) {
|
||||
border-bottom: 1px solid $xpr-border-table;
|
||||
}
|
||||
|
||||
.o_fp_xpr_part_id {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
|
||||
// The picker + overlay container
|
||||
.o_fp_xpr_part_picker_wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Strip Many2OneField chrome to inline-bold-text
|
||||
.o_field_widget,
|
||||
.o_field_widget > div,
|
||||
.o_field_widget input,
|
||||
.o_field_many2one input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 1px 4px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: $xpr-accent;
|
||||
width: 100%;
|
||||
}
|
||||
.o_field_widget input:focus {
|
||||
background: $xpr-cell-focus;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// OVERLAY - shown when picker not focused; hides display_name
|
||||
// and shows just part_number_display
|
||||
.o_fp_xpr_part_num_overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: $xpr-card;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
padding: 1px 4px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: $xpr-accent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
// Hide overlay when picker is focused (user is typing/searching)
|
||||
.o_fp_xpr_part_picker_wrap:focus-within .o_fp_xpr_part_num_overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_fp_xpr_part_sep {
|
||||
color: $xpr-text-dim;
|
||||
font-weight: 400;
|
||||
padding: 0 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.o_fp_xpr_part_rev {
|
||||
font-weight: 600;
|
||||
color: $xpr-text;
|
||||
min-width: 30px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.o_fp_xpr_part_rev_empty {
|
||||
color: $xpr-text-dim;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
// Overlay background matches the row hover state
|
||||
.o_list_view tbody tr:hover & .o_fp_xpr_part_num_overlay {
|
||||
background: $xpr-row-hover;
|
||||
}
|
||||
.o_fp_xpr_part_name {
|
||||
.o_fp_xpr_part_name_input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
color: $xpr-text;
|
||||
padding: 2px 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: $xpr-text-dim;
|
||||
font-style: italic;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: $xpr-cell-focus;
|
||||
}
|
||||
&:disabled {
|
||||
background: transparent;
|
||||
color: $xpr-text-dim;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_part_serial {
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
|
||||
.o_fp_xpr_serial_input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
color: $xpr-text-muted;
|
||||
padding: 2px 4px;
|
||||
letter-spacing: 0.2px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
font-style: italic;
|
||||
color: $xpr-text-dim;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: $xpr-cell-focus;
|
||||
color: $xpr-text;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_bulk_btn {
|
||||
flex: 0 0 auto;
|
||||
background: $xpr-section-bg;
|
||||
border: 1px solid $xpr-border-strong;
|
||||
color: $xpr-text-muted;
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $xpr-accent-bg;
|
||||
color: $xpr-accent;
|
||||
border-color: $xpr-accent;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BAKE PILL - click-to-edit (FpExpressBakePill widget)
|
||||
// ============================================================
|
||||
.o_fp_xpr_bake_wrap {
|
||||
display: inline-block;
|
||||
|
||||
.o_fp_xpr_bake_pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&.has-bake {
|
||||
background: $xpr-bake-bg;
|
||||
color: $xpr-bake-text;
|
||||
border-color: $xpr-bake-border;
|
||||
}
|
||||
&.no-bake {
|
||||
background: $xpr-card-soft;
|
||||
color: $xpr-text-muted;
|
||||
border-color: $xpr-border-strong;
|
||||
font-style: italic;
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_bake_editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 200px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid $xpr-border;
|
||||
border-radius: 3px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: $xpr-card;
|
||||
color: $xpr-text;
|
||||
|
||||
&:focus {
|
||||
border-color: $xpr-accent;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
.o_fp_xpr_bake_actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.btn { padding: 2px 8px; font-size: 11px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// view_source badge column on drafts list - accent-coloured for Express
|
||||
.o_list_view .badge.text-bg-info {
|
||||
background-color: $xpr-accent !important;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating -- 3D Viewer + Configurator Layout
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// =============================================================================
|
||||
|
||||
// -- Configurator layout (single column) -------------------------------------
|
||||
// The right-side 3D viewer + drawing preview were retired in favour of
|
||||
// smart-button + inline-Preview-link affordances. Layout collapses to a
|
||||
// single full-width column. Wrapper kept so the SCSS hook stays stable
|
||||
// in case we add a side panel back later.
|
||||
.o_fp_cfg_layout {
|
||||
display: block;
|
||||
}
|
||||
.o_fp_cfg_fields {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// -- 3D viewer widget --
|
||||
.o_fp_3d_viewer_root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_3d_placeholder {
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bs-secondary-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.o_fp_3d_iframe {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f0f2f5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Inside the preview column: same height as the PDF preview iframe
|
||||
.o_fp_cfg_preview .o_fp_3d_iframe {
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
// -- 3D Viewer Dialog (full-screen modal) --
|
||||
.o_fp_3d_dialog {
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_body {
|
||||
width: 100%;
|
||||
background-color: #f0f2f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_iframe {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_actions {
|
||||
padding: 8px 12px;
|
||||
text-align: right;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
// -- Inline PDF preview widget (fp_pdf_inline_preview) --
|
||||
.o_fp_pdf_inline_root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_frame_wrap {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_placeholder {
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating - Job Status pill on the SO list
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// One pill per row, one colour per phase, vibrant + saturated so phases
|
||||
// pop at a glance against both the light and dark Odoo bundles. Same
|
||||
// hue map for both modes - saturated 500-level Tailwind hues with white
|
||||
// text give consistent contrast against either page background.
|
||||
// =============================================================================
|
||||
|
||||
// ----- Vibrant tints (light + dark) -----
|
||||
$_fp-muted-bg : #6b7280; // slate
|
||||
$_fp-warning-bg : #f59e0b; // amber
|
||||
$_fp-primary-bg : #8b5cf6; // violet
|
||||
$_fp-info-bg : #3b82f6; // blue
|
||||
$_fp-shipping-bg : #06b6d4; // cyan
|
||||
$_fp-delivered-bg : #14b8a6; // teal
|
||||
$_fp-invoiced-bg : #84cc16; // lime
|
||||
$_fp-paid-bg : #16a34a; // green
|
||||
$_fp-danger-bg : #ef4444; // red
|
||||
|
||||
// Matching glow shadows - darker tone of the same hue for a subtle
|
||||
// drop-shadow that gives the pill a "lifted" feel without being noisy.
|
||||
$_fp-muted-glow : rgba(31, 41, 55, 0.35);
|
||||
$_fp-warning-glow : rgba(180, 83, 9, 0.45);
|
||||
$_fp-primary-glow : rgba(91, 33, 182, 0.45);
|
||||
$_fp-info-glow : rgba(29, 78, 216, 0.45);
|
||||
$_fp-shipping-glow : rgba(14, 116, 144, 0.45);
|
||||
$_fp-delivered-glow : rgba(15, 118, 110, 0.45);
|
||||
$_fp-invoiced-glow : rgba(101, 163, 13, 0.45);
|
||||
$_fp-paid-glow : rgba(21, 128, 61, 0.5);
|
||||
$_fp-danger-glow : rgba(185, 28, 28, 0.45);
|
||||
|
||||
// =============================================================================
|
||||
// Pill base
|
||||
// =============================================================================
|
||||
.fp-job-status {
|
||||
display: inline-block;
|
||||
padding: 0.4em 0.95em;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.015em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
min-width: 72px;
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-kind tints - same map applies to light + dark bundles. White text
|
||||
// gives consistent contrast against any saturated mid-tone hue.
|
||||
// =============================================================================
|
||||
.fp-kind-muted { background-color: $_fp-muted-bg; box-shadow: 0 1px 3px $_fp-muted-glow; }
|
||||
.fp-kind-warning { background-color: $_fp-warning-bg; box-shadow: 0 1px 3px $_fp-warning-glow; }
|
||||
.fp-kind-primary { background-color: $_fp-primary-bg; box-shadow: 0 1px 3px $_fp-primary-glow; }
|
||||
.fp-kind-info { background-color: $_fp-info-bg; box-shadow: 0 1px 3px $_fp-info-glow; }
|
||||
.fp-kind-shipping { background-color: $_fp-shipping-bg; box-shadow: 0 1px 3px $_fp-shipping-glow; }
|
||||
.fp-kind-delivered { background-color: $_fp-delivered-bg; box-shadow: 0 1px 3px $_fp-delivered-glow; }
|
||||
.fp-kind-invoiced { background-color: $_fp-invoiced-bg; box-shadow: 0 1px 3px $_fp-invoiced-glow; }
|
||||
.fp-kind-paid {
|
||||
background-color: $_fp-paid-bg;
|
||||
box-shadow: 0 1px 4px $_fp-paid-glow;
|
||||
font-weight: 700;
|
||||
}
|
||||
.fp-kind-danger { background-color: $_fp-danger-bg; box-shadow: 0 1px 3px $_fp-danger-glow; }
|
||||
@@ -1,159 +0,0 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// Part of the Fusion Plating product family.
|
||||
//
|
||||
// Sub 3 - Process Composer styles.
|
||||
//
|
||||
// Theme handling: Odoo 19 compiles this SCSS into BOTH web.assets_backend
|
||||
// (bright) and web.assets_web_dark (dark). We branch at compile time on
|
||||
// $o-webclient-color-scheme so the dark bundle gets distinct colours.
|
||||
// Bootstrap CSS variables (--bs-body-bg etc.) don't flip reliably in
|
||||
// Odoo 19's backend - hardcoded hex via CSS custom properties is the
|
||||
// pattern documented in CLAUDE.md (shopfloor tokens).
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---- Token defaults (bright mode) ----
|
||||
$_fp-composer-page-hex: #f3f4f6;
|
||||
$_fp-composer-card-hex: #ffffff;
|
||||
$_fp-composer-border-hex: #d8dadd;
|
||||
$_fp-composer-panel-hex: #f5f5f5;
|
||||
$_fp-composer-text-hex: #1f2328;
|
||||
$_fp-composer-muted-hex: #6a737d;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-composer-page-hex: #1a1d21 !global;
|
||||
$_fp-composer-card-hex: #22262d !global;
|
||||
$_fp-composer-border-hex: #3a3f47 !global;
|
||||
$_fp-composer-panel-hex: #2a2f36 !global;
|
||||
$_fp-composer-text-hex: #e6e6e6 !global;
|
||||
$_fp-composer-muted-hex: #9aa0a6 !global;
|
||||
}
|
||||
|
||||
// Expose as CSS custom properties so admins can override per-company
|
||||
// without recompiling. Token names intentionally unique to this module
|
||||
// to avoid collisions with shopfloor / plant-overview tokens.
|
||||
$fp-composer-card: var(--fp-composer-card, $_fp-composer-card-hex);
|
||||
$fp-composer-border: var(--fp-composer-border, $_fp-composer-border-hex);
|
||||
$fp-composer-panel: var(--fp-composer-panel, $_fp-composer-panel-hex);
|
||||
$fp-composer-text: var(--fp-composer-text, $_fp-composer-text-hex);
|
||||
$fp-composer-muted: var(--fp-composer-muted, $_fp-composer-muted-hex);
|
||||
|
||||
.o_fp_part_composer {
|
||||
padding: 16px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
color: $fp-composer-text;
|
||||
|
||||
// Variants table - keep the 5 action buttons (Tree / Simple /
|
||||
// Duplicate / Rename / Delete) on a single row. Without this the
|
||||
// Delete button wraps even on wide screens because Bootstrap's
|
||||
// `.table` lets cells shrink to content+wrap.
|
||||
.o_fp_part_composer_variants {
|
||||
td:last-child,
|
||||
th:last-child {
|
||||
white-space: nowrap;
|
||||
width: 1%; // shrink-to-fit so buttons stay tight on the right
|
||||
}
|
||||
}
|
||||
|
||||
&_state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: $fp-composer-muted;
|
||||
|
||||
.fa {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&_error {
|
||||
color: var(--bs-danger, #c00);
|
||||
}
|
||||
|
||||
&_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $fp-composer-border;
|
||||
}
|
||||
|
||||
&_title {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: $fp-composer-text;
|
||||
}
|
||||
}
|
||||
|
||||
&_loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: $fp-composer-panel;
|
||||
border: 1px solid $fp-composer-border;
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: $fp-composer-text;
|
||||
}
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
&_tree {
|
||||
min-height: 300px;
|
||||
padding: 24px;
|
||||
background: $fp-composer-card;
|
||||
color: $fp-composer-text;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $fp-composer-border;
|
||||
}
|
||||
|
||||
&_hint,
|
||||
&_empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: $fp-composer-muted;
|
||||
|
||||
.fa {
|
||||
color: $fp-composer-muted;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $fp-composer-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// "Open Process Editor" button - icon + label vertically centred,
|
||||
// icon forced to a dark tone for high contrast against the primary
|
||||
// button's green fill (the default inherited colour was washed out).
|
||||
&_editor_btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
line-height: 1;
|
||||
|
||||
.fa {
|
||||
color: #ffffff; // white - matches the button label for a clean read
|
||||
font-size: 1.05em;
|
||||
margin: 0; // override the _hint/_empty 16px bottom margin
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Express Orders - stacked DWG / OPEN action buttons -->
|
||||
<t t-name="fusion_plating_configurator.FpExpressActionBtns">
|
||||
<div class="o_fp_xpr_action_stack">
|
||||
<button class="o_fp_xpr_action_stack_btn"
|
||||
t-on-click="onUpload"
|
||||
t-att-disabled="!hasPart"
|
||||
title="Upload a drawing for this part (saves to part record)">
|
||||
DWG
|
||||
</button>
|
||||
<button class="o_fp_xpr_action_stack_btn"
|
||||
t-on-click="onOpen"
|
||||
t-att-disabled="!hasPart"
|
||||
title="Open the part record in a modal">
|
||||
OPEN
|
||||
</button>
|
||||
<button t-if="maskingEnabled"
|
||||
class="o_fp_xpr_action_stack_btn o_fp_xpr_mask_btn"
|
||||
t-on-click="onUploadMask"
|
||||
t-att-disabled="!hasPart"
|
||||
title="Attach masking reference image(s)/PDF(s) - shown to the operator on the masking step">
|
||||
MASK<t t-if="maskCount"> (<t t-esc="maskCount"/>)</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Express Orders - Bake pill (click-to-edit) -->
|
||||
<t t-name="fusion_plating_configurator.FpExpressBakePill">
|
||||
<div class="o_fp_xpr_bake_wrap">
|
||||
<t t-if="!state.editing">
|
||||
<span class="o_fp_xpr_bake_pill"
|
||||
t-att-class="{ 'has-bake': hasBake, 'no-bake': !hasBake }"
|
||||
t-on-click="openEditor"
|
||||
t-esc="pillLabel"
|
||||
title="Click to edit"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_xpr_bake_editor" t-on-keydown="onKeyDown">
|
||||
<textarea t-ref="textarea"
|
||||
t-model="state.draft"
|
||||
rows="2"
|
||||
placeholder="e.g. 350°F × 4 hr"/>
|
||||
<div class="o_fp_xpr_bake_actions">
|
||||
<button class="btn btn-sm btn-primary" t-on-click="save">Save</button>
|
||||
<button class="btn btn-sm btn-secondary" t-on-click="clear">Clear</button>
|
||||
<button class="btn btn-sm btn-link" t-on-click="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Express Orders - Part cell template (3 stacked rows)
|
||||
|
||||
Row 1: Part picker (Many2OneField - display_name is just the
|
||||
part_number when fp_express_part_picker context is set)
|
||||
Row 2: editable part description (writes to part.name on blur)
|
||||
Row 3: editable serial #s (parses comma-separated, creates fp.serial
|
||||
records as needed) + small + bulk button
|
||||
-->
|
||||
<t t-name="fusion_plating_configurator.FpExpressPartCell">
|
||||
<div class="o_fp_xpr_part_cell">
|
||||
<!-- Row 1 - Part picker (left, with part-number overlay) + / + Revision (right)
|
||||
The overlay shows JUST the part_number_display when not focused;
|
||||
on focus, the overlay hides so the user sees the autocomplete input. -->
|
||||
<div class="o_fp_xpr_part_row o_fp_xpr_part_id">
|
||||
<div class="o_fp_xpr_part_picker_wrap">
|
||||
<Many2OneField t-props="props"/>
|
||||
<span t-if="hasPart"
|
||||
class="o_fp_xpr_part_num_overlay"
|
||||
t-esc="partNumber"/>
|
||||
</div>
|
||||
<span class="o_fp_xpr_part_sep">/</span>
|
||||
<span class="o_fp_xpr_part_rev"
|
||||
t-att-class="{ 'o_fp_xpr_part_rev_empty': !partRev }"
|
||||
t-esc="partRev or 'rev'"/>
|
||||
</div>
|
||||
<!-- Row 2 - Editable part description (saves to part.name) -->
|
||||
<div class="o_fp_xpr_part_row o_fp_xpr_part_name">
|
||||
<input class="o_fp_xpr_part_name_input"
|
||||
type="text"
|
||||
t-att-value="partName"
|
||||
t-att-disabled="!hasPart"
|
||||
t-on-change="onNameChange"
|
||||
placeholder="- part description -"
|
||||
title="Edit the part's name - saves to the part record"/>
|
||||
</div>
|
||||
<!-- Row 3 - Editable serial list + bulk-add button -->
|
||||
<div class="o_fp_xpr_part_row o_fp_xpr_part_serial">
|
||||
<input class="o_fp_xpr_serial_input"
|
||||
type="text"
|
||||
t-att-value="serialsText"
|
||||
t-att-disabled="!hasPart"
|
||||
t-on-change="onSerialsChange"
|
||||
placeholder="serial #(s) - comma separated"
|
||||
title="Type serials separated by commas - creates fp.serial records as needed"/>
|
||||
<button class="o_fp_xpr_bulk_btn"
|
||||
t-on-click="onBulkClick"
|
||||
t-att-disabled="!hasPart"
|
||||
title="Bulk add serials (paste list or range fill)">
|
||||
+ bulk
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,50 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.Fp3dViewer">
|
||||
<div class="o_fp_3d_viewer_root">
|
||||
<t t-if="!state.hasAttachment">
|
||||
<div class="o_fp_3d_placeholder text-center text-muted p-4">
|
||||
<i class="fa fa-cube fa-3x mb-2 d-block"/>
|
||||
<span>Upload a 3D model (STL, STEP, IGES) to preview it here.</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="state.hasAttachment">
|
||||
<iframe t-att-src="state.iframeSrc"
|
||||
class="o_fp_3d_iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen="true"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_plating_configurator.Fp3dViewerDialog">
|
||||
<Dialog title.translate="3D Model Viewer"
|
||||
size="dialogSize"
|
||||
contentClass="'o_fp_3d_dialog'"
|
||||
footer="false">
|
||||
<div class="o_fp_3d_dialog_body">
|
||||
<iframe t-att-src="iframeSrc"
|
||||
t-att-style="frameStyle"
|
||||
class="o_fp_3d_dialog_iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen="true"/>
|
||||
</div>
|
||||
<div class="o_fp_3d_dialog_actions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="toggleSize">
|
||||
<i t-att-class="state.isMaximized ? 'fa fa-compress me-1' : 'fa fa-expand me-1'"/>
|
||||
<t t-if="state.isMaximized">Restore</t>
|
||||
<t t-else="">Maximize</t>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary ms-2"
|
||||
t-on-click="props.close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,73 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPdfPreviewBinary">
|
||||
<div t-attf-class="oe_fileupload {{props.className ? props.className : ''}}" aria-atomic="true">
|
||||
<div class="o_attachments">
|
||||
<t t-foreach="files" t-as="file" t-key="file_index">
|
||||
<t t-set="editable" t-value="!props.readonly"/>
|
||||
<t t-set="ext" t-value="getExtension(file)"/>
|
||||
<t t-set="url" t-value="getUrl(file.id)"/>
|
||||
<t t-set="isPdf" t-value="(file.mimetype === 'application/pdf') or (file.name and file.name.toLowerCase().endsWith('.pdf'))"/>
|
||||
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' }"
|
||||
t-att-title="file.name">
|
||||
<div class="o_attachment_wrap">
|
||||
<div class="o_image_box float-start"
|
||||
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name">
|
||||
<a t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''"
|
||||
aria-label="Open">
|
||||
<img t-if="isImage(file)"
|
||||
class="o_preview_image o_hover object-fit-cover rounded align-baseline"
|
||||
t-attf-src="/web/image/{{ file.id }}"
|
||||
onerror="this.src = '/web/static/img/mimetypes/image.svg'"/>
|
||||
<span t-else="" class="o_image o_preview_image o_hover"
|
||||
t-att-data-mimetype="file.mimetype"
|
||||
t-att-data-ext="ext" role="img"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="caption">
|
||||
<a class="ml4"
|
||||
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name"
|
||||
t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''"><t t-esc="file.name"/></a>
|
||||
</div>
|
||||
<div class="caption small">
|
||||
<a class="ml4 small text-uppercase"
|
||||
t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''">
|
||||
<b><t t-esc="ext"/></b>
|
||||
</a>
|
||||
</div>
|
||||
<div class="o_attachment_uploaded">
|
||||
<i class="text-success fa fa-check" role="img"
|
||||
aria-label="Uploaded" title="Uploaded"/>
|
||||
</div>
|
||||
<div t-if="editable" class="o_attachment_delete"
|
||||
t-on-click.stop="() => this.onFileRemove(file.id)">
|
||||
<span role="img" aria-label="Delete" title="Delete">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="!props.readonly and (!props.numberOfFiles or files.length < props.numberOfFiles)"
|
||||
class="oe_add">
|
||||
<FileInput acceptedFileExtensions="props.acceptedFileExtensions"
|
||||
multiUpload="true"
|
||||
onUpload.bind="onFileUploaded"
|
||||
resModel="props.record.resModel"
|
||||
resId="props.record.resId or 0">
|
||||
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
|
||||
<span class="fa fa-paperclip" aria-label="Attach"/>
|
||||
<t t-esc="uploadText"/>
|
||||
</button>
|
||||
</FileInput>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,163 +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.
|
||||
|
||||
OWL template for the part-scoped Process Composer client action.
|
||||
Sub 9 - multi-variant Composer.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPartProcessComposer">
|
||||
<div class="o_fp_part_composer">
|
||||
<t t-if="state.loading">
|
||||
<div class="o_fp_part_composer_state">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<span> Loading…</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.error">
|
||||
<div class="o_fp_part_composer_state o_fp_part_composer_error">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span t-esc="state.error"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.part">
|
||||
<div class="o_fp_part_composer_header">
|
||||
<button class="btn btn-secondary" t-on-click="backToPart">
|
||||
<i class="fa fa-arrow-left"/>
|
||||
<span> Back to Part</span>
|
||||
</button>
|
||||
<div class="o_fp_part_composer_title">
|
||||
<h2>Process Composer - <t t-esc="state.part.display"/></h2>
|
||||
<small class="text-muted" t-if="state.part.customer">
|
||||
Customer: <t t-esc="state.part.customer"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_part_composer_variants mt-3">
|
||||
<h4>Process Variants</h4>
|
||||
<p class="text-muted small">
|
||||
Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework").
|
||||
One variant is the default; order lines may pick another at entry time.
|
||||
</p>
|
||||
<t t-if="state.variants.length === 0">
|
||||
<div class="o_fp_part_composer_empty">
|
||||
<i class="fa fa-cogs fa-2x"/>
|
||||
<p>No variants yet. Pick a template below and add the first one.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Default</th>
|
||||
<th>Label</th>
|
||||
<th>Recipe Name</th>
|
||||
<th class="text-end">Nodes</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.variants" t-as="v" t-key="v.id">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-if="v.is_default">
|
||||
<span class="badge bg-success">Default</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="btn btn-link btn-sm p-0"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onSetDefaultVariant(v.id)">
|
||||
Set Default
|
||||
</button>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<strong t-esc="v.label"/>
|
||||
</td>
|
||||
<td class="text-muted" t-esc="v.name"/>
|
||||
<td class="text-end" t-esc="v.node_count"/>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-primary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.openRecipeEditor(v.id)"
|
||||
title="Open the tree editor (drag-and-drop hierarchy view)">
|
||||
<i class="fa fa-pencil"/> Tree
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.openRecipeSimpleEditor(v.id)"
|
||||
title="Open the Simple Recipe Editor (flat 2-pane drag-drop)">
|
||||
<i class="fa fa-list-alt"/> Simple
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onDuplicateVariant(v.id)">
|
||||
<i class="fa fa-copy"/> Duplicate
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onRenameVariant(v.id)">
|
||||
<i class="fa fa-i-cursor"/> Rename
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onDeleteVariant(v.id)">
|
||||
<i class="fa fa-trash"/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_part_composer_loader mt-4">
|
||||
<h4>Add Variant from Template</h4>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<label class="me-2">Template:</label>
|
||||
<!-- Bumped min-width 280px → 360px and let it
|
||||
flex-grow so long template names (e.g.
|
||||
"Chemical Conversion - Iridite Type II Cl 3")
|
||||
don't truncate to "Chem…". Reported 2026-05-20. -->
|
||||
<select class="form-select"
|
||||
style="min-width: 360px; flex: 1 1 360px; max-width: 560px;"
|
||||
t-on-change="onSelectTemplate">
|
||||
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
|
||||
<option t-att-value="tpl.id"
|
||||
t-att-selected="tpl.id == state.selectedTemplateId">
|
||||
<t t-esc="tpl.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<input class="form-control"
|
||||
style="min-width: 220px; flex: 1 1 220px; max-width: 320px;"
|
||||
placeholder="Variant label (e.g. Standard ENP)"
|
||||
t-att-value="state.newVariantLabel"
|
||||
t-on-input="onNewLabelInput"/>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onAddVariantFromTemplate('tree')"
|
||||
t-att-disabled="state.busy or !state.selectedTemplateId"
|
||||
title="Add the variant and open it in the Tree Editor">
|
||||
<i class="fa fa-sitemap me-1"/> Add - Tree
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onAddVariantFromTemplate('simple')"
|
||||
t-att-disabled="state.busy or !state.selectedTemplateId"
|
||||
title="Add the variant and open it in the Simple Editor">
|
||||
<i class="fa fa-list me-1"/> Add - Simple
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted small mt-1">
|
||||
Leave the label blank to use the template name. The first variant added becomes the default automatically.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPdfInlinePreview">
|
||||
<div class="o_fp_pdf_inline_root">
|
||||
<t t-if="state.hasAttachment">
|
||||
<div class="o_fp_pdf_inline_frame_wrap">
|
||||
<iframe t-att-src="state.iframeSrc"
|
||||
class="o_fp_pdf_inline_iframe"
|
||||
frameborder="0"
|
||||
title="PDF Preview"/>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
t-on-click="openFullScreen">
|
||||
<i class="fa fa-expand me-1"/>Full Screen
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="!state.hasAttachment">
|
||||
<div class="o_fp_pdf_inline_placeholder text-center text-muted p-4">
|
||||
<i class="fa fa-file-pdf-o fa-3x mb-2 d-block"/>
|
||||
<span>No PDF attached.</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,11 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import test_express_part_defaults
|
||||
from . import test_express_line_fields
|
||||
from . import test_express_so_line_fields
|
||||
from . import test_express_sale_order_fields
|
||||
from . import test_express_wizard_fields
|
||||
from . import test_part_description_history
|
||||
from . import test_charge_tax_lot
|
||||
@@ -1,86 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Configurable charge + order-level tax + lot pricing (spec 2026-05-29)."""
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_charge_tax_lot')
|
||||
class TestChargeTaxLot(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'ChargeCust'})
|
||||
cls.tax13 = cls.env['account.tax'].create({
|
||||
'name': 'FP Test 13%',
|
||||
'amount': 13.0,
|
||||
'amount_type': 'percent',
|
||||
'type_tax_use': 'sale',
|
||||
})
|
||||
|
||||
# ----- Task 1: charge type model -----
|
||||
def test_charge_type_quick_create_and_default(self):
|
||||
ct = self.env['fp.additional.charge.type'].create({
|
||||
'name': 'Rush Fee', 'default_amount': 75.0,
|
||||
})
|
||||
self.assertEqual(ct.name, 'Rush Fee')
|
||||
self.assertEqual(ct.default_amount, 75.0)
|
||||
cid, cname = self.env['fp.additional.charge.type'].name_create('Setup')
|
||||
self.assertTrue(cid)
|
||||
|
||||
# ----- Task 3: totals -----
|
||||
def _make_wizard(self, **kw):
|
||||
vals = {'partner_id': self.partner.id}
|
||||
vals.update(kw)
|
||||
return self.env['fp.direct.order.wizard'].create(vals)
|
||||
|
||||
def test_tax_applies_on_subtotal_plus_charge(self):
|
||||
wiz = self._make_wizard(charge_amount=100.0, tax_id=self.tax13.id)
|
||||
self.env['fp.direct.order.line'].create({
|
||||
'wizard_id': wiz.id, 'quantity': 1, 'unit_price': 50.0,
|
||||
})
|
||||
wiz.invalidate_recordset()
|
||||
self.assertEqual(wiz.total_subtotal, 50.0)
|
||||
self.assertAlmostEqual(wiz.total_tax, 19.5, places=2)
|
||||
self.assertAlmostEqual(wiz.total_amount, 169.5, places=2)
|
||||
|
||||
# ----- Task 4: lot pricing (order-level toggle) -----
|
||||
def test_lot_onchange_derives_unit_price(self):
|
||||
wiz = self._make_wizard(is_lot_order=True)
|
||||
line = self.env['fp.direct.order.line'].new({
|
||||
'wizard_id': wiz.id, 'quantity': 500, 'lot_total': 1000.0,
|
||||
})
|
||||
line._onchange_lot_pricing()
|
||||
self.assertEqual(line.unit_price, 2.0)
|
||||
|
||||
def test_lot_onchange_noop_when_not_lot_order(self):
|
||||
# Editing lot_total on a non-lot order must NOT touch unit_price.
|
||||
wiz = self._make_wizard()
|
||||
line = self.env['fp.direct.order.line'].new({
|
||||
'wizard_id': wiz.id, 'quantity': 500, 'lot_total': 1000.0,
|
||||
'unit_price': 7.0,
|
||||
})
|
||||
line._onchange_lot_pricing()
|
||||
self.assertEqual(line.unit_price, 7.0)
|
||||
|
||||
def test_lot_order_toggle_rederives_line_prices(self):
|
||||
wiz = self._make_wizard()
|
||||
self.env['fp.direct.order.line'].create({
|
||||
'wizard_id': wiz.id, 'quantity': 500, 'lot_total': 1000.0,
|
||||
'unit_price': 0.0,
|
||||
})
|
||||
wiz.is_lot_order = True
|
||||
wiz._onchange_is_lot_order()
|
||||
self.assertEqual(wiz.line_ids.unit_price, 2.0)
|
||||
|
||||
def test_lot_line_subtotal_uses_lot_total(self):
|
||||
# On a lot order, the summary subtotal uses the flat lot_total
|
||||
# per line (not qty × unit_price), even if unit_price is 0.
|
||||
wiz = self._make_wizard(tax_id=self.tax13.id, is_lot_order=True)
|
||||
self.env['fp.direct.order.line'].create({
|
||||
'wizard_id': wiz.id, 'quantity': 500, 'lot_total': 1000.0,
|
||||
'unit_price': 0.0,
|
||||
})
|
||||
wiz.invalidate_recordset()
|
||||
self.assertEqual(wiz.total_subtotal, 1000.0)
|
||||
@@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Express Orders - Task A2 schema tests
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_express')
|
||||
class TestExpressLineFields(TransactionCase):
|
||||
def test_new_fields_exist(self):
|
||||
Line = self.env['fp.direct.order.line']
|
||||
self.assertIn('customer_line_ref', Line._fields)
|
||||
self.assertIn('masking_enabled', Line._fields)
|
||||
self.assertIn('bake_instructions', Line._fields)
|
||||
|
||||
def test_masking_default_true(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Cust'})
|
||||
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': partner.id})
|
||||
line = self.env['fp.direct.order.line'].create({
|
||||
'wizard_id': wiz.id,
|
||||
'quantity': 1,
|
||||
})
|
||||
self.assertTrue(line.masking_enabled)
|
||||
self.assertFalse(line.customer_line_ref)
|
||||
self.assertFalse(line.bake_instructions)
|
||||
@@ -1,24 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Express Orders - Task A1 schema tests
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_express')
|
||||
class TestExpressPartDefaults(TransactionCase):
|
||||
def test_new_fields_exist(self):
|
||||
Part = self.env['fp.part.catalog']
|
||||
self.assertIn('default_specification_text', Part._fields)
|
||||
self.assertIn('default_bake_instructions', Part._fields)
|
||||
self.assertIn('default_masking_enabled', Part._fields)
|
||||
|
||||
def test_default_masking_enabled_default_value(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Test Customer'})
|
||||
part = self.env['fp.part.catalog'].create({
|
||||
'partner_id': partner.id,
|
||||
'part_number': 'TEST-001',
|
||||
'revision': 'A',
|
||||
'name': 'Test Part',
|
||||
})
|
||||
self.assertTrue(part.default_masking_enabled)
|
||||
self.assertFalse(part.default_specification_text)
|
||||
self.assertFalse(part.default_bake_instructions)
|
||||
@@ -1,17 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Express Orders - Task A4 schema tests
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_express')
|
||||
class TestExpressSaleOrderFields(TransactionCase):
|
||||
def test_new_fields_exist(self):
|
||||
SO = self.env['sale.order']
|
||||
self.assertIn('x_fc_material_process', SO._fields)
|
||||
self.assertIn('x_fc_internal_notes', SO._fields)
|
||||
self.assertIn('x_fc_print_terms', SO._fields)
|
||||
|
||||
def test_print_terms_default_true(self):
|
||||
partner = self.env['res.partner'].create({'name': 'C'})
|
||||
so = self.env['sale.order'].create({'partner_id': partner.id})
|
||||
self.assertTrue(so.x_fc_print_terms)
|
||||
@@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Express Orders - Task A3 schema tests
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_express')
|
||||
class TestExpressSoLineFields(TransactionCase):
|
||||
def test_new_fields_exist(self):
|
||||
Line = self.env['sale.order.line']
|
||||
self.assertIn('x_fc_customer_line_ref', Line._fields)
|
||||
self.assertIn('x_fc_masking_enabled', Line._fields)
|
||||
self.assertIn('x_fc_bake_instructions', Line._fields)
|
||||
|
||||
def test_masking_default_true(self):
|
||||
partner = self.env['res.partner'].create({'name': 'C'})
|
||||
product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'FP-SERVICE')], limit=1
|
||||
) or self.env['product.product'].create({
|
||||
'name': 'svc', 'type': 'service', 'default_code': 'FP-SERVICE',
|
||||
})
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
|
||||
})
|
||||
line = so.order_line[:1]
|
||||
self.assertTrue(line.x_fc_masking_enabled)
|
||||
self.assertFalse(line.x_fc_customer_line_ref)
|
||||
self.assertFalse(line.x_fc_bake_instructions)
|
||||
@@ -1,31 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Express Orders - Task A5 schema tests
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_express')
|
||||
class TestExpressWizardFields(TransactionCase):
|
||||
def test_new_fields_exist(self):
|
||||
Wiz = self.env['fp.direct.order.wizard']
|
||||
for fname in (
|
||||
'material_process', 'pricelist_id', 'validity_date',
|
||||
'internal_notes', 'terms_and_conditions', 'view_source',
|
||||
):
|
||||
self.assertIn(fname, Wiz._fields, msg=f'Missing field: {fname}')
|
||||
|
||||
def test_old_notes_retired(self):
|
||||
Wiz = self.env['fp.direct.order.wizard']
|
||||
# `notes` was renamed to terms_and_conditions in the pre-migration.
|
||||
self.assertNotIn('notes', Wiz._fields)
|
||||
|
||||
def test_view_source_default_express(self):
|
||||
partner = self.env['res.partner'].create({'name': 'C'})
|
||||
wiz = self.env['fp.direct.order.wizard'].create({'partner_id': partner.id})
|
||||
self.assertEqual(wiz.view_source, 'express')
|
||||
|
||||
def test_currency_id_is_related_from_pricelist(self):
|
||||
Wiz = self.env['fp.direct.order.wizard']
|
||||
currency_field = Wiz._fields.get('currency_id')
|
||||
self.assertIsNotNone(currency_field)
|
||||
# Stored related from pricelist_id.currency_id
|
||||
self.assertEqual(currency_field.related, ('pricelist_id', 'currency_id'))
|
||||
@@ -1,84 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Per-part description history (spec 2026-05-29)."""
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_desc_history')
|
||||
class TestPartDescriptionHistory(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'DescCust'})
|
||||
cls.part = cls.env['fp.part.catalog'].create({
|
||||
'partner_id': cls.partner.id,
|
||||
'part_number': 'DH-001',
|
||||
'revision': 'A',
|
||||
'name': 'Desc Part',
|
||||
})
|
||||
|
||||
def _mk_version(self, internal, customer, **kw):
|
||||
vals = {
|
||||
'part_catalog_id': self.part.id,
|
||||
'internal_description': internal,
|
||||
'customer_facing_description': customer,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.part.description.version'].create(vals)
|
||||
|
||||
# ----- Task 1: model invariants -----
|
||||
def test_version_no_increments_and_is_latest_flips(self):
|
||||
v1 = self._mk_version('int 1', 'cust 1')
|
||||
v2 = self._mk_version('int 2', 'cust 2')
|
||||
self.assertEqual(v1.version_no, 1)
|
||||
self.assertEqual(v2.version_no, 2)
|
||||
self.assertFalse(v1.is_latest)
|
||||
self.assertTrue(v2.is_latest)
|
||||
|
||||
def test_name_uses_order_and_date(self):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
v = self._mk_version('i', 'c', sale_order_id=so.id,
|
||||
source_date='2026-05-29')
|
||||
self.assertIn(so.name, v.name)
|
||||
self.assertIn('2026-05-29', v.name)
|
||||
|
||||
# ----- Task 2: part helpers -----
|
||||
def test_resolve_falls_back_to_default_spec(self):
|
||||
self.part.default_specification_text = 'legacy cust'
|
||||
descs = self.part._fp_resolve_line_descriptions()
|
||||
self.assertEqual(descs['customer_facing'], 'legacy cust')
|
||||
self.assertEqual(descs['internal'], '')
|
||||
|
||||
def test_resolve_prefers_latest_version(self):
|
||||
self.part.default_specification_text = 'legacy cust'
|
||||
self._mk_version('hist int', 'hist cust')
|
||||
descs = self.part._fp_resolve_line_descriptions()
|
||||
self.assertEqual(descs['customer_facing'], 'hist cust')
|
||||
self.assertEqual(descs['internal'], 'hist int')
|
||||
|
||||
def test_save_dedups_when_unchanged(self):
|
||||
self.part._fp_save_description_version('i', 'c')
|
||||
self.part._fp_save_description_version('i', 'c') # identical
|
||||
self.assertEqual(
|
||||
self.env['fp.part.description.version'].search_count(
|
||||
[('part_catalog_id', '=', self.part.id)]), 1)
|
||||
|
||||
def test_save_creates_new_on_change_and_syncs_default(self):
|
||||
self.part._fp_save_description_version('i1', 'c1')
|
||||
self.part._fp_save_description_version('i1', 'c2') # changed
|
||||
versions = self.env['fp.part.description.version'].search(
|
||||
[('part_catalog_id', '=', self.part.id)])
|
||||
self.assertEqual(len(versions), 2)
|
||||
self.assertEqual(self.part.default_specification_text, 'c2')
|
||||
|
||||
# ----- Task 5: SO line auto-load -----
|
||||
def test_so_line_onchange_loads_latest_version(self):
|
||||
self.part._fp_save_description_version('shop notes', 'cust spec')
|
||||
line = self.env['sale.order.line'].new({
|
||||
'x_fc_part_catalog_id': self.part.id,
|
||||
})
|
||||
line._fp_onchange_part_load_description()
|
||||
self.assertEqual(line.name, 'cust spec')
|
||||
self.assertEqual(line.x_fc_internal_description, 'shop notes')
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fp_additional_charge_type_list" model="ir.ui.view">
|
||||
<field name="name">fp.additional.charge.type.list</field>
|
||||
<field name="model">fp.additional.charge.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="default_amount" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_additional_charge_type" model="ir.actions.act_window">
|
||||
<field name="name">Additional Charge Types</field>
|
||||
<field name="res_model">fp.additional.charge.type</field>
|
||||
<field name="view_mode">list</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_additional_charge_type"
|
||||
name="Additional Charge Types"
|
||||
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||
action="action_fp_additional_charge_type"
|
||||
sequence="30"/>
|
||||
</odoo>
|
||||
@@ -1,106 +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>
|
||||
|
||||
<!-- ===== Window actions (must be defined before menus reference them) ===== -->
|
||||
<record id="action_fp_customers" model="ir.actions.act_window">
|
||||
<field name="name">Customers</field>
|
||||
<field name="res_model">res.partner</field>
|
||||
<field name="view_mode">list,form,kanban</field>
|
||||
<field name="domain">[('customer_rank', '>', 0)]</field>
|
||||
<field name="context">{'default_customer_rank': 1}</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
The Plating app's landing screen is now resolved by a server
|
||||
action defined in fusion_plating core (Phase 1):
|
||||
user.x_fc_plating_landing_action_id →
|
||||
company.x_fc_default_landing_action_id →
|
||||
action_fp_sale_orders fallback
|
||||
See fusion_plating/data/fp_landing_data.xml.
|
||||
-->
|
||||
|
||||
<!-- ===== SALES submenu under Fusion Plating root ===== -->
|
||||
<menuitem id="menu_fp_sales"
|
||||
name="Sales"
|
||||
parent="fusion_plating.menu_fp_root"
|
||||
sequence="5"
|
||||
groups="fusion_plating.group_fp_sales_rep"/>
|
||||
|
||||
<!-- === New Quote - top-of-menu entry point for a fresh quote === -->
|
||||
<menuitem id="menu_fp_new_quote"
|
||||
name="New Quote"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_quote_configurator"
|
||||
sequence="1"/>
|
||||
|
||||
<menuitem id="menu_fp_direct_order"
|
||||
name="New Direct Order"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_direct_order_wizard"
|
||||
sequence="5"/>
|
||||
|
||||
<menuitem id="menu_fp_direct_order_drafts"
|
||||
name="Direct Order Drafts"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_direct_order_drafts"
|
||||
sequence="6"/>
|
||||
|
||||
<menuitem id="menu_fp_quotations"
|
||||
name="Quotations"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_quotations"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_fp_sale_orders"
|
||||
name="Sale Orders"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_sale_orders"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_fp_customers"
|
||||
name="Customers"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_customers"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_fp_part_catalog"
|
||||
name="Part Catalog"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_part_catalog"
|
||||
sequence="40"/>
|
||||
|
||||
<menuitem id="menu_fp_part_catalog_import"
|
||||
name="Import Parts (CSV)"
|
||||
parent="menu_fp_sales"
|
||||
action="action_fp_part_catalog_import_wizard"
|
||||
sequence="45"/>
|
||||
|
||||
<!-- The Configurator top-level menu was retired in Phase F (2026-05-15)
|
||||
after the Promote Customer Spec refactor left only 3 admin items
|
||||
under it. They've been re-homed into the Configuration hub's
|
||||
themed folders, where managers expect to find admin records:
|
||||
Pricing Rules → Configuration → Pricing & Billing
|
||||
Materials → Configuration → Materials & Tanks
|
||||
Line Desc Tpl → Configuration → Quality & Documents (in
|
||||
fp_sale_description_template_views.xml)
|
||||
-->
|
||||
<menuitem id="menu_fp_pricing_rules"
|
||||
name="Pricing Rules"
|
||||
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||
action="action_fp_pricing_rule"
|
||||
sequence="40"
|
||||
groups="group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
<menuitem id="menu_fp_part_materials"
|
||||
name="Materials"
|
||||
parent="fusion_plating.menu_fp_config_materials_tanks"
|
||||
action="action_fp_part_material"
|
||||
sequence="40"
|
||||
groups="group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,413 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================
|
||||
Express Orders form view (2026-05-26 - rebuild #2)
|
||||
|
||||
Uses raw <div> + CSS Grid for the header to match the mockup's
|
||||
4-column flat grid layout. Odoo's <group col="4"> renders as
|
||||
an HTML table with broken cells when colspan + nested groups
|
||||
are involved - switching to manual divs.
|
||||
|
||||
Same model (fp.direct.order.wizard) as the legacy view.
|
||||
============================================================ -->
|
||||
|
||||
<record id="view_fp_express_order_form" model="ir.ui.view">
|
||||
<field name="name">fp.express.order.form</field>
|
||||
<field name="model">fp.direct.order.wizard</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Express Order Entry" class="o_fp_xpr">
|
||||
<header>
|
||||
<button name="action_create_order" type="object"
|
||||
string="Confirm Order"
|
||||
class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_view_sale_order" type="object"
|
||||
string="Open Sale Order"
|
||||
class="btn-primary"
|
||||
invisible="state != 'confirmed' or not sale_order_id"/>
|
||||
<button name="action_switch_to_legacy" type="object"
|
||||
string="Switch to Legacy View"
|
||||
class="btn-secondary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_cancel" type="object"
|
||||
string="Discard Draft"
|
||||
confirm="Mark this draft as cancelled? The data is preserved for audit."
|
||||
invisible="state != 'draft'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,confirmed"/>
|
||||
</header>
|
||||
|
||||
<div class="alert alert-warning mb-0"
|
||||
role="alert"
|
||||
invisible="not missing_info_msg">
|
||||
<i class="fa fa-exclamation-triangle me-2"/>
|
||||
<field name="missing_info_msg" readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart"
|
||||
invisible="not sale_order_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oe_title">
|
||||
<label for="name" class="o_form_label"/>
|
||||
<h1 class="d-flex align-items-center gap-2">
|
||||
<field name="name" readonly="1"/>
|
||||
<span class="o_fp_xpr_pill">EXPRESS</span>
|
||||
</h1>
|
||||
<field name="view_source" invisible="1"/>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
HEADER GRID - pure CSS Grid (4 cols × 4 rows)
|
||||
========================================================= -->
|
||||
<div class="o_fp_xpr_grid">
|
||||
|
||||
<!-- ROW 1 -->
|
||||
<div class="o_fp_xpr_cell span-2 required">
|
||||
<label for="partner_id">Customer</label>
|
||||
<field name="partner_id" nolabel="1"
|
||||
options="{'no_create_edit': True}"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell span-2">
|
||||
<label for="partner_shipping_id">Shipping Address</label>
|
||||
<field name="partner_shipping_id" nolabel="1"
|
||||
options="{'no_create_edit': False}"
|
||||
invisible="not partner_id"/>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
PO Block fills LEFT half (cols 1-2) across rows 2-7.
|
||||
RIGHT half (cols 3-4) flows 6 pairs of fields
|
||||
alongside it - Customer Job #/Job Sorting, Material
|
||||
Process/Lead Time, Payment Terms/Delivery Method,
|
||||
Pricelist/Quote Validity, Blanket SO/Invoice Strategy,
|
||||
Sales Rep/conditional Deposit-or-Progress %.
|
||||
|
||||
Net: PO block height matches 6 × ~60px right stack -
|
||||
no dead air on either side.
|
||||
============================================================ -->
|
||||
<div class="o_fp_xpr_cell span-2 row-span-6 o_fp_xpr_po_block">
|
||||
<div class="o_fp_xpr_po_head">
|
||||
<span>CUSTOMER PO</span>
|
||||
<field name="po_status" widget="badge"
|
||||
decoration-success="po_status == 'received'"
|
||||
decoration-warning="po_status == 'pending'"
|
||||
decoration-danger="po_status == 'missing'"
|
||||
nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_po_row">
|
||||
<label for="po_number">PO #</label>
|
||||
<field name="po_number" nolabel="1"
|
||||
placeholder="Enter the customer PO number"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_po_row">
|
||||
<label for="po_attachment_file">PDF</label>
|
||||
<field name="po_attachment_file" nolabel="1"
|
||||
filename="po_attachment_filename"/>
|
||||
<field name="po_attachment_filename" invisible="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_po_row">
|
||||
<label for="po_pending">PO Pending</label>
|
||||
<field name="po_pending" nolabel="1"
|
||||
widget="boolean_toggle"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_po_row" invisible="not po_pending">
|
||||
<label for="po_expected_date">Expected By</label>
|
||||
<field name="po_expected_date" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_po_chase" invisible="not po_pending">
|
||||
<i class="fa fa-clock-o me-1"/>
|
||||
Order will confirm without a PO. A chase activity
|
||||
will fire on the expected date.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side row 2: Customer Job # + Job Sorting -->
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="customer_job_number">Customer Job #</label>
|
||||
<field name="customer_job_number" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="job_sort_id">Job Sorting</label>
|
||||
<field name="job_sort_id" nolabel="1"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</div>
|
||||
|
||||
<!-- Right side row 3: Material/Process + Lead Time -->
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="material_process">Material / Process</label>
|
||||
<field name="material_process" nolabel="1"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Pick a recipe..."/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="lead_time_min_days">Lead Time (days)</label>
|
||||
<div class="o_fp_xpr_range">
|
||||
<field name="lead_time_min_days" nolabel="1"
|
||||
class="o_fp_xpr_range_input"/>
|
||||
<span class="o_fp_xpr_range_sep">to</span>
|
||||
<field name="lead_time_max_days" nolabel="1"
|
||||
class="o_fp_xpr_range_input"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side row 4: Payment Terms + Delivery Method -->
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="payment_term_id">Payment Terms</label>
|
||||
<field name="payment_term_id" nolabel="1"
|
||||
options="{'no_create': True}"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="delivery_method">Delivery Method</label>
|
||||
<field name="delivery_method" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Right side row 5: Pricelist + Quote Validity -->
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="pricelist_id">Currency / Pricelist</label>
|
||||
<field name="pricelist_id" nolabel="1"
|
||||
context="{'fp_express_currency_picker': True}"
|
||||
options="{'no_create_edit': True}"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="validity_date">Quote Validity</label>
|
||||
<field name="validity_date" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Right side row 6: Blanket Sales Order + Invoice Strategy -->
|
||||
<div class="o_fp_xpr_cell o_fp_xpr_inline_label">
|
||||
<label for="is_blanket_order">Blanket Sales Order</label>
|
||||
<div class="o_fp_xpr_inline_pair">
|
||||
<field name="is_blanket_order" nolabel="1"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="block_partial_shipments" nolabel="1"
|
||||
widget="boolean_toggle"
|
||||
invisible="not is_blanket_order"/>
|
||||
<span class="o_fp_xpr_inline_help"
|
||||
invisible="not is_blanket_order">Block partial</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="invoice_strategy">Invoice Strategy</label>
|
||||
<field name="invoice_strategy" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Right side row 7: Sales Rep + conditional Deposit/Progress % -->
|
||||
<div class="o_fp_xpr_cell">
|
||||
<label for="user_id">Sales Rep</label>
|
||||
<field name="user_id" nolabel="1"
|
||||
readonly="state != 'draft'"
|
||||
options="{'no_create': True}"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell" invisible="invoice_strategy != 'deposit'">
|
||||
<label for="deposit_percent">Deposit %</label>
|
||||
<field name="deposit_percent" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_cell" invisible="invoice_strategy != 'progress'">
|
||||
<label for="progress_initial_percent">Progress Initial %</label>
|
||||
<field name="progress_initial_percent" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
ORDER LINES - spreadsheet
|
||||
========================================================= -->
|
||||
<div class="o_fp_xpr_section_title">Order Lines</div>
|
||||
|
||||
<!-- Legend bar - like the mockup -->
|
||||
<div class="o_fp_xpr_legend">
|
||||
<span><strong>Mask ✓</strong> include all masking + de-masking recipe steps</span>
|
||||
<span><strong>Bake pill</strong> click to type bake instruction (empty = skip bake)</span>
|
||||
<span><strong>DWG</strong> upload drawing to part</span>
|
||||
<span><strong>OPEN</strong> open the part record</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 d-flex gap-2">
|
||||
<button name="action_add_from_prior_so"
|
||||
type="object"
|
||||
string="+ Add From Prior SO"
|
||||
class="btn-secondary btn-sm"
|
||||
invisible="not partner_id"/>
|
||||
<button name="action_add_from_quotes"
|
||||
type="object"
|
||||
string="+ Add From Quotes"
|
||||
class="btn-secondary btn-sm"
|
||||
invisible="not partner_id"/>
|
||||
</div>
|
||||
<field name="line_ids" class="o_fp_xpr_lines">
|
||||
<list editable="bottom"
|
||||
decoration-warning="is_missing_info">
|
||||
<field name="is_missing_info" column_invisible="1"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<!-- The multi-row Part cell. Owns part_catalog_id picker
|
||||
PLUS displays part_revision_display, part_name_display,
|
||||
serial_ids + the inline + bulk button. -->
|
||||
<field name="part_catalog_id"
|
||||
string="Part Number"
|
||||
widget="fp_express_part_cell"
|
||||
width="230px"
|
||||
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A', 'fp_express_part_picker': True}"
|
||||
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<!-- Hidden related fields the widget reads. Must be on the
|
||||
list so they're prefetched per row. -->
|
||||
<field name="part_number_display" column_invisible="1"/>
|
||||
<field name="part_revision_display" column_invisible="1"/>
|
||||
<field name="part_name_display" column_invisible="1"/>
|
||||
<!-- Writable bridges used by the Part cell widget for
|
||||
editable rows 2 (description) and 3 (serials). -->
|
||||
<field name="part_name_editable" column_invisible="1"/>
|
||||
<field name="serials_text" column_invisible="1"/>
|
||||
<field name="serial_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||
domain="[('part_id', '=', part_catalog_id)]"
|
||||
column_invisible="1"/>
|
||||
<!-- Specification + Internal Notes: NO width attr, let them grow with available space -->
|
||||
<field name="line_description" string="Specification (Customer-Facing)"/>
|
||||
<field name="customer_line_ref" string="Line Job #" placeholder="ABC" width="80px"/>
|
||||
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010" width="100px"/>
|
||||
<field name="masking_enabled" string="Mask" widget="boolean_toggle" width="55px"/>
|
||||
<field name="masking_attachment_ids" column_invisible="1"/>
|
||||
<!-- Bake pill - click to edit -->
|
||||
<field name="bake_instructions"
|
||||
string="Bake"
|
||||
widget="fp_express_bake_pill"
|
||||
width="120px"/>
|
||||
<field name="internal_description" string="Internal Notes" optional="show"/>
|
||||
<field name="quantity" string="Qty" width="55px"/>
|
||||
<field name="unit_price"
|
||||
string="Price"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
width="80px"/>
|
||||
<field name="line_subtotal"
|
||||
string="Subtotal"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
sum="Total"
|
||||
width="90px"/>
|
||||
<!-- Stacked DWG / OPEN buttons in ONE column -->
|
||||
<field name="action_btns_anchor"
|
||||
string=" "
|
||||
widget="fp_express_action_btns"
|
||||
width="60px"/>
|
||||
<field name="process_variant_id"
|
||||
string="Process / Recipe"
|
||||
options="{'no_quick_create': True}"
|
||||
invisible="not part_catalog_id"
|
||||
optional="hide"/>
|
||||
<field name="tax_ids"
|
||||
string="Tax"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
optional="hide"
|
||||
width="110px"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
|
||||
<!-- =========================================================
|
||||
FOOTER GRID - Notes/Terms left + Totals right
|
||||
========================================================= -->
|
||||
<div class="o_fp_xpr_footer">
|
||||
|
||||
<div class="o_fp_xpr_footer_left">
|
||||
<div class="o_fp_xpr_card">
|
||||
<div class="o_fp_xpr_card_title">Order-Level Internal Notes</div>
|
||||
<div class="o_fp_xpr_card_sub">Visible only to estimator / planner / shop. Never prints.</div>
|
||||
<field name="internal_notes" nolabel="1"
|
||||
placeholder="Type internal notes..."/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_card">
|
||||
<div class="o_fp_xpr_card_title">Terms & Conditions
|
||||
<span class="o_fp_xpr_chip">PRINTS</span>
|
||||
</div>
|
||||
<div class="o_fp_xpr_card_sub">Customer-facing - prints on quote / SO / invoice.</div>
|
||||
<field name="terms_and_conditions" nolabel="1"
|
||||
placeholder="Customer-facing terms..."/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_xpr_footer_right">
|
||||
<div class="o_fp_xpr_card o_fp_xpr_totals">
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Subtotal</span>
|
||||
<field name="total_subtotal"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Tax</span>
|
||||
<field name="total_tax"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Tooling Charge</span>
|
||||
<field name="tooling_charge"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Total Lines</span>
|
||||
<field name="total_line_count" readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Total Quantity</span>
|
||||
<field name="total_qty" readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row o_fp_xpr_grand">
|
||||
<span class="o_fp_xpr_total_label">Grand Total</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<field name="total_amount"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
readonly="1" nolabel="1"/>
|
||||
<field name="currency_id"
|
||||
readonly="1" nolabel="1"
|
||||
class="o_fp_xpr_currency_pill"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ====== Action + Menu ====== -->
|
||||
<record id="action_fp_express_order" model="ir.actions.act_window">
|
||||
<field name="name">+ New Express Order</field>
|
||||
<field name="res_model">fp.direct.order.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fp_express_order_form"/>
|
||||
<field name="target">current</field>
|
||||
<field name="context">{'default_view_source': 'express'}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_express_order"
|
||||
name="+ New Express Order"
|
||||
parent="fusion_plating_configurator.menu_fp_sales"
|
||||
action="action_fp_express_order"
|
||||
sequence="3"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,386 +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>
|
||||
|
||||
<!-- ===== Part Catalog List View ===== -->
|
||||
<record id="view_fp_part_catalog_list" model="ir.ui.view">
|
||||
<field name="name">fp.part.catalog.list</field>
|
||||
<field name="model">fp.part.catalog</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Part Catalog" decoration-muted="not active">
|
||||
<field name="part_number"/>
|
||||
<field name="name" string="Part Name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="revision"/>
|
||||
<field name="material_id" string="Material"/>
|
||||
<field name="substrate_material" optional="hide"/>
|
||||
<field name="surface_area"/>
|
||||
<field name="complexity"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Part Catalog Form View ===== -->
|
||||
<record id="view_fp_part_catalog_form" model="ir.ui.view">
|
||||
<field name="name">fp.part.catalog.form</field>
|
||||
<field name="model">fp.part.catalog</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Part Catalog">
|
||||
<header>
|
||||
<button name="action_open_revision_wizard"
|
||||
string="Create New Revision"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-code-fork"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_customer"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-user"
|
||||
invisible="not partner_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Customer</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_sale_orders"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-text-o"
|
||||
invisible="sale_order_count == 0">
|
||||
<field name="sale_order_count" widget="statinfo" string="Sale Orders"/>
|
||||
</button>
|
||||
<button name="action_view_workorders"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cogs"
|
||||
invisible="workorder_count == 0">
|
||||
<field name="workorder_count" widget="statinfo" string="Work Orders"/>
|
||||
</button>
|
||||
<button name="action_view_configurators"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-sliders"
|
||||
invisible="configurator_count == 0">
|
||||
<field name="configurator_count" widget="statinfo" string="Quotes"/>
|
||||
</button>
|
||||
<button name="action_view_revisions"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-code-fork"
|
||||
invisible="revision_count < 2">
|
||||
<field name="revision_count" widget="statinfo" string="Revisions"/>
|
||||
</button>
|
||||
<button name="%(action_fp_sale_description_template)d"
|
||||
type="action"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-text-o"
|
||||
context="{'search_default_part_catalog_id': id, 'default_part_catalog_id': id}">
|
||||
<field name="description_template_count" widget="statinfo" string="Descriptions"/>
|
||||
</button>
|
||||
<!-- Sub 3 follow-up: Process smart button. Click
|
||||
opens the part-scoped Process Composer so
|
||||
users have a one-tap path from any part
|
||||
form to its process tree. -->
|
||||
<button name="action_open_part_composer"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-sitemap">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value"
|
||||
invisible="default_process_id">
|
||||
None
|
||||
</span>
|
||||
<span class="o_stat_value text-success"
|
||||
invisible="not default_process_id">
|
||||
<i class="fa fa-check-circle"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Process</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<widget name="web_ribbon" title="Superseded" bg_color="text-bg-warning" invisible="is_latest_revision"/>
|
||||
<div class="oe_title">
|
||||
<label for="part_number" string="Part Number"/>
|
||||
<h1><field name="part_number" placeholder="e.g. VS-R392007E01"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="name" string="Part Name"
|
||||
placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="revision"/>
|
||||
<field name="is_latest_revision" invisible="1"/>
|
||||
<field name="parent_part_id" invisible="not parent_part_id"/>
|
||||
</group>
|
||||
<group string="Manufacturing Defaults">
|
||||
<field name="material_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="substrate_material" invisible="1"/>
|
||||
<field name="x_fc_default_lead_time_days"/>
|
||||
<field name="certificate_requirement"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Quality & Delivery moved into its own notebook tab below
|
||||
(was a top-level group above the notebook). -->
|
||||
<!-- Auto-extracted geometry from 3D model -->
|
||||
<group string="3D Model Analysis"
|
||||
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
|
||||
<group>
|
||||
<field name="bbox_summary_in" readonly="1"/>
|
||||
<field name="volume_mm3" readonly="1"/>
|
||||
<field name="bbox_length_mm" invisible="1"/>
|
||||
<field name="bbox_width_mm" invisible="1"/>
|
||||
<field name="bbox_height_mm" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="hole_count" readonly="1"/>
|
||||
<field name="hole_summary" readonly="1" invisible="not hole_summary"/>
|
||||
<field name="is_manifold" widget="boolean_toggle" readonly="1"/>
|
||||
</group>
|
||||
<div class="alert alert-warning mb-0"
|
||||
colspan="2"
|
||||
invisible="is_manifold or not model_attachment_id">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<strong>Geometry warning:</strong> 3D model is not watertight (manifold).
|
||||
This often indicates open shells or invalid surfaces. Review before quoting.
|
||||
</div>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Process" name="process">
|
||||
<group>
|
||||
<field name="default_process_id" readonly="1"
|
||||
help="The variant used by default when an order line does not pick another."/>
|
||||
<field name="process_variant_count" readonly="1"/>
|
||||
</group>
|
||||
<div class="mt-2">
|
||||
<button name="action_open_part_composer" type="object"
|
||||
string="Compose"
|
||||
icon="fa-wrench"
|
||||
class="btn-primary"
|
||||
help="Open the Process Composer to manage this part's process variants."/>
|
||||
<button name="action_open_default_simple_editor" type="object"
|
||||
string="Edit Default (Simple)"
|
||||
icon="fa-list-alt"
|
||||
class="btn-info ms-1"
|
||||
invisible="not default_process_id"
|
||||
help="Jump straight to the Simple Recipe Editor for the default variant - flat 2-pane drag-drop layout."/>
|
||||
<button name="action_open_default_tree_editor" type="object"
|
||||
string="Edit Default (Tree)"
|
||||
icon="fa-sitemap"
|
||||
class="btn-secondary ms-1"
|
||||
invisible="not default_process_id"
|
||||
help="Jump straight to the Tree Editor for the default variant."/>
|
||||
</div>
|
||||
<p class="text-muted mt-3">
|
||||
The <strong>Compose</strong> button opens the Process Composer where you can add
|
||||
multiple process <em>variants</em> for this part - for example "Standard ENP",
|
||||
"Selective Masking", "Rework". One variant is flagged as default; estimators
|
||||
may pick a different variant on a per-order basis. Each variant can be edited
|
||||
in either the <strong>Tree</strong> or <strong>Simple</strong> view - same data,
|
||||
two layouts.
|
||||
</p>
|
||||
<field name="process_variant_ids" readonly="1">
|
||||
<list>
|
||||
<field name="is_default_variant" widget="boolean_toggle" readonly="1"/>
|
||||
<field name="variant_label"/>
|
||||
<field name="name"/>
|
||||
<field name="estimated_duration" optional="hide"/>
|
||||
<button name="action_open_simple_editor" type="object"
|
||||
string="Simple" icon="fa-list-alt"
|
||||
class="btn-link"/>
|
||||
<button name="action_open_tree_editor" type="object"
|
||||
string="Tree" icon="fa-sitemap"
|
||||
class="btn-link"/>
|
||||
</list>
|
||||
</field>
|
||||
<!-- Default Specification picker added by
|
||||
fusion_plating_quality view inherit. -->
|
||||
<separator string="Default Thickness" class="mt-4"/>
|
||||
<group>
|
||||
<field name="x_fc_default_thickness_range"
|
||||
placeholder="e.g. 0.0005-0.0008 mils"/>
|
||||
</group>
|
||||
<p class="text-muted">
|
||||
Defaults pre-fill new direct-order lines
|
||||
for this part. Thickness also auto-fills
|
||||
from the most recent order for the same
|
||||
(part, customer) pair when one exists.
|
||||
</p>
|
||||
</page>
|
||||
<page string="Dimensions & Complexity" name="dimensions">
|
||||
<group>
|
||||
<field name="geometry_source"/>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Surface & Weight">
|
||||
<label for="surface_area"/>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<field name="surface_area" class="oe_inline"/>
|
||||
<button name="action_calculate_surface_area" type="object"
|
||||
string="Calculate from 3D Model"
|
||||
class="btn-link" icon="fa-calculator"
|
||||
invisible="not model_attachment_id"/>
|
||||
</div>
|
||||
<field name="surface_area_uom"/>
|
||||
<field name="masking_area_sqin"/>
|
||||
<field name="effective_area_sqin" readonly="1"/>
|
||||
<field name="weight"/>
|
||||
<field name="material_weight_kg" readonly="1"/>
|
||||
</group>
|
||||
<group string="Bounding Box">
|
||||
<field name="dimensions_length"/>
|
||||
<field name="dimensions_width"/>
|
||||
<field name="dimensions_height"/>
|
||||
</group>
|
||||
<group string="Complexity">
|
||||
<field name="complexity"/>
|
||||
<field name="masking_zones"/>
|
||||
<field name="has_blind_holes"/>
|
||||
<field name="has_recesses"/>
|
||||
<field name="has_threads"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
|
||||
</page>
|
||||
<page string="Attachments" name="attachments">
|
||||
<group>
|
||||
<!-- Upload slot: Binary field that wraps the file
|
||||
in an ir.attachment on change. Hidden once a
|
||||
3D model is already attached. -->
|
||||
<label for="model_upload" string="Upload 3D Model"
|
||||
invisible="model_attachment_id"/>
|
||||
<div class="o_row" invisible="model_attachment_id">
|
||||
<field name="model_upload" nolabel="1"
|
||||
filename="model_upload_filename"
|
||||
class="oe_inline"/>
|
||||
<field name="model_upload_filename" invisible="1"/>
|
||||
<span class="text-muted ms-2 small">
|
||||
STEP / STP / STL / IGES / IGS / BREP
|
||||
</span>
|
||||
</div>
|
||||
<!-- Current attachment + remove affordance -->
|
||||
<label for="model_attachment_id" string="3D Model File"
|
||||
invisible="not model_attachment_id"/>
|
||||
<div class="o_row" invisible="not model_attachment_id">
|
||||
<field name="model_attachment_id" nolabel="1" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="drawing_attachment_ids" widget="fp_pdf_preview_binary"/>
|
||||
</group>
|
||||
<div invisible="not model_attachment_id" class="mt-3">
|
||||
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
||||
</div>
|
||||
</page>
|
||||
<page string="Descriptions" name="descriptions">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Canned descriptions for this part.</strong>
|
||||
Internal = what the shop floor sees on the WO / traveler.
|
||||
Customer-Facing = what prints on SO, invoice, packing slip.
|
||||
Whichever row the estimator picks on the order wizard lands both values on the SO line.
|
||||
</div>
|
||||
<field name="description_template_ids"
|
||||
context="{'default_part_catalog_id': id, 'default_partner_id': partner_id}">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name" placeholder="e.g. Standard, With threaded holes masked"/>
|
||||
<field name="tag" optional="show"/>
|
||||
<field name="internal_description" placeholder="What the shop floor sees on the WO / traveler"/>
|
||||
<field name="customer_facing_description" placeholder="What prints on SO, invoice, packing slip"/>
|
||||
<field name="usage_count" string="Used" optional="hide"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
<separator string="Description History (auto-saved per order)"/>
|
||||
<field name="description_version_ids" readonly="1">
|
||||
<list>
|
||||
<field name="version_no" string="#"/>
|
||||
<field name="name" string="Reference"/>
|
||||
<field name="customer_facing_description" string="Customer-Facing"/>
|
||||
<field name="sale_order_id" string="Order"/>
|
||||
<field name="create_uid" string="By"/>
|
||||
<field name="create_date" string="When"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Revision History" name="revisions"
|
||||
invisible="not parent_part_id and not revision_ids">
|
||||
<field name="revision_ids" mode="list">
|
||||
<list default_order="revision_date desc">
|
||||
<field name="revision"/>
|
||||
<field name="revision_note"/>
|
||||
<field name="revision_date"/>
|
||||
<field name="surface_area"/>
|
||||
<field name="model_attachment_id" string="3D Model"/>
|
||||
<field name="is_latest_revision" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Additional notes about this part..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Part Catalog Search View ===== -->
|
||||
<record id="view_fp_part_catalog_search" model="ir.ui.view">
|
||||
<field name="name">fp.part.catalog.search</field>
|
||||
<field name="model">fp.part.catalog</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="part_number"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="material_id" string="Material"/>
|
||||
<separator/>
|
||||
<filter string="Aluminium" name="material_aluminium" domain="[('substrate_material','=','aluminium')]"/>
|
||||
<filter string="Steel" name="material_steel" domain="[('substrate_material','=','steel')]"/>
|
||||
<filter string="Stainless Steel" name="material_stainless" domain="[('substrate_material','=','stainless')]"/>
|
||||
<filter string="Copper" name="material_copper" domain="[('substrate_material','=','copper')]"/>
|
||||
<filter string="Titanium" name="material_titanium" domain="[('substrate_material','=','titanium')]"/>
|
||||
<filter string="Other" name="material_other" domain="[('substrate_material','=','other')]"/>
|
||||
<separator/>
|
||||
<filter string="Simple" name="complexity_simple" domain="[('complexity','=','simple')]"/>
|
||||
<filter string="Moderate" name="complexity_moderate" domain="[('complexity','=','moderate')]"/>
|
||||
<filter string="Complex" name="complexity_complex" domain="[('complexity','=','complex')]"/>
|
||||
<filter string="Very Complex" name="complexity_very_complex" domain="[('complexity','=','very_complex')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
|
||||
<filter string="Material" name="group_material" context="{'group_by':'material_id'}"/>
|
||||
<filter string="Material Category" name="group_material_category" context="{'group_by':'substrate_material'}"/>
|
||||
<filter string="Complexity" name="group_complexity" context="{'group_by':'complexity'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Window Action ===== -->
|
||||
<record id="action_fp_part_catalog" model="ir.actions.act_window">
|
||||
<field name="name">Part Catalog</field>
|
||||
<field name="res_model">fp.part.catalog</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_part_catalog_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No parts in the catalog yet
|
||||
</p>
|
||||
<p>
|
||||
Add customer parts with geometry, material, and complexity data
|
||||
for instant re-quoting on repeat orders.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,92 +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>
|
||||
|
||||
<record id="view_fp_part_material_list" model="ir.ui.view">
|
||||
<field name="name">fp.part.material.list</field>
|
||||
<field name="model">fp.part.material</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Materials" editable="bottom" decoration-muted="not active">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<field name="density" string="Density (g/cm³)"/>
|
||||
<field name="notes" optional="show"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_part_material_form" model="ir.ui.view">
|
||||
<field name="name">fp.part.material.form</field>
|
||||
<field name="model">fp.part.material</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Material">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Material"/>
|
||||
<h1><field name="name" placeholder="e.g. Aluminium 6061"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="category"/>
|
||||
<field name="density"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="notes" placeholder="Alloy spec, source, supplier note..."/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="text-muted">
|
||||
Leave Density at 0 to use the category default
|
||||
(Aluminium 2.70, Steel 7.85, Stainless 8.00,
|
||||
Copper 8.96, Titanium 4.51 g/cm³).
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_part_material_search" model="ir.ui.view">
|
||||
<field name="name">fp.part.material.search</field>
|
||||
<field name="model">fp.part.material</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<separator/>
|
||||
<filter string="Aluminium" name="cat_aluminium" domain="[('category','=','aluminium')]"/>
|
||||
<filter string="Steel" name="cat_steel" domain="[('category','=','steel')]"/>
|
||||
<filter string="Stainless" name="cat_stainless" domain="[('category','=','stainless')]"/>
|
||||
<filter string="Copper" name="cat_copper" domain="[('category','=','copper')]"/>
|
||||
<filter string="Titanium" name="cat_titanium" domain="[('category','=','titanium')]"/>
|
||||
<filter string="Other" name="cat_other" domain="[('category','=','other')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Category" name="group_category" context="{'group_by':'category'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_part_material" model="ir.actions.act_window">
|
||||
<field name="name">Materials</field>
|
||||
<field name="res_model">fp.part.material</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_part_material_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No materials yet</p>
|
||||
<p>Define the materials your shop processes. Each material
|
||||
picks a category (Aluminium, Steel, etc.) used for pricing
|
||||
rules and density-based weight calculations.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,137 +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>
|
||||
|
||||
<!-- ===== Pricing Rule List View ===== -->
|
||||
<record id="view_fp_pricing_rule_list" model="ir.ui.view">
|
||||
<field name="name">fp.pricing.rule.list</field>
|
||||
<field name="model">fp.pricing.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Pricing Rules" decoration-muted="not active">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="substrate_material"/>
|
||||
<field name="certification_level"/>
|
||||
<field name="pricing_method"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="base_rate" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
||||
<field name="minimum_charge" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Pricing Rule Form View ===== -->
|
||||
<record id="view_fp_pricing_rule_form" model="ir.ui.view">
|
||||
<field name="name">fp.pricing.rule.form</field>
|
||||
<field name="model">fp.pricing.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Pricing Rule">
|
||||
<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 Aluminium - Commercial"/></h1>
|
||||
</div>
|
||||
<group string="Filters">
|
||||
<group>
|
||||
<field name="substrate_material"/>
|
||||
<field name="certification_level"/>
|
||||
</group>
|
||||
<group>
|
||||
<div class="text-muted" colspan="2">
|
||||
Leave filter fields blank to create a global rule
|
||||
that matches any configuration.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Pricing">
|
||||
<group>
|
||||
<field name="pricing_method"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="base_rate" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="thickness_factor"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="masking_rate_per_zone" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="setup_fee" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="minimum_charge" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<label for="rush_surcharge_percent"/>
|
||||
<div class="o_row">
|
||||
<field name="rush_surcharge_percent" nolabel="1" class="oe_inline"/>
|
||||
<span class="ms-1">%</span>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Complexity Surcharges" name="surcharges">
|
||||
<field name="complexity_surcharge_ids">
|
||||
<list editable="bottom">
|
||||
<field name="complexity"/>
|
||||
<field name="surcharge_percent" string="Surcharge %"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Internal notes about this pricing rule..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Pricing Rule Search View ===== -->
|
||||
<record id="view_fp_pricing_rule_search" model="ir.ui.view">
|
||||
<field name="name">fp.pricing.rule.search</field>
|
||||
<field name="model">fp.pricing.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<separator/>
|
||||
<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 Piece" name="per_piece" domain="[('pricing_method','=','per_piece')]"/>
|
||||
<filter string="Flat Rate" name="flat_rate" domain="[('pricing_method','=','flat_rate')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Window Action ===== -->
|
||||
<record id="action_fp_pricing_rule" model="ir.actions.act_window">
|
||||
<field name="name">Pricing Rules</field>
|
||||
<field name="res_model">fp.pricing.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_pricing_rule_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No pricing rules defined yet
|
||||
</p>
|
||||
<p>
|
||||
Define formula-based pricing rules matched by coating
|
||||
configuration, substrate material, and certification level.
|
||||
The first matching rule (by sequence) wins.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,118 +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.
|
||||
|
||||
Separates shared recipe templates from part-scoped clones in the
|
||||
backend so thousands of part clones don't bury the 5-10 real
|
||||
templates in the main Recipes list.
|
||||
|
||||
* Narrow the existing Process Recipes action to templates only
|
||||
(part_catalog_id = False).
|
||||
* Add a sibling "Part Processes" menu + action for audit /
|
||||
administration of every clone, grouped by part by default.
|
||||
* Extend the search view with template / part-scoped toggles
|
||||
so admins can cross between the two lists without switching
|
||||
menus.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ========== Extend search view: add part_catalog_id filters ========== -->
|
||||
<record id="view_fp_process_node_search_part_scoped"
|
||||
model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.search.part.scoped</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="inherit_id"
|
||||
ref="fusion_plating.view_fp_process_node_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='operations']" position="after">
|
||||
<separator/>
|
||||
<filter name="templates_only"
|
||||
string="Shared Templates"
|
||||
domain="[('part_catalog_id', '=', False)]"/>
|
||||
<filter name="part_scoped"
|
||||
string="Part-Scoped"
|
||||
domain="[('part_catalog_id', '!=', False)]"/>
|
||||
</xpath>
|
||||
<xpath expr="//filter[@name='group_wc']" position="after">
|
||||
<filter name="group_part" string="Part"
|
||||
context="{'group_by': 'part_catalog_id'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Extend form view: Linked Parts smart button ========== -->
|
||||
<!-- Smart button visible only on shared template recipes (no
|
||||
part_catalog_id). Opens the list of part-cloned recipe roots
|
||||
that were copied from this template. -->
|
||||
<record id="view_fp_process_node_form_linked_parts"
|
||||
model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.form.linked.parts</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="//div[@name='button_box']" position="inside">
|
||||
<button name="action_open_cloned_recipes" type="object"
|
||||
class="oe_stat_button" icon="fa-link"
|
||||
invisible="node_type != 'recipe' or part_catalog_id">
|
||||
<field name="cloned_recipe_count" widget="statinfo"
|
||||
string="Linked Parts"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Extend list view: surface part column ========== -->
|
||||
<record id="view_fp_process_node_tree_part_scoped"
|
||||
model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.tree.part.scoped</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="inherit_id"
|
||||
ref="fusion_plating.view_fp_process_node_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="part_catalog_id" string="Part" optional="show"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Narrow main Recipes action to templates only ========== -->
|
||||
<record id="fusion_plating.action_fp_process_recipe"
|
||||
model="ir.actions.act_window">
|
||||
<field name="domain">[('node_type', '=', 'recipe'), ('part_catalog_id', '=', False)]</field>
|
||||
<field name="context">{'default_node_type': 'recipe', 'search_default_recipes_only': 1, 'search_default_templates_only': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== NEW action - Part Processes ========== -->
|
||||
<record id="action_fp_process_recipe_part_scoped"
|
||||
model="ir.actions.act_window">
|
||||
<field name="name">Part Processes</field>
|
||||
<field name="res_model">fusion.plating.process.node</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('node_type', '=', 'recipe'), ('part_catalog_id', '!=', False)]</field>
|
||||
<field name="context">{'search_default_part_scoped': 1, 'search_default_group_part': 1}</field>
|
||||
<field name="search_view_id"
|
||||
ref="fusion_plating.view_fp_process_node_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No part-customised processes yet
|
||||
</p>
|
||||
<p>
|
||||
This view lists every process tree that has been
|
||||
composed for a specific part via the part form's
|
||||
<b>Compose</b> button. Each row is a clone of a shared
|
||||
template with per-part tweaks. To browse the shared
|
||||
templates themselves, use <b>Process Recipes</b>.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_process_part_scoped"
|
||||
name="Part Processes"
|
||||
parent="fusion_plating.menu_fp_operations"
|
||||
action="action_fp_process_recipe_part_scoped"
|
||||
sequence="6"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,386 +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>
|
||||
|
||||
<!-- ===== Configurator Form View ===== -->
|
||||
<record id="view_fp_quote_configurator_form" model="ir.ui.view">
|
||||
<field name="name">fp.quote.configurator.form</field>
|
||||
<field name="model">fp.quote.configurator</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Quote Configurator">
|
||||
<header>
|
||||
<button name="action_promote_to_direct_order"
|
||||
string="Add to Direct Order"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state != 'draft'"
|
||||
help="Add this quote as a line on a Direct Order draft. Multiple quotes can land on the same draft so one PO covers them all."/>
|
||||
<button name="action_recalculate_price"
|
||||
string="Recalculate"
|
||||
type="object"
|
||||
class="btn-secondary"/>
|
||||
<button name="action_save_to_catalog"
|
||||
string="Save to Catalog"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
confirm="This will overwrite the part catalog's geometry, substrate, masking area, and complexity with values from this quote. Continue?"
|
||||
invisible="not part_catalog_id"/>
|
||||
<button name="action_mark_lost"
|
||||
string="Mark as Lost"
|
||||
type="object"
|
||||
class="btn-warning"
|
||||
invisible="state != 'draft'"
|
||||
confirm="Mark this quote as Lost? Set the Lost Reason first."/>
|
||||
<button name="action_mark_expired"
|
||||
string="Mark as Expired"
|
||||
type="object"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_cancel"
|
||||
string="Cancel"
|
||||
type="object"
|
||||
invisible="state == 'cancelled'"/>
|
||||
<button name="action_reset_draft"
|
||||
string="Reset to Draft"
|
||||
type="object"
|
||||
invisible="state == 'draft'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,confirmed,lost,expired,cancelled"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_order"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-text-o"
|
||||
invisible="not sale_order_id">
|
||||
<field name="sale_order_id" widget="statinfo" string="Sale Order"/>
|
||||
</button>
|
||||
<button name="action_view_part_catalog"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cube"
|
||||
invisible="not part_catalog_id">
|
||||
<field name="part_catalog_id" widget="statinfo" string="Part"/>
|
||||
</button>
|
||||
<!--
|
||||
3D Model + Drawings smart buttons.
|
||||
Both open a modal preview (action_open_3d_fullscreen
|
||||
and action_view_drawings) that replaces what used
|
||||
to be the right-column inline previews.
|
||||
-->
|
||||
<button name="action_open_3d_fullscreen"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cube"
|
||||
invisible="not model_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">3D Model</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_drawings"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-pdf-o"
|
||||
invisible="drawing_count == 0">
|
||||
<field name="drawing_count" widget="statinfo" string="Drawings"/>
|
||||
</button>
|
||||
<button name="action_view_rfq"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-envelope-o"
|
||||
invisible="not rfq_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">RFQ</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_po"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-o"
|
||||
invisible="not po_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">PO</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Single-column layout. The right-side 3D viewer +
|
||||
Drawing preview were removed (commit pending) - both
|
||||
live behind the 3D Model / Drawings smart buttons at
|
||||
the top of the form, plus inline "Preview" links
|
||||
next to each respective field.
|
||||
-->
|
||||
<div class="o_fp_cfg_layout">
|
||||
<div class="o_fp_cfg_fields">
|
||||
<group>
|
||||
<group string="Customer & Part">
|
||||
<field name="partner_id"/>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="recipe_id"/>
|
||||
<!-- 3D File: upload before, filename + clear button after -->
|
||||
<field name="upload_3d_file" filename="upload_3d_filename"
|
||||
invisible="state != 'draft' or model_attachment_id"
|
||||
string="Attach 3D File"/>
|
||||
<field name="upload_3d_filename" invisible="1"/>
|
||||
<!--
|
||||
3D Model + inline Preview link. Field shows
|
||||
the attachment name, the small Preview link
|
||||
opens the same fullscreen wizard as the
|
||||
smart button at the top of the form.
|
||||
-->
|
||||
<label for="model_attachment_id" string="3D Model"
|
||||
invisible="not model_attachment_id"/>
|
||||
<div class="o_row" invisible="not model_attachment_id">
|
||||
<field name="model_attachment_id" nolabel="1"
|
||||
readonly="state != 'draft'"/>
|
||||
<button name="action_open_3d_fullscreen"
|
||||
type="object"
|
||||
string="Preview"
|
||||
icon="fa-eye"
|
||||
class="btn btn-link btn-sm ms-2 p-0"
|
||||
title="Open 3D model preview"/>
|
||||
</div>
|
||||
<!-- Drawing: upload before, filename + Preview link after -->
|
||||
<field name="upload_drawing" filename="upload_drawing_filename"
|
||||
invisible="state != 'draft' or drawing_count > 0"
|
||||
string="Attach Drawing"/>
|
||||
<field name="upload_drawing_filename" invisible="1"/>
|
||||
<label for="first_drawing_id" string="Drawing"
|
||||
invisible="drawing_count == 0"/>
|
||||
<div class="o_row" invisible="drawing_count == 0">
|
||||
<field name="first_drawing_id" nolabel="1"
|
||||
readonly="state != 'draft'"/>
|
||||
<button name="action_view_drawings"
|
||||
type="object"
|
||||
string="Preview"
|
||||
icon="fa-eye"
|
||||
class="btn btn-link btn-sm ms-2 p-0"
|
||||
title="Open drawing preview"/>
|
||||
</div>
|
||||
<field name="drawing_count" invisible="1"/>
|
||||
</group>
|
||||
<group string="RFQ / PO Documents">
|
||||
<field name="upload_rfq_file"
|
||||
filename="upload_rfq_filename"
|
||||
invisible="state != 'draft' or rfq_attachment_id"
|
||||
string="Attach RFQ"/>
|
||||
<field name="upload_rfq_filename" invisible="1"/>
|
||||
<field name="rfq_attachment_id"
|
||||
invisible="not rfq_attachment_id"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="upload_po_file"
|
||||
filename="upload_po_filename"
|
||||
invisible="state != 'draft' or po_attachment_id"
|
||||
string="Attach PO"/>
|
||||
<field name="upload_po_filename" invisible="1"/>
|
||||
<field name="po_attachment_id"
|
||||
invisible="not po_attachment_id"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="po_number_preliminary"
|
||||
string="PO Number"
|
||||
readonly="state != 'draft'"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!--
|
||||
Row 2 - Quantity / Options on the LEFT, Auto-from-3D on
|
||||
the RIGHT (visible only when a part catalog is linked).
|
||||
Quantity moved out of the RFQ/PO group so the right
|
||||
column has a peer instead of stretching alone.
|
||||
-->
|
||||
<group>
|
||||
<group string="Quantity & Options">
|
||||
<field name="quantity"/>
|
||||
<field name="batch_size"/>
|
||||
<field name="complexity"/>
|
||||
<field name="rush_order"/>
|
||||
</group>
|
||||
<group string="Auto from 3D"
|
||||
invisible="not part_catalog_id">
|
||||
<field name="bbox_summary_in"
|
||||
string="Dimensions"
|
||||
readonly="1"/>
|
||||
<field name="material_weight_kg"
|
||||
string="Weight (kg)"
|
||||
readonly="1"/>
|
||||
<field name="hole_count"
|
||||
string="Holes"
|
||||
readonly="1"/>
|
||||
<field name="hole_summary"
|
||||
readonly="1"
|
||||
invisible="not hole_summary"/>
|
||||
<field name="is_manifold"
|
||||
widget="boolean_toggle"
|
||||
readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<div class="alert alert-warning"
|
||||
invisible="is_manifold or not part_catalog_id or not hole_count">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<strong>Warning:</strong> 3D model is not watertight.
|
||||
Surface area calculation may be inaccurate. Review the file before quoting.
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Row 3 - Geometry on the LEFT, Delivery & Fees on the
|
||||
RIGHT. Delivery/Fees used to live in its own row with
|
||||
an empty right side; pairing it with Geometry keeps
|
||||
both columns balanced.
|
||||
-->
|
||||
<group>
|
||||
<group string="Geometry">
|
||||
<field name="surface_area"/>
|
||||
<field name="surface_area_uom"/>
|
||||
<field name="masking_area_sqin"
|
||||
string="Masking Area (sq in)"/>
|
||||
<field name="effective_area_sqin"
|
||||
string="Effective Plating Area"
|
||||
readonly="1"/>
|
||||
<field name="thickness_requested"/>
|
||||
<field name="material_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="substrate_material" invisible="1"/>
|
||||
<field name="masking_zones"/>
|
||||
<field name="turnaround_days"/>
|
||||
</group>
|
||||
<group string="Delivery & Fees">
|
||||
<field name="delivery_method"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="shipping_fee" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="delivery_fee" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Pricing"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="calculated_price" widget="monetary" readonly="1"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
class="fw-bold fs-4"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="estimator_override_price" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="price_breakdown_html" readonly="1" colspan="2"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<notebook>
|
||||
<page string="Sale Order" name="sale_order">
|
||||
<group>
|
||||
<field name="sale_order_id" readonly="1"/>
|
||||
<field name="won_date" readonly="1"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Win / Loss" name="win_loss">
|
||||
<group>
|
||||
<group>
|
||||
<field name="lost_reason"/>
|
||||
<field name="lost_competitor_name"
|
||||
invisible="lost_reason != 'competitor'"/>
|
||||
<field name="lost_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="lost_details" colspan="2"
|
||||
placeholder="What did we learn? (Price point competitor beat, spec we didn't meet, etc.)"/>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Internal notes about this quote..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Configurator List View ===== -->
|
||||
<record id="view_fp_quote_configurator_list" model="ir.ui.view">
|
||||
<field name="name">fp.quote.configurator.list</field>
|
||||
<field name="model">fp.quote.configurator</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Quote Configurators"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-muted="state == 'cancelled'"
|
||||
default_order="create_date desc">
|
||||
<field name="create_date" string="Date"/>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="recipe_id"/>
|
||||
<field name="surface_area"/>
|
||||
<field name="quantity"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="calculated_price" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
||||
<field name="estimator_override_price" string="Final Price"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'confirmed'"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-danger="state == 'cancelled'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Configurator Search View ===== -->
|
||||
<record id="view_fp_quote_configurator_search" model="ir.ui.view">
|
||||
<field name="name">fp.quote.configurator.search</field>
|
||||
<field name="model">fp.quote.configurator</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="recipe_id"/>
|
||||
<separator/>
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
||||
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||
<group>
|
||||
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Recipe" name="group_recipe" context="{'group_by': 'recipe_id'}"/>
|
||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Window Action ===== -->
|
||||
<record id="action_fp_quote_configurator" model="ir.actions.act_window">
|
||||
<field name="name">Quote Configurator</field>
|
||||
<field name="res_model">fp.quote.configurator</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
|
||||
<field name="context">{}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new quote configurator session
|
||||
</p>
|
||||
<p>
|
||||
Select a customer and coating configuration, enter part geometry,
|
||||
and the pricing engine will calculate a quote. The estimator can
|
||||
override the calculated price before creating a sale order.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,118 +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.
|
||||
|
||||
Manage reusable descriptions that estimators can drop onto sale order lines.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_sale_description_template_list" model="ir.ui.view">
|
||||
<field name="name">fp.sale.description.template.list</field>
|
||||
<field name="model">fp.sale.description.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list multi_edit="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="part_catalog_id" optional="show"/>
|
||||
<field name="name"/>
|
||||
<field name="tag" widget="badge"
|
||||
decoration-info="tag == 'standard'"
|
||||
decoration-warning="tag == 'masking'"
|
||||
decoration-danger="tag == 'rework'"
|
||||
decoration-success="tag in ('aerospace','nuclear')"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="usage_count" string="Used"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_sale_description_template_form" model="ir.ui.view">
|
||||
<field name="name">fp.sale.description.template.form</field>
|
||||
<field name="model">fp.sale.description.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. ENP - Standard Aluminium"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="partner_id" readonly="part_catalog_id"/>
|
||||
<field name="tag"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="usage_count" readonly="1"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Internal Description (Shop Floor Only)">
|
||||
<field name="internal_description" nolabel="1" colspan="2"
|
||||
placeholder="What the shop floor sees on the WO / traveler…"/>
|
||||
</group>
|
||||
<separator string="Customer-Facing Description"/>
|
||||
<field name="customer_facing_description" colspan="2"
|
||||
placeholder="Electroless nickel plating per AMS 2404, Class I, Type II…"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_sale_description_template_search" model="ir.ui.view">
|
||||
<field name="name">fp.sale.description.template.search</field>
|
||||
<field name="model">fp.sale.description.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="internal_description"/>
|
||||
<field name="customer_facing_description"/>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="tag"/>
|
||||
<filter name="active" string="Active" domain="[('active','=',True)]"/>
|
||||
<filter name="with_part" string="Part-Specific"
|
||||
domain="[('part_catalog_id','!=',False)]"/>
|
||||
<filter name="no_part" string="Generic (No Part)"
|
||||
domain="[('part_catalog_id','=',False)]"/>
|
||||
<group>
|
||||
<filter name="group_part" string="Part"
|
||||
context="{'group_by': 'part_catalog_id'}"/>
|
||||
<filter name="group_customer" string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
<filter name="group_tag" string="Category"
|
||||
context="{'group_by': 'tag'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_sale_description_template" model="ir.actions.act_window">
|
||||
<field name="name">Line Description Templates</field>
|
||||
<field name="res_model">fp.sale.description.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_sale_description_template_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first line description template
|
||||
</p>
|
||||
<p>
|
||||
Save the language you use on repeat orders - masking rules,
|
||||
spec callouts, packaging notes. The estimator picks one,
|
||||
tweaks it, and it lands on the order line.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_sale_description_templates"
|
||||
name="Line Description Templates"
|
||||
parent="fusion_plating.menu_fp_config_quality_docs"
|
||||
action="action_fp_sale_description_template"
|
||||
sequence="90"
|
||||
groups="fusion_plating_configurator.group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
</odoo>
|
||||