diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 0cc73b90..655aecf0 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.4.0.0', + 'version': '19.0.5.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index dcb1aa93..a29745eb 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -14,7 +14,7 @@ import logging from markupsafe import Markup -from odoo import fields, models +from odoo import api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -65,6 +65,204 @@ class FpJob(models.Model): 'idempotency. Cleared post-cutover.', ) + # ------------------------------------------------------------------ + # Smart-button counts (Feature A — operator workflow) + # + # Compute counts for each downstream model so the form view can + # render an oe_stat_button row similar to sale.order. Cross-module + # models are runtime-detected so this still works when one of the + # bridge modules is uninstalled. + # ------------------------------------------------------------------ + sale_order_count = fields.Integer(compute='_compute_smart_counts') + delivery_count = fields.Integer(compute='_compute_smart_counts') + invoice_count = fields.Integer(compute='_compute_smart_counts') + payment_count = fields.Integer(compute='_compute_smart_counts') + quality_hold_count = fields.Integer(compute='_compute_smart_counts') + certificate_count = fields.Integer(compute='_compute_smart_counts') + timelog_count = fields.Integer(compute='_compute_smart_counts') + portal_job_count = fields.Integer(compute='_compute_smart_counts') + + @api.depends( + 'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids', + 'step_ids.time_log_ids', 'origin', 'partner_id', + ) + def _compute_smart_counts(self): + AccountMove = self.env.get('account.move') + AccountPayment = self.env.get('account.payment') + QualityHold = self.env.get('fusion.plating.quality.hold') + Certificate = self.env.get('fp.certificate') + for job in self: + job.sale_order_count = 1 if job.sale_order_id else 0 + job.delivery_count = 1 if job.delivery_id else 0 + job.portal_job_count = 1 if job.portal_job_id else 0 + + # Invoices via origin (the SO name) + if AccountMove is not None and job.origin: + job.invoice_count = AccountMove.search_count([ + ('invoice_origin', '=', job.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ]) + else: + job.invoice_count = 0 + + # Payments — find invoices for this SO, then payments + # reconciled against them. + if (AccountMove is not None and AccountPayment is not None + and job.origin): + inv_ids = AccountMove.search([ + ('invoice_origin', '=', job.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ]).ids + if inv_ids: + job.payment_count = AccountPayment.search_count([ + ('reconciled_invoice_ids', 'in', inv_ids), + ]) + else: + job.payment_count = 0 + else: + job.payment_count = 0 + + if QualityHold is not None: + job.quality_hold_count = QualityHold.search_count([ + ('x_fc_job_id', '=', job.id), + ]) + else: + job.quality_hold_count = 0 + + if Certificate is not None: + job.certificate_count = Certificate.search_count([ + ('x_fc_job_id', '=', job.id), + ]) + else: + job.certificate_count = 0 + + job.timelog_count = sum( + len(s.time_log_ids) for s in job.step_ids + ) + + # ------------------------------------------------------------------ + # Smart-button actions + # ------------------------------------------------------------------ + def action_view_sale_order(self): + self.ensure_one() + if not self.sale_order_id: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'res_id': self.sale_order_id.id, + 'view_mode': 'form', + 'name': self.sale_order_id.name, + } + + def action_view_steps(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.job.step', + 'view_mode': 'list,form', + 'domain': [('job_id', '=', self.id)], + 'name': 'Steps — %s' % self.name, + 'context': {'default_job_id': self.id}, + } + + def action_view_deliveries(self): + self.ensure_one() + if not self.delivery_id: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.plating.delivery', + 'res_id': self.delivery_id.id, + 'view_mode': 'form', + 'name': self.delivery_id.name, + } + + def action_view_invoices(self): + self.ensure_one() + if not self.origin: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [ + ('invoice_origin', '=', self.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ], + 'name': 'Invoices — %s' % self.name, + } + + def action_view_payments(self): + self.ensure_one() + if not self.origin: + return {'type': 'ir.actions.act_window_close'} + AccountMove = self.env.get('account.move') + if AccountMove is None: + return {'type': 'ir.actions.act_window_close'} + inv_ids = AccountMove.search([ + ('invoice_origin', '=', self.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ]).ids + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.payment', + 'view_mode': 'list,form', + 'domain': ( + [('reconciled_invoice_ids', 'in', inv_ids)] + if inv_ids else [('id', '=', 0)] + ), + 'name': 'Payments — %s' % self.name, + } + + def action_view_quality_holds(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.plating.quality.hold', + 'view_mode': 'list,form', + 'domain': [('x_fc_job_id', '=', self.id)], + 'name': 'Quality Holds — %s' % self.name, + 'context': {'default_x_fc_job_id': self.id}, + } + + def action_view_certificates(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.certificate', + 'view_mode': 'list,form', + 'domain': [('x_fc_job_id', '=', self.id)], + 'name': 'Certificates — %s' % self.name, + 'context': {'default_x_fc_job_id': self.id}, + } + + def action_view_timelogs(self): + self.ensure_one() + step_ids = self.step_ids.ids + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.job.step.timelog', + 'view_mode': 'list,form', + 'domain': ( + [('step_id', 'in', step_ids)] + if step_ids else [('id', '=', 0)] + ), + 'name': 'Time Logs — %s' % self.name, + } + + def action_view_portal_job(self): + self.ensure_one() + if not self.portal_job_id: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.plating.portal.job', + 'res_id': self.portal_job_id.id, + 'view_mode': 'form', + 'name': self.portal_job_id.name, + } + # ------------------------------------------------------------------ # Recipe → fp.job.step generation (Task 2.4) # diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py index adf1b07f..4196c1b4 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py @@ -576,6 +576,53 @@ class TestPhase6Controllers(TransactionCase): self.assertEqual(step_by_node[op.id].name, 'Op1') +class TestFpJobSmartButtons(TransactionCase): + """Feature A — verify smart-button count fields and action methods + are wired on fp.job. Runtime-detect tests confirm the methods exist + without requiring downstream models to be installed.""" + + def test_smart_count_fields_exist(self): + for f in ( + 'sale_order_count', 'delivery_count', 'invoice_count', + 'payment_count', 'quality_hold_count', 'certificate_count', + 'timelog_count', 'portal_job_count', + ): + self.assertIn(f, self.env['fp.job']._fields) + + def test_smart_action_methods_exist(self): + Job = self.env['fp.job'] + for m in ( + 'action_view_sale_order', 'action_view_steps', + 'action_view_deliveries', 'action_view_invoices', + 'action_view_payments', 'action_view_quality_holds', + 'action_view_certificates', 'action_view_timelogs', + 'action_view_portal_job', + ): + self.assertTrue( + hasattr(Job, m), + 'fp.job missing action method %s' % m, + ) + + def test_smart_counts_compute_for_empty_job(self): + partner = self.env['res.partner'].create({'name': 'C'}) + product = self.env['product.product'].create({'name': 'W'}) + job = self.env['fp.job'].create({ + 'partner_id': partner.id, + 'product_id': product.id, + 'qty': 1.0, + }) + # All counts should be 0 on a freshly-created job (no SO, + # no delivery, no portal job, no holds, etc.) + self.assertEqual(job.sale_order_count, 0) + self.assertEqual(job.delivery_count, 0) + self.assertEqual(job.invoice_count, 0) + self.assertEqual(job.payment_count, 0) + self.assertEqual(job.quality_hold_count, 0) + self.assertEqual(job.certificate_count, 0) + self.assertEqual(job.timelog_count, 0) + self.assertEqual(job.portal_job_count, 0) + + class TestPhase7Migration(TransactionCase): """Phase 7 — verify the migration script idempotency-key fields are in place and the script files are present + parse as valid Python. diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml index 26687d01..8b571a74 100644 --- a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml +++ b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml @@ -1,11 +1,18 @@ fp.job.form.jobs.inherit @@ -19,6 +26,67 @@ icon="fa-sitemap" invisible="state == 'draft'"/> + + + +
+ + + + + + + + + +
+
diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index bbdda141..eafcdd11 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.15.0.0', + 'version': '19.0.16.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', @@ -50,6 +50,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_bake_window_views.xml', 'views/fp_first_piece_gate_views.xml', 'views/fp_plant_overview_views.xml', + 'views/tank_status_template.xml', 'views/fp_menu.xml', ], 'demo': [ @@ -61,11 +62,16 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. # and variables directly (Odoo 19 forbids @import in custom SCSS, # so tokens are resolved via bundle concatenation order). 'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss', + 'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss', 'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss', 'fusion_plating_shopfloor/static/src/scss/plant_overview.scss', 'fusion_plating_shopfloor/static/src/scss/process_tree.scss', 'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss', 'fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss', + # qr_scanner.js MUST load before its consumers so the + # `import { QrScanner } from "./qr_scanner"` resolves. + 'fusion_plating_shopfloor/static/src/js/qr_scanner.js', + 'fusion_plating_shopfloor/static/src/xml/qr_scanner.xml', 'fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml', 'fusion_plating_shopfloor/static/src/xml/plant_overview.xml', 'fusion_plating_shopfloor/static/src/xml/process_tree.xml', @@ -75,6 +81,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_shopfloor/static/src/js/process_tree.js', 'fusion_plating_shopfloor/static/src/js/manager_dashboard.js', ], + 'web.assets_frontend': [ + # Tank status page (rendered via web.frontend_layout for + # NFC tap-to-view from a phone). Tokens loaded first. + 'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss', + 'fusion_plating_shopfloor/static/src/scss/tank_status.scss', + ], }, 'installable': True, 'application': False, diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py index 007ab933..69b5026a 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py @@ -4,3 +4,4 @@ from . import shopfloor_controller from . import manager_controller +from . import tank_status diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tank_status.py b/fusion_plating/fusion_plating_shopfloor/controllers/tank_status.py new file mode 100644 index 00000000..4d671231 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tank_status.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# /fp/tank/ — mobile-friendly tank status page. Linked from NFC +# tags on the physical tank. The operator taps the tag with a phone, +# the tag's URL opens this page in their default browser. +# +# Auth is `user` so an operator must be logged in (no public exposure +# of bath chemistry / job-customer data). Operators stay logged in on +# the shopfloor tablet, so this is friction-free in practice. +# +# Why URL-based and not Web NFC API: Web NFC is Chrome-Android only; +# iOS Safari does not expose any NFC API. iOS instead reads the URL +# off the tag's NDEF record and opens it in the default browser. As +# long as the tag stores the URL, both platforms Just Work. + +from odoo import http +from odoo.http import request + + +class FpTankStatusController(http.Controller): + + @http.route( + '/fp/tank/', + type='http', + auth='user', + website=False, + ) + def fp_tank_status(self, tank_id, **kwargs): + Tank = request.env['fusion.plating.tank'].sudo() + tank = Tank.browse(tank_id).exists() + if not tank: + return request.render( + 'fusion_plating_shopfloor.tank_status_not_found', + {'tank_id': tank_id}, + ) + + # Find the active step on this tank (in progress or paused). + # fp.job.step.tank_id was added in fusion_plating core. + Step = request.env['fp.job.step'].sudo() + active_step = Step.search([ + ('tank_id', '=', tank.id), + ('state', 'in', ('in_progress', 'paused')), + ], order='date_started desc', limit=1) + + # Up to 5 ready steps for this tank — the operator's "what's + # coming next" signal. + ready_steps = Step.search([ + ('tank_id', '=', tank.id), + ('state', '=', 'ready'), + ], order='sequence asc', limit=5) + + # Most recent bath log. Readings are line-level + # (fusion.plating.bath.log.line), keyed by parameter_code (pH, + # temperature, nickel, etc.). The template iterates the lines. + bath_log = request.env['fusion.plating.bath.log'].sudo().search( + [('tank_id', '=', tank.id)], + order='log_date desc, create_date desc', + limit=1, + ) + + return request.render( + 'fusion_plating_shopfloor.tank_status_page', + { + 'tank': tank, + 'active_step': active_step, + 'ready_steps': ready_steps, + 'bath_log': bath_log, + }, + ) diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js index 665b531b..7feac04e 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js @@ -16,10 +16,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; export class ManagerDashboard extends Component { static template = "fusion_plating_shopfloor.ManagerDashboard"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js index 4e73d2d5..4f7866a8 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js @@ -22,10 +22,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; export class PlantOverview extends Component { static template = "fusion_plating_shopfloor.PlantOverview"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js index 1f78a72d..0d694205 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js @@ -24,10 +24,12 @@ 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"; +import { QrScanner } from "./qr_scanner"; export class ProcessTree extends Component { static template = "fusion_plating_shopfloor.ProcessTree"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js new file mode 100644 index 00000000..fd71a6e4 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js @@ -0,0 +1,158 @@ +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — Reusable QR Scanner OWL Component +// Copyright 2026 Nexa Systems Inc. · License OPL-1 +// +// Renders a single button. On click, opens a modal that streams the rear +// camera into a