feat(reports): centralised Job Traveller / Shop Router

One PDF that follows a job through the shop — prints from either the
Sale Order or the Manufacturing Order. Matches existing design language
(fp_landscape_styles, .fp-header-primary banners, bordered tables,
.sig-line for sign-off, .highlight-box for callouts).

Sections per traveller:
  1. Title bar with REWORK / RUSH ORDER badges
  2. Job header — customer, PO #, part #, coating, recipe, facility,
     qty, dates, current parts location
  3. Receiving summary — received qty, state, damage flag
  4. Process Routing table — one row per WO with step #, operation,
     work centre, bath, tank, target thickness, dwell, expected
     duration, + sign-off columns (operator, date/time, initials,
     qty pass/reject)
  5. Bath chemistry targets snapshot per bath used
  6. Quality holds — red callout only when present
  7. Certificates issued + Delivery info (side-by-side)
  8. Rework reason block (only on rework MOs)
  9. Ruled notes / exceptions area
  10. Final supervisor + QA sign-off

Four ir.actions.report entries registered:
  - Job Traveller (Landscape) on mrp.production  [default print]
  - Job Traveller (Portrait)  on mrp.production
  - Job Traveller (Landscape) on sale.order      [iterates MOs]
  - Job Traveller (Portrait)  on sale.order

Regression-tested all 15 existing reports (SO, WO, MO margin, invoice,
BoL, CoC EN, receipt) — every one still renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 02:48:03 -04:00
parent adc27c637a
commit 3b5b5cbf7c
3 changed files with 590 additions and 0 deletions

View File

@@ -40,6 +40,7 @@
# Quote-to-cash reports (portrait + landscape)
'report/report_fp_sale.xml',
'report/report_fp_work_order.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_packing_slip.xml',
'report/report_fp_bol.xml',
'report/report_fp_invoice.xml',

View File

@@ -418,4 +418,54 @@
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 19. Job Traveller (Shop Router) -->
<!-- Four report actions: MO landscape/portrait + SO landscape/portrait -->
<!-- ============================================================= -->
<record id="action_report_fp_job_traveller_mo_landscape" model="ir.actions.report">
<field name="name">Job Traveller (Landscape)</field>
<field name="model">mrp.production</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_job_traveller_mo_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_job_traveller_mo_landscape</field>
<field name="print_report_name">'Traveller - %s' % object.name</field>
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<record id="action_report_fp_job_traveller_mo_portrait" model="ir.actions.report">
<field name="name">Job Traveller (Portrait)</field>
<field name="model">mrp.production</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_job_traveller_mo_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_job_traveller_mo_portrait</field>
<field name="print_report_name">'Traveller - %s' % object.name</field>
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_job_traveller_so_landscape" model="ir.actions.report">
<field name="name">Job Traveller (Landscape)</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_job_traveller_so_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_job_traveller_so_landscape</field>
<field name="print_report_name">'Traveller - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<record id="action_report_fp_job_traveller_so_portrait" model="ir.actions.report">
<field name="name">Job Traveller (Portrait)</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_job_traveller_so_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_job_traveller_so_portrait</field>
<field name="print_report_name">'Traveller - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,539 @@
<?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.
Fusion Plating — Job Traveller (Shop Router).
One centralised document that follows a job through the shop. Pulls
together everything about the MO: customer + PO, receiving, recipe,
every work order with a sign-off row, bath chemistry targets,
quality holds, certificates issued, delivery info, notes.
Two templates:
- report_fp_job_traveller_mo: canonical, per MO
- report_fp_job_traveller_so: SO wrapper that iterates the SO's MOs
Two paper formats (portrait + landscape) wrap the inner body so the
planner can choose. Landscape is the shop-floor default because the
routing table is wider.
-->
<odoo>
<!-- ============================================================= -->
<!-- INNER BODY — shared between portrait and landscape -->
<!-- Receives `mo` (mrp.production) in the t-call context. -->
<!-- ============================================================= -->
<template id="report_fp_job_traveller_body">
<t t-set="so" t-value="mo.env['sale.order'].search([('name', '=', mo.origin)], limit=1) if mo.origin else mo.env['sale.order']"/>
<t t-set="job" t-value="mo.x_fc_portal_job_id"/>
<t t-set="Cert" t-value="mo.env.get('fp.certificate')"/>
<t t-set="certs" t-value="Cert.search([('production_id', '=', mo.id)]) if Cert is not None else []"/>
<t t-set="Delivery" t-value="mo.env.get('fusion.plating.delivery')"/>
<t t-set="deliveries" t-value="Delivery.search([('job_ref', '=', job.name)]) if (Delivery is not None and job) else []"/>
<t t-set="holds" t-value="mo.env['fusion.plating.quality.hold'].search([('production_id', '=', mo.id)])"/>
<t t-set="Receiving" t-value="mo.env.get('fp.receiving')"/>
<t t-set="receivings" t-value="Receiving.search([('sale_order_id', '=', so.id)]) if (Receiving is not None and so) else []"/>
<t t-set="wos" t-value="mo.workorder_ids.sorted('sequence')"/>
<!-- ===== Title bar ===== -->
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px;">
<h2 style="margin: 0; font-size: 20pt;">
Job Traveller — <span t-field="mo.name"/>
</h2>
<div style="text-align: right;">
<t t-if="mo.x_fc_is_rework">
<span class="status-warning" style="font-size: 11pt;">
<i class="fa fa-refresh"/> REWORK
<t t-if="mo.x_fc_original_production_id">
of <span t-field="mo.x_fc_original_production_id.name"/>
</t>
</span>
</t>
<t t-if="so and so.x_fc_rush_order">
<span class="status-fail" style="font-size: 11pt; margin-left: 12px;">
<i class="fa fa-bolt"/> RUSH ORDER
</span>
</t>
</div>
</div>
<!-- ===== 1. JOB HEADER — Customer / PO / Part / Qty / Dates ===== -->
<table class="bordered">
<thead>
<tr><th colspan="6" class="fp-header-primary">JOB HEADER</th></tr>
</thead>
<tbody>
<tr>
<th class="info-header" style="width: 15%;">Customer</th>
<td style="width: 20%;"><span t-field="mo.product_id.product_tmpl_id"/>
<t t-if="so"><br/><span t-field="so.partner_id"/></t>
</td>
<th class="info-header" style="width: 10%;">Sale Order</th>
<td class="text-center" style="width: 15%;">
<t t-if="so"><span t-field="so.name"/></t>
<t t-else=""></t>
</td>
<th class="info-header" style="width: 10%;">Customer PO #</th>
<td class="text-center" style="width: 15%;">
<t t-if="so and so.x_fc_po_number"><span t-field="so.x_fc_po_number"/></t>
<t t-else=""></t>
</td>
</tr>
<tr>
<th class="info-header">Part Number</th>
<td>
<t t-if="so and so.x_fc_part_catalog_id">
<span t-field="so.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""><span t-field="mo.product_id.default_code"/></t>
</td>
<th class="info-header">Product</th>
<td><span t-field="mo.product_id"/></td>
<th class="info-header">Quantity</th>
<td class="text-center">
<span t-esc="int(mo.product_qty)"/>
<span t-field="mo.product_uom_id"/>
</td>
</tr>
<tr>
<th class="info-header">Coating Config</th>
<td>
<t t-if="so and so.x_fc_coating_config_id">
<span t-field="so.x_fc_coating_config_id"/>
</t>
<t t-else=""></t>
</td>
<th class="info-header">Recipe</th>
<td>
<t t-if="mo.x_fc_recipe_id"><span t-field="mo.x_fc_recipe_id"/></t>
<t t-else=""><span class="status-warning">No recipe assigned</span></t>
</td>
<th class="info-header">Facility</th>
<td><span t-field="mo.x_fc_facility_id"/></td>
</tr>
<tr>
<th class="info-header">Date Planned</th>
<td class="text-center">
<t t-if="mo.date_start">
<span t-field="mo.date_start" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""></t>
</td>
<th class="info-header">Target Ship</th>
<td class="text-center">
<t t-if="job and job.target_ship_date">
<span t-field="job.target_ship_date"/>
</t>
<t t-elif="so and so.commitment_date">
<span t-field="so.commitment_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""></t>
</td>
<th class="info-header">Current Location</th>
<td class="text-center">
<span t-field="mo.x_fc_current_location"/>
</td>
</tr>
</tbody>
</table>
<!-- ===== 2. RECEIVING ===== -->
<table class="bordered">
<thead>
<tr><th colspan="5" class="fp-header-primary">RECEIVING</th></tr>
</thead>
<tbody>
<tr>
<th class="info-header" style="width: 15%;">Ref</th>
<th class="info-header" style="width: 15%;">Received On</th>
<th class="info-header" style="width: 15%;">Qty Received</th>
<th class="info-header" style="width: 15%;">Status</th>
<th class="info-header" style="width: 40%;">Damage / Notes</th>
</tr>
<t t-if="receivings">
<tr t-foreach="receivings" t-as="rec">
<td class="text-center"><span t-field="rec.name"/></td>
<td class="text-center">
<t t-if="rec.received_date">
<span t-field="rec.received_date"/>
</t>
<t t-else=""></t>
</td>
<td class="text-center">
<t t-if="'quantity_received' in rec._fields"><span t-esc="rec.quantity_received"/></t>
<t t-else=""></t>
</td>
<td class="text-center"><span t-field="rec.state"/></td>
<td>
<t t-if="'damage_ids' in rec._fields and rec.damage_ids">
<span class="status-warning">
<i class="fa fa-warning"/> <span t-esc="len(rec.damage_ids)"/> damage entries
</span>
</t>
<t t-else=""></t>
</td>
</tr>
</t>
<t t-else="">
<tr>
<td colspan="5" class="text-center note-row">No receiving record found.</td>
</tr>
</t>
</tbody>
</table>
<!-- ===== 3. ROUTING TABLE — the main event ===== -->
<table class="bordered" style="margin-top: 12px;">
<thead>
<tr><th colspan="12" class="fp-header-primary">PROCESS ROUTING</th></tr>
<tr>
<th style="width: 4%;">#</th>
<th style="width: 16%;">Operation</th>
<th style="width: 11%;">Work Centre</th>
<th style="width: 9%;">Bath</th>
<th style="width: 7%;">Tank</th>
<th style="width: 7%;">Target Thk</th>
<th style="width: 5%;">Dwell</th>
<th style="width: 6%;">Exp. Dur.</th>
<th style="width: 8%;">Operator</th>
<th style="width: 9%;">Date / Time</th>
<th style="width: 5%;">Initials</th>
<th style="width: 13%;">Qty Pass / Reject</th>
</tr>
</thead>
<tbody>
<t t-if="wos">
<tr t-foreach="wos" t-as="wo">
<td class="text-center"><span t-esc="wo_index + 1"/></td>
<td><span t-field="wo.name"/></td>
<td><span t-field="wo.workcenter_id"/></td>
<td><span t-field="wo.x_fc_bath_id"/></td>
<td class="text-center"><span t-field="wo.x_fc_tank_id"/></td>
<td class="text-center">
<t t-if="wo.x_fc_thickness_target">
<span t-esc="wo.x_fc_thickness_target"/>
<span t-esc="dict(wo._fields['x_fc_thickness_uom'].selection).get(wo.x_fc_thickness_uom, '')"/>
</t>
<t t-else=""></t>
</td>
<td class="text-center">
<t t-if="wo.x_fc_dwell_time_minutes">
<span t-esc="wo.x_fc_dwell_time_minutes"/>m
</t>
<t t-else=""></t>
</td>
<td class="text-center">
<t t-if="wo.duration_expected">
<span t-esc="'%.0f' % wo.duration_expected"/>m
</t>
<t t-else=""></t>
</td>
<td class="sig-line"/>
<td class="sig-line"/>
<td class="sig-line"/>
<td class="sig-line"/>
</tr>
</t>
<t t-else="">
<tr>
<td colspan="12" class="text-center note-row">
No work orders generated yet. Assign a recipe and confirm the MO to generate the routing.
</td>
</tr>
</t>
</tbody>
</table>
<!-- ===== 4. BATH CHEMISTRY TARGETS ===== -->
<t t-set="baths" t-value="wos.mapped('x_fc_bath_id').filtered(lambda b: b.target_line_ids)"/>
<t t-if="baths">
<table class="bordered" style="margin-top: 12px;">
<thead>
<tr><th colspan="5" class="fp-header-primary">BATH CHEMISTRY TARGETS</th></tr>
<tr>
<th style="width: 25%;">Bath</th>
<th style="width: 30%;">Parameter</th>
<th style="width: 15%;">Min</th>
<th style="width: 15%;">Max</th>
<th style="width: 15%;">UOM</th>
</tr>
</thead>
<tbody>
<t t-foreach="baths" t-as="bath">
<t t-foreach="bath.target_line_ids" t-as="p">
<tr>
<td t-if="p_index == 0" t-attf-rowspan="{{ len(bath.target_line_ids) }}">
<span t-field="bath.name"/>
</td>
<td><span t-field="p.parameter_id"/></td>
<td class="text-center"><span t-esc="p.target_min or '—'"/></td>
<td class="text-center"><span t-esc="p.target_max or '—'"/></td>
<td class="text-center"><span t-esc="p.uom or '—'"/></td>
</tr>
</t>
</t>
</tbody>
</table>
</t>
<!-- ===== 5. QUALITY HOLDS — only if present ===== -->
<t t-if="holds">
<div class="highlight-box" style="border-color: #c62828; background-color: #ffebee; margin-top: 12px;">
<strong class="status-fail">
<i class="fa fa-exclamation-triangle"/>
<span t-esc="len(holds)"/> QUALITY HOLD(S) ON THIS JOB
</strong>
<table class="bordered" style="margin-top: 8px; margin-bottom: 0;">
<thead>
<tr>
<th style="width: 12%;">Ref</th>
<th style="width: 13%;">Reason</th>
<th style="width: 10%;">Qty</th>
<th style="width: 10%;">State</th>
<th style="width: 15%;">Operator</th>
<th style="width: 40%;">Description</th>
</tr>
</thead>
<tbody>
<tr t-foreach="holds" t-as="hold">
<td class="text-center"><span t-field="hold.name"/></td>
<td><span t-field="hold.hold_reason"/></td>
<td class="text-center"><span t-field="hold.qty_on_hold"/></td>
<td class="text-center"><span t-field="hold.state"/></td>
<td><span t-field="hold.operator_id"/></td>
<td><span t-field="hold.description"/></td>
</tr>
</tbody>
</table>
</div>
</t>
<!-- ===== 6. CERTIFICATES + DELIVERY — side by side ===== -->
<table style="margin-top: 12px; border: none;">
<tr style="border: none;">
<td style="width: 50%; padding-right: 6px; border: none; vertical-align: top;">
<table class="bordered">
<thead>
<tr><th colspan="3" class="fp-header-primary">CERTIFICATES ISSUED</th></tr>
<tr>
<th style="width: 40%;">Reference</th>
<th style="width: 30%;">Type</th>
<th style="width: 30%;">Status</th>
</tr>
</thead>
<tbody>
<t t-if="certs">
<tr t-foreach="certs" t-as="c">
<td class="text-center"><span t-field="c.name"/></td>
<td class="text-center">
<span t-esc="dict(c._fields['certificate_type'].selection).get(c.certificate_type, c.certificate_type)"/>
</td>
<td class="text-center"><span t-field="c.state"/></td>
</tr>
</t>
<t t-else="">
<tr><td colspan="3" class="text-center note-row">None issued yet.</td></tr>
</t>
</tbody>
</table>
</td>
<td style="width: 50%; padding-left: 6px; border: none; vertical-align: top;">
<table class="bordered">
<thead>
<tr><th colspan="4" class="fp-header-primary">DELIVERY</th></tr>
<tr>
<th style="width: 30%;">Ref</th>
<th style="width: 25%;">State</th>
<th style="width: 20%;">Driver</th>
<th style="width: 25%;">Tracking</th>
</tr>
</thead>
<tbody>
<t t-if="deliveries">
<tr t-foreach="deliveries" t-as="dlv">
<td class="text-center"><span t-field="dlv.name"/></td>
<td class="text-center"><span t-field="dlv.state"/></td>
<td class="text-center">
<t t-if="dlv.assigned_driver_id"><span t-field="dlv.assigned_driver_id"/></t>
<t t-else=""></t>
</td>
<td class="text-center">
<t t-if="'tracking_ref' in dlv._fields and dlv.tracking_ref">
<span t-field="dlv.tracking_ref"/>
</t>
<t t-else=""></t>
</td>
</tr>
</t>
<t t-else="">
<tr><td colspan="4" class="text-center note-row">No delivery scheduled yet.</td></tr>
</t>
</tbody>
</table>
</td>
</tr>
</table>
<!-- ===== 7. REWORK REASON — only if rework ===== -->
<t t-if="mo.x_fc_is_rework and mo.x_fc_rework_reason">
<div class="highlight-box" style="margin-top: 12px;">
<strong>Rework Reason:</strong>
<div style="margin-top: 4px;"><span t-field="mo.x_fc_rework_reason"/></div>
</div>
</t>
<!-- ===== 8. NOTES / EXCEPTIONS (blank ruled area) ===== -->
<div style="margin-top: 14px;">
<strong>Shop Floor Notes / Exceptions:</strong>
<div style="border: 1px solid #000; min-height: 90px; margin-top: 4px; background-image: linear-gradient(to bottom, transparent 29px, #ccc 29px, #ccc 30px, transparent 30px); background-size: 100% 30px;"/>
</div>
<!-- ===== 9. FINAL SIGN-OFF ===== -->
<table class="bordered" style="margin-top: 14px;">
<thead>
<tr>
<th colspan="4" class="fp-header-primary">JOB COMPLETION SIGN-OFF</th>
</tr>
<tr>
<th style="width: 30%;">Shop Supervisor</th>
<th style="width: 20%;">Date</th>
<th style="width: 30%;">QA Inspector</th>
<th style="width: 20%;">Date</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sig-line"/>
<td class="sig-line"/>
<td class="sig-line"/>
<td class="sig-line"/>
</tr>
</tbody>
</table>
<p class="small-muted" style="margin-top: 12px; text-align: right;">
Generated <span t-esc="mo.env.cr.now()" t-options="{'widget': 'datetime'}"/>
— This traveller must remain with the parts through all operations.
</p>
</template>
<!-- ============================================================= -->
<!-- MO-based Traveller — LANDSCAPE (default) -->
<!-- ============================================================= -->
<template id="report_fp_job_traveller_mo_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="mo">
<t t-call="web.external_layout">
<t t-set="doc" t-value="mo"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<t t-call="fusion_plating_reports.report_fp_job_traveller_body"/>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- MO-based Traveller — PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_job_traveller_mo_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="mo">
<t t-call="web.external_layout">
<t t-set="doc" t-value="mo"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<t t-call="fusion_plating_reports.report_fp_job_traveller_body"/>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- SO-based Traveller — iterates the SO's MOs -->
<!-- Landscape default -->
<!-- ============================================================= -->
<template id="report_fp_job_traveller_so_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-set="mos" t-value="so.env['mrp.production'].search([('origin', '=', so.name)])"/>
<t t-if="not mos">
<t t-call="web.external_layout">
<t t-set="doc" t-value="so"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2>Job Traveller — <span t-field="so.name"/></h2>
<div class="highlight-box">
<strong class="status-warning">
<i class="fa fa-info-circle"/>
No Manufacturing Order has been generated for this Sale Order yet.
</strong>
</div>
</div>
</div>
</t>
</t>
<t t-foreach="mos" t-as="mo">
<t t-call="web.external_layout">
<t t-set="doc" t-value="mo"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<t t-call="fusion_plating_reports.report_fp_job_traveller_body"/>
</div>
</div>
</t>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- SO-based Traveller — PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_job_traveller_so_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-set="mos" t-value="so.env['mrp.production'].search([('origin', '=', so.name)])"/>
<t t-if="not mos">
<t t-call="web.external_layout">
<t t-set="doc" t-value="so"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4>Job Traveller — <span t-field="so.name"/></h4>
<div class="highlight-box">
<strong class="status-warning">
<i class="fa fa-info-circle"/>
No Manufacturing Order has been generated for this Sale Order yet.
</strong>
</div>
</div>
</div>
</t>
</t>
<t t-foreach="mos" t-as="mo">
<t t-call="web.external_layout">
<t t-set="doc" t-value="mo"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<t t-call="fusion_plating_reports.report_fp_job_traveller_body"/>
</div>
</div>
</t>
</t>
</t>
</t>
</template>
</odoo>