feat(jobs,shopfloor): smart buttons + QR scanner + NFC tank pages
Three connected operator-workflow features for entech.
A. fp.job smart buttons — count fields and action methods for sale
order, steps, deliveries, invoices, payments, quality holds,
certificates, time logs, and portal job. Each is an oe_stat_button
that drills into the matching records, mirroring the sale.order
pattern. Cross-module models are runtime-detected so the form
stays clean when bridge modules are uninstalled.
B. Reusable QR scanner OWL component (`<QrScanner/>`) wired into the
Manager Desk, Tablet Station, Plant Overview, and Process Tree
headers. Click → modal with rear-camera stream (getUserMedia) +
BarcodeDetector live decode → opens the matching fp.job form via
the action service. Falls back to a manual URL paste box on
browsers without BarcodeDetector. Works on iOS 17+ Safari and
Android Chrome. Width uses `min(420px, 92vw)` wrapped in #{} so
dart-sass passes it through verbatim instead of trying to compute
incompatible units at compile time.
C. /fp/tank/<id> public-but-auth-required tank status page for NFC
taps. Renders the tank's current step (in-progress / paused),
queued ready steps, and most recent bath chemistry log (lines
table) on a mobile-first page. URL-based so it works on iOS Safari
without the Web NFC API — the operator taps the NFC tag, the URL
opens in the default browser, the page auto-renders. New
web.assets_frontend bundle entry pulls in the design tokens +
tank_status.scss.
Manifest version bumps: jobs 19.0.5.0.0, shopfloor 19.0.16.0.0.
Tests: 44 pass (3 new smart-button assertions added).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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)
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Adds a "Process Tree" button to the fp.job form header, calling
|
||||
fp.job.action_open_process_tree (which launches the canonical
|
||||
fp_process_tree shopfloor client action with job_id in context).
|
||||
Adds a "Process Tree" header button + smart-button row to the
|
||||
fp.job form. The fp.job form in core has no button_box yet, so
|
||||
we inject one at the top of the sheet (xpath //sheet position
|
||||
"inside" with a sibling reference at the start).
|
||||
|
||||
Hidden while the job is in draft (no recipe-derived steps yet).
|
||||
Smart buttons appear only when the underlying count is > 0
|
||||
(except Steps, which always shows since every confirmed job
|
||||
has steps). Pattern follows the existing oe_stat_button row
|
||||
from sale.order / mrp.production.
|
||||
|
||||
Process Tree header button is hidden while the job is in draft
|
||||
(no recipe-derived steps yet).
|
||||
-->
|
||||
<record id="view_fp_job_form_jobs_inherit" model="ir.ui.view">
|
||||
<field name="name">fp.job.form.jobs.inherit</field>
|
||||
@@ -19,6 +26,67 @@
|
||||
icon="fa-sitemap"
|
||||
invisible="state == 'draft'"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Inject a button_box at the top of the sheet, before the
|
||||
oe_title block. Smart buttons drill into the matching
|
||||
records the way sale.order does. -->
|
||||
<xpath expr="//sheet/div[hasclass('oe_title')]" position="before">
|
||||
<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="sale_order_count == 0">
|
||||
<field name="sale_order_count" widget="statinfo"
|
||||
string="Sale Order"/>
|
||||
</button>
|
||||
<button name="action_view_steps" type="object"
|
||||
class="oe_stat_button" icon="fa-list-ol">
|
||||
<field name="step_count" widget="statinfo"
|
||||
string="Steps"/>
|
||||
</button>
|
||||
<button name="action_view_deliveries" type="object"
|
||||
class="oe_stat_button" icon="fa-truck"
|
||||
invisible="delivery_count == 0">
|
||||
<field name="delivery_count" widget="statinfo"
|
||||
string="Delivery"/>
|
||||
</button>
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="invoice_count == 0">
|
||||
<field name="invoice_count" widget="statinfo"
|
||||
string="Invoices"/>
|
||||
</button>
|
||||
<button name="action_view_payments" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
invisible="payment_count == 0">
|
||||
<field name="payment_count" widget="statinfo"
|
||||
string="Payments"/>
|
||||
</button>
|
||||
<button name="action_view_quality_holds" type="object"
|
||||
class="oe_stat_button" icon="fa-pause-circle"
|
||||
invisible="quality_hold_count == 0">
|
||||
<field name="quality_hold_count" widget="statinfo"
|
||||
string="Holds"/>
|
||||
</button>
|
||||
<button name="action_view_certificates" type="object"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="certificate_count == 0">
|
||||
<field name="certificate_count" widget="statinfo"
|
||||
string="Certificates"/>
|
||||
</button>
|
||||
<button name="action_view_timelogs" type="object"
|
||||
class="oe_stat_button" icon="fa-clock-o"
|
||||
invisible="timelog_count == 0">
|
||||
<field name="timelog_count" widget="statinfo"
|
||||
string="Time Logs"/>
|
||||
</button>
|
||||
<button name="action_view_portal_job" type="object"
|
||||
class="oe_stat_button" icon="fa-globe"
|
||||
invisible="portal_job_count == 0">
|
||||
<field name="portal_job_count" widget="statinfo"
|
||||
string="Portal Job"/>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user