feat(jobs): Phase 5 — fp.job reports (sticker + traveller)

Two parallel report definitions for the native job model:

1. Job Sticker (6x4 inch custom paperformat) bound to fp.job. Prints
   WH/JOB/... ID, customer, SO, qty, due date, recipe, step
   progress. QR encodes /fp/job/<id> for scan-to-job navigation.

2. Job Traveller bound to fp.job, A4 portrait. Job header + all
   fp.job.step rows in sequence order with operator sign-off
   column.

Coexists with fusion_plating_reports' MO/WO bindings — both print
menus stay live during migration.

Deferred reports (use existing during migration; rebind at cutover):
- BoL, Packing Slip, Invoice (read from SO, no fp.job change needed)
- WO Margin (cost rollup; rebuild against fp.job.step.cost_total
  in phase-end polish)

Adds fusion_plating_reports to fusion_plating_jobs depends.

Tests deferred to post-Tailscale-restore: 3 new tests verify
report actions are registered + sticker template renders without
QWeb errors. Module file content verified locally as
well-formed XML.

Manifest 19.0.1.7.0 → 19.0.1.8.0.

Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 00:05:48 -04:00
parent 51a5cbbe5d
commit c528d581c2
6 changed files with 225 additions and 1 deletions

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import report

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.1.7.0',
'version': '19.0.1.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'description': """
@@ -33,10 +33,13 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating_portal', # fusion.plating.portal.job
'fusion_plating_quality', # fusion.plating.customer.spec, fusion.plating.quality.hold
'fusion_plating_receiving', # fp.racking.inspection (Phase 3)
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
],
'data': [
'security/ir.model.access.csv',
'views/res_config_settings_views.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
],
'installable': True,
'application': False,

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Native fp.job sticker — parallel to fusion_plating_reports' WO Box
Sticker which binds to mrp.production/mrp.workorder. Coexists during
the migration period.
QR encodes /fp/job/<id> (controller added in Task TBD; for now scan
will fall back to /web/login if controller absent).
-->
<odoo>
<record id="paperformat_fp_job_sticker" model="report.paperformat">
<field name="name">FP Job Sticker (6x4")</field>
<field name="format">custom</field>
<field name="page_width">152</field>
<field name="page_height">102</field>
<field name="orientation">Portrait</field>
<field name="margin_top">0</field>
<field name="margin_bottom">0</field>
<field name="margin_left">0</field>
<field name="margin_right">0</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/>
<field name="dpi">300</field>
</record>
<record id="action_report_fp_job_sticker" model="ir.actions.report">
<field name="name">Job Sticker</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_template</field>
<field name="print_report_name">'Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<template id="report_fp_job_sticker_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-set="_base_url" t-value="env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="_scan_url" t-value="_base_url + '/fp/job/' + str(job.id)"/>
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri('QR', _scan_url, width=300, height=300)"/>
<style>
@page { margin: 0; size: 152mm 102mm; }
html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; height: 100% !important; }
.fp-job-sticker {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #000;
position: absolute;
top: 4px; left: 4px; right: 4px; bottom: 4px;
padding: 8px;
box-sizing: border-box;
border: 2px solid #000;
}
.fp-job-sticker-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1.5px solid #000;
padding-bottom: 6px;
margin-bottom: 8px;
}
.fp-job-sticker-id {
font-size: 36pt;
font-weight: 900;
line-height: 1;
}
.fp-job-sticker-qr { width: 32mm; height: 32mm; display: block; }
.fp-job-sticker-row { padding: 3px 0; font-size: 14pt; }
.fp-job-sticker-label { font-weight: 700; display: inline-block; min-width: 30mm; }
</style>
<div class="fp-job-sticker">
<div class="fp-job-sticker-head">
<div>
<div style="font-size: 10pt; letter-spacing: 0.5mm; text-transform: uppercase;">Plating Job</div>
<div class="fp-job-sticker-id">
<span t-esc="job.name"/>
</div>
</div>
<img class="fp-job-sticker-qr" t-if="_qr_src" t-att-src="_qr_src"/>
</div>
<div class="fp-job-sticker-row">
<span class="fp-job-sticker-label">Customer:</span>
<span t-esc="job.partner_id.name"/>
</div>
<div class="fp-job-sticker-row" t-if="job.sale_order_id">
<span class="fp-job-sticker-label">SO:</span>
<span t-esc="job.sale_order_id.name"/>
</div>
<div class="fp-job-sticker-row">
<span class="fp-job-sticker-label">Qty:</span>
<strong><span t-esc="int(job.qty) if job.qty == int(job.qty) else job.qty"/></strong>
</div>
<div class="fp-job-sticker-row" t-if="job.date_deadline">
<span class="fp-job-sticker-label">Due:</span>
<span t-esc="job.date_deadline.strftime('%b %d, %Y')"/>
</div>
<div class="fp-job-sticker-row" t-if="job.recipe_id">
<span class="fp-job-sticker-label">Recipe:</span>
<span t-esc="job.recipe_id.name"/>
</div>
<div class="fp-job-sticker-row">
<span class="fp-job-sticker-label">Steps:</span>
<span t-esc="job.step_done_count"/> / <span t-esc="job.step_count"/>
</div>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Native fp.job traveller — minimal portrait A4 listing all steps.
-->
<odoo>
<record id="action_report_fp_job_traveller" model="ir.actions.report">
<field name="name">Job Traveller</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_traveller_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_traveller_template</field>
<field name="print_report_name">'Traveller - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
</record>
<template id="report_fp_job_traveller_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<div class="page">
<h1>Job Traveller — <span t-esc="job.name"/></h1>
<table class="table table-sm" style="margin-top: 1em;">
<tr><th>Customer</th><td><span t-esc="job.partner_id.name"/></td></tr>
<tr><th>SO</th><td><span t-esc="job.sale_order_id.name or '-'"/></td></tr>
<tr><th>Qty</th><td><span t-esc="job.qty"/></td></tr>
<tr><th>Recipe</th><td><span t-esc="job.recipe_id.name or '-'"/></td></tr>
<tr><th>Deadline</th><td><span t-esc="job.date_deadline and job.date_deadline.strftime('%b %d, %Y') or '-'"/></td></tr>
<tr><th>Status</th><td><span t-esc="job.state"/></td></tr>
</table>
<h2 style="margin-top: 2em;">Steps</h2>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>#</th>
<th>Operation</th>
<th>Work Centre</th>
<th>Kind</th>
<th>Expected (min)</th>
<th>Actual (min)</th>
<th>State</th>
<th>Operator Sign-off</th>
</tr>
</thead>
<tbody>
<t t-foreach="job.step_ids.sorted('sequence')" t-as="step">
<tr>
<td><span t-esc="step.sequence"/></td>
<td><span t-esc="step.name"/></td>
<td><span t-esc="step.work_centre_id.name or ''"/></td>
<td><span t-esc="step.kind"/></td>
<td><span t-esc="step.duration_expected"/></td>
<td><span t-esc="step.duration_actual"/></td>
<td><span t-esc="step.state"/></td>
<td style="border-bottom: 1px solid #999; min-width: 100px;"></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -503,3 +503,32 @@ class TestPhase4Refactors(TransactionCase):
})
job.action_confirm() # Should not raise even with no templates
self.assertEqual(job.state, 'confirmed')
class TestReports(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
def test_sticker_report_action_exists(self):
action = self.env.ref('fusion_plating_jobs.action_report_fp_job_sticker', raise_if_not_found=False)
self.assertTrue(action)
self.assertEqual(action.model, 'fp.job')
def test_traveller_report_action_exists(self):
action = self.env.ref('fusion_plating_jobs.action_report_fp_job_traveller', raise_if_not_found=False)
self.assertTrue(action)
self.assertEqual(action.model, 'fp.job')
def test_sticker_renders_for_a_job(self):
# Smoke test: the QWeb template should render without error.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
report = self.env.ref('fusion_plating_jobs.action_report_fp_job_sticker')
# Render HTML (faster than PDF; doesn't need wkhtmltopdf)
html, _ = report._render_qweb_html(report.report_name, job.ids)
self.assertIn(job.name, html.decode() if isinstance(html, bytes) else html)