feat(sub12c): chronological CoC body + body_style opt-in router (Task 3)

New template: fusion_plating_reports.coc_body_chronological.
Walks fp.job.step.move records in time order (chain-of-custody).
Per-move heading 'Step Name (Tank Code)' with 'Moved By / Time / Qty'
meta line + a 5-column measurement sub-table (Name / Description /
Target / Actual / Recorded By) when the destination step has captured
inputs. Heading-only when there are no inputs (gating moves).

New router template: coc_body_router. Picks chronological vs classic
based on fp.certificate.body_style. Existing certs default to 'classic'
so no regressions. Both English + French CoC templates rerouted.

fp.certificate.body_style ('classic' | 'chronological') exposed on
the cert form alongside certified_by_id. Operator picks per cert.

Sign-off block reuses the existing owner_user_id signature pattern +
x_fc_coc_signature_override fallback. Cert statement boilerplate is
inline (Sub 12d will move it to a configurable per-customer field).

The Actual column in the measurement sub-table is rendered blank
because Sub 12a/12b runtime captures step_input values via the
operator's per-step input form which lives in a model not yet wired
into this template — Sub 12d follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-27 21:42:03 -04:00
parent 12fcd11016
commit 9d88c25136
4 changed files with 228 additions and 2 deletions

View File

@@ -70,6 +70,18 @@ class FpCertificate(models.Model):
certified_by_id = fields.Many2one( certified_by_id = fields.Many2one(
'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).', 'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).',
) )
# ===== Sub 12c — chronological CoC opt-in ===============================
body_style = fields.Selection(
[
('classic', 'Classic (recipe-order)'),
('chronological', 'Chronological (chain-of-custody)'),
],
string='CoC Body Style', default='classic',
help='Chronological walks fp.job.step.move records in time order '
'with measurement sub-tables per move, matching Steelhead\'s '
'CoC PDF layout. Classic uses the existing recipe-order body.',
)
issue_date = fields.Date(string='Issue Date', default=fields.Date.today, tracking=True) issue_date = fields.Date(string='Issue Date', default=fields.Date.today, tracking=True)
attachment_id = fields.Many2one('ir.attachment', string='Certificate PDF') attachment_id = fields.Many2one('ir.attachment', string='Certificate PDF')
thickness_reading_ids = fields.One2many( thickness_reading_ids = fields.One2many(

View File

@@ -93,6 +93,7 @@
<group> <group>
<field name="issued_by_id"/> <field name="issued_by_id"/>
<field name="certified_by_id"/> <field name="certified_by_id"/>
<field name="body_style"/>
</group> </group>
<group> <group>
<field name="reading_count" readonly="1"/> <field name="reading_count" readonly="1"/>

View File

@@ -307,7 +307,8 @@
<t t-call="web.external_layout"> <t t-call="web.external_layout">
<t t-set="LANG" t-value="'en'"/> <t t-set="LANG" t-value="'en'"/>
<div class="page"> <div class="page">
<t t-call="fusion_plating_reports.coc_body"/> <!-- Sub 12c — router picks chronological vs classic body -->
<t t-call="fusion_plating_reports.coc_body_router"/>
</div> </div>
</t> </t>
</t> </t>
@@ -324,7 +325,8 @@
<t t-call="web.external_layout"> <t t-call="web.external_layout">
<t t-set="LANG" t-value="'fr'"/> <t t-set="LANG" t-value="'fr'"/>
<div class="page"> <div class="page">
<t t-call="fusion_plating_reports.coc_body"/> <!-- Sub 12c — router picks chronological vs classic body -->
<t t-call="fusion_plating_reports.coc_body_router"/>
</div> </div>
</t> </t>
</t> </t>

View File

@@ -0,0 +1,211 @@
<?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 12c — Chronological CoC body.
Walks fp.job.step.move records in time order (chain-of-custody),
rendering each transition as a heading ("Step Name (Tank Code)")
with a "Moved By / Time" meta line + a 5-column measurement
sub-table when the destination step has captured input values.
Mirrors Steelhead's CoC PDF layout (screens 19-24).
Wired via fp.certificate.body_style = 'chronological' through the
coc_body_router template.
-->
<odoo>
<template id="coc_body_chronological">
<!-- Resolve the linked job. fp.certificate has x_fc_job_id (Sub 11+). -->
<t t-set="job" t-value="('x_fc_job_id' in doc._fields and doc.x_fc_job_id) or False"/>
<t t-set="moves" t-value="(job and 'move_ids' in job._fields and job.move_ids.sorted('move_datetime')) or []"/>
<style>
.fp-coc-chrono { font-family: Arial, sans-serif; font-size: 9pt; color: #000; padding-top: 8mm; }
.fp-coc-chrono h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; }
.fp-coc-chrono h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-coc-chrono .fp-chrono-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-coc-chrono table.bordered,
.fp-coc-chrono table.bordered th,
.fp-coc-chrono table.bordered td { border: 1px solid #000; border-collapse: collapse; }
.fp-coc-chrono table.bordered { width: 100%; margin-bottom: 8px; }
.fp-coc-chrono table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: center; }
.fp-coc-chrono table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
.fp-coc-chrono .text-center { text-align: center; }
.fp-coc-chrono hr.heavy { border: 0; border-top: 2px solid #000; margin: 8px 0; }
</style>
<div class="fp-coc-chrono">
<h1>Certificate of Conformance</h1>
<!-- Job header (compact) -->
<table class="bordered">
<tr>
<th style="width: 18%;">Part Number</th>
<th style="width: 28%;">Description</th>
<th style="width: 8%;">Quantity</th>
<th style="width: 8%;">Work Order</th>
<th style="width: 14%;">PO Number</th>
<th style="width: 14%;">Packing List No</th>
<th style="width: 10%;">Date</th>
</tr>
<tr>
<td>
<t t-if="job and 'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or job.product_id.default_code or '—'"/>
</t>
<t t-else="">
<span t-esc="(job and job.product_id and job.product_id.default_code) or '—'"/>
</t>
</td>
<td>
<t t-if="job and 'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
</t>
<t t-else="">
<span t-esc="(job and job.product_id and job.product_id.name) or '—'"/>
</t>
</td>
<td class="text-center">
<span t-esc="(job and job.qty) or ''"/>
</td>
<td class="text-center">
<span t-esc="(job and job.name) or '—'"/>
</td>
<td>
<span t-esc="(job and job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
</td>
<td/>
<td>
<span t-esc="(doc.create_date and doc.create_date.strftime('%Y-%m-%d')) or ''"/>
</td>
</tr>
</table>
<h3 style="margin-top: 6px;">Specification(s):
<span style="font-weight: normal;"
t-esc="(job and job.recipe_id and job.recipe_id.name) or '—'"/>
</h3>
<hr class="heavy"/>
<!-- Chain-of-custody walk -->
<t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<t t-set="captured" t-value="(dest and dest.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')) or []"/>
<h3>
<span t-esc="(dest and dest.name) or '—'"/>
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
</h3>
<div class="fp-chrono-meta">
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
<span> · </span>
<strong>Time:</strong>
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
<t t-if="mv.qty_moved">
<span> · </span>
<strong>Qty:</strong> <span t-esc="mv.qty_moved"/>
</t>
</div>
<!-- Measurement sub-table — only when the destination step has step_input prompts -->
<t t-if="captured">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 14%;">Target</th>
<th style="width: 18%;">Actual</th>
<th style="width: 14%;">Recorded By</th>
</tr>
</thead>
<tbody>
<t t-foreach="captured" t-as="inp">
<tr>
<td><span t-esc="inp.name"/></td>
<td>
<t t-if="inp.hint">
<span t-esc="inp.hint"/>
</t>
</td>
<td class="text-center">
<t t-if="'target_min' in inp._fields and inp.target_min and inp.target_max">
<span t-esc="inp.target_min"/><span t-esc="inp.target_max"/>
<t t-if="'target_unit' in inp._fields and inp.target_unit">
<span> </span><span t-esc="inp.target_unit"/>
</t>
</t>
<t t-elif="'target_unit' in inp._fields and inp.target_unit">
<span t-esc="inp.target_unit"/>
</t>
</td>
<td/>
<td>
<span t-esc="(mv.moved_by_user_id and mv.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</t>
<hr class="heavy"/>
<!-- Sign-off block (re-uses owner_user_id signature pattern from coc_body) -->
<t t-set="owner_sig" t-value="False"/>
<t t-if="company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
</t>
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override or owner_sig"/>
<t t-set="signer_name" t-value="(doc.certified_by_id and doc.certified_by_id.name) or (company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<table class="bordered">
<tr>
<td style="width: 50%; vertical-align: top;">
<strong>Certified By:</strong><br/>
<t t-if="signature_img">
<img t-att-src="'data:image/png;base64,%s' % signature_img.decode()"
style="max-height: 22mm; max-width: 70mm;"/>
</t><br/>
<strong>Name:</strong> <span t-esc="signer_name"/>
</td>
<td style="width: 50%; vertical-align: top;">
<strong>Certification Statement:</strong>
<span style="font-size: 8.5pt;">
Ref. WO# <span t-esc="(job and job.name) or ''"/>
</span>
<p style="font-size: 8pt; margin-top: 4px;">
We certify that the parts listed above have been processed
in accordance with the specifications referenced and that
all required tests have been performed. Records on file at
our facility per AS9100 / ISO 9001 retention policy.
</p>
</td>
</tr>
</table>
</div>
</template>
<!-- ============================================================== -->
<!-- Router — picks chronological vs classic body -->
<!-- Wired into the existing CoC actions in report_coc.xml. -->
<!-- ============================================================== -->
<template id="coc_body_router">
<t t-if="doc.body_style == 'chronological' and 'x_fc_job_id' in doc._fields and doc.x_fc_job_id">
<t t-call="fusion_plating_reports.coc_body_chronological"/>
</t>
<t t-else="">
<t t-call="fusion_plating_reports.coc_body"/>
</t>
</template>
</odoo>