Three problems on the box-sticker stack rolled into one spec: 1. Backend: _create_fp_jobs grouping key collapses lines with different thicknesses or SNs into one job. Silent compliance hole. Fix: add thickness_id + serial_id to the key tuple. 2. No per-box stickers: a line with qty=5 prints 1 page showing "Qty: 5". Want 5 pages with "1 / 5", "2 / 5", ... "5 / 5". 3. No Internal variant: sticker always reads line.name (customer facing). Want a parallel variant that reads x_fc_internal_description (Sub 2 internal description field). Renaming: existing actions keep their XML IDs (bookmarks / binding_model_id records survive). Labels become: sale.order: External Sticker + Internal Sticker (new) fp.job: External Job Sticker + Internal Job Sticker (new) All three changes share the same inner template, same files — ship together. No data migration required; existing fp.jobs are protected by the idempotency guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
Sticker — Multi-part, Per-box, Internal/External Variants
Date: 2026-05-13
Module(s): fusion_plating_jobs, fusion_plating_reports
Author: Gurpreet (Nexa Systems Inc.)
Status: Approved — ready for implementation plan
Summary
The box sticker (printed at SO level and at fp.job level) currently mishandles three real-world scenarios on multi-line orders:
- Silent thickness/SN merge bug. When two SO lines share
(recipe, part, coating)but differ in thickness or serial, the current_create_fp_jobsgrouping collapses them into onefp.job. The job inherits the FIRST line's thickness/SN — the other line's values are silently dropped from the sticker (and eventually from the CoC). - No per-box stickers. A line with
qty = 5prints one sticker showingQty: 5. Operators want one physical label per box, with a1 / 5,2 / 5, ... indicator. - No Internal variant. The sticker always prints the
customer-facing description (
_line.name) in the Notes column. The shop floor wants a parallel variant that shows the internal ops description (_line.x_fc_internal_description, from Sub 2) instead.
This spec covers all three as a single piece of work — they touch the same files and ship together.
Goals / non-goals
Goals
- Multi-thickness / multi-SN lines split into separate
fp.jobrecords with correct WO-XXXXX-NN naming. - SO sticker and Job sticker render one page per physical box,
with a
Box X / Nindicator replacing the currentQty: N. - New "Internal" variant for each sticker that prints the internal description in the Notes column. Existing variant becomes "External".
- Both variants share the same inner template — only the Notes source differs.
- Existing action XML IDs unchanged so bookmarks and binding records keep working.
Non-goals
- Per-physical-box serial number tracking (today's
x_fc_serial_idis one per line, shared across all boxes in that line — that's fine). - Box-count override (today: 1 sticker per qty unit; if the shop packs 5 parts into 1 box, that's an operational choice the sticker doesn't try to encode).
- Migration of pre-existing single-line, single-thickness jobs — they remain as-is.
Current state (post Sub 11)
Backend — fusion_plating_jobs/models/sale_order.py
# Inside _create_fp_jobs(), the grouping key:
key = (recipe.id, part_id, coating_id)
groups[key] = groups.get(key, ...) | line
Lines that share ALL THREE collapse into one fp.job. Sub 11's
comment explicitly calls out the part_id+coating_id check ("sharing
only the recipe is not enough — would put Part A's number on a cert
covering both") but doesn't extend the same reasoning to thickness
or SN. The thickness Many2one (x_fc_thickness_id) and serial
Many2one (x_fc_serial_id) were added in Sub 5, after the grouping
logic was last touched.
Sticker — fusion_plating_reports/report/report_fp_wo_sticker.xml
Two outer templates wrap a shared inner:
report_fp_so_sticker(bound tosale.orderviaaction_report_fp_so_sticker) — iteratesso.order_line.filtered(lambda l: l.x_fc_part_catalog_id), renders one inner per line.report_fp_job_sticker_template(infusion_plating_jobs/report/report_fp_job_sticker.xml, bound tofp.jobviaaction_report_fp_job_sticker) — iteratesdocs, renders one inner per job.
Neither outer accounts for qty > 1 — each line/job produces
exactly one inner render.
The inner template report_fp_wo_sticker_inner sets variables and
renders one page. The Notes content is fixed:
<t t-set="_notes_content" t-value="(_line and _line.name)
or (_part and _part.name)
or '-'"/>
There is no way for an outer to override this — it's a hard read of
_line.name.
Architecture — the three changes
Change 1 — Backend split: extend grouping key
In fusion_plating_jobs/models/sale_order.py, in the method that
builds the groups dict (currently _create_fp_jobs around line
424–441), extend the key tuple:
# Before
key = (recipe.id, part_id, coating_id)
# After
thickness_id = (
'x_fc_thickness_id' in line._fields
and line.x_fc_thickness_id.id
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
Effect: Lines that previously merged silently across different
thicknesses or SNs now split into separate fp.jobs. WO-XXXXX-NN
suffixes apply normally (driven by the existing
ordered_keys = sorted(...) block — no change needed there).
Backwards compat: Single-line SOs and same-(thickness, SN) multi-line SOs collapse identically to before. No data migration required.
Change 2 — Per-box render in the inner template
fusion_plating_reports/report/report_fp_wo_sticker.xml, in the
report_fp_wo_sticker_inner template:
- Move the variable-resolution + style block OUT of the per-page render (these don't change per box, so they don't need to repeat).
- Wrap the
<div class="fp-sticker">body in a box loop:
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
<div class="fp-sticker">
... existing structure ...
</div>
</t>
- Change the Qty row's value column to show
X / Nwhen_qty_total > 1:
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<t t-if="_qty_total and _qty_total > 1">
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
</t>
<t t-else="">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</t>
</span>
</td>
</tr>
Outer templates supply _qty_total:
- SO outer:
_qty_total = line.product_uom_qty - Job outer:
_qty_total = job.qty
If _qty_total is missing/zero, fall back to 1 so single-box
behavior is unchanged.
Change 3 — Internal/External variants
3a. Inner template: override-or-fallback on _notes_content
In report_fp_wo_sticker_inner, change the _notes_content set
from a hard read to override-or-fallback (matches the existing
pattern for _so, _part, etc.):
<!-- Was: -->
<t t-set="_notes_content" t-value="(_line and _line.name)
or (_part and _part.name)
or '-'"/>
<!-- After: -->
<t t-set="_notes_content" t-value="_notes_content
or (_line and _line.name)
or (_part and _part.name)
or '-'"/>
External outer templates don't set _notes_content → falls through
to _line.name (unchanged External behavior).
Internal outer templates pre-set _notes_content before
t-calling the inner:
<t t-set="_notes_content" t-value="(_line and 'x_fc_internal_description' in _line._fields
and _line.x_fc_internal_description) or '-'"/>
3b. New outer templates + action records
SO Internal — in fusion_plating_reports/report/report_fp_wo_sticker.xml:
<template id="report_fp_so_sticker_internal">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Override: read internal description instead of line.name -->
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
and line.x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</t>
</template>
SO External — existing report_fp_so_sticker template gets one
addition: <t t-set="_qty_total" t-value="line.product_uom_qty"/>.
No other logic change (no _notes_content set = External default).
Job Internal — in fusion_plating_jobs/report/report_fp_job_sticker.xml:
<template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Override: read internal description from first linked SO line -->
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</template>
Job External — existing report_fp_job_sticker_template
template gets one addition: <t t-set="_qty_total" t-value="job.qty"/>.
Action records — labels + new XML IDs
In fusion_plating_reports/report/report_actions.xml:
<!-- Existing record — rename label only -->
<record id="action_report_fp_so_sticker" model="ir.actions.report">
<field name="name">External Sticker</field> <!-- was: "WO Box Sticker" -->
...
</record>
<!-- New record -->
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
<field name="name">Internal Sticker</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
In fusion_plating_jobs/report/report_fp_job_sticker.xml:
<!-- Existing record — rename label only -->
<record id="action_report_fp_job_sticker" model="ir.actions.report">
<field name="name">External Job Sticker</field> <!-- was: "Job Sticker" -->
...
</record>
<!-- New record -->
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
<field name="name">Internal 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_internal_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="print_report_name">'Internal 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>
Files touched
| # | File | Change |
|---|---|---|
| 1 | fusion_plating_jobs/models/sale_order.py |
Extend grouping key in _create_fp_jobs (+5 lines) |
| 2 | fusion_plating_reports/report/report_fp_wo_sticker.xml |
Inner template: box loop, Qty row logic, _notes_content fallback chain. SO outer: add _qty_total. NEW: SO Internal outer template. |
| 3 | fusion_plating_reports/report/report_actions.xml |
Rename existing SO action label. NEW: SO Internal action record. |
| 4 | fusion_plating_jobs/report/report_fp_job_sticker.xml |
Job outer: add _qty_total. Rename existing job action label. NEW: Job Internal outer template + action record. |
| 5 | fusion_plating_jobs/__manifest__.py |
Version bump |
| 6 | fusion_plating_reports/__manifest__.py |
Version bump |
Migration
None required.
- New grouping key (
_create_fp_jobs) is purely additive — existing jobs are protected by the existingif existing: returnidempotency guard. Single-line and same-(thickness, SN) multi-line SOs collapse identically to before. - Existing XML IDs unchanged — bookmarks /
binding_model_idrecords keep working. Only the visible label flips. - New variants appear in the Print menu on next module upgrade with no data work.
Testing
Scenario 1 — Multi-thickness split (new fp.jobs)
Create a new SO with two lines:
- Line 10: Part A, Coating X, Thickness 0.3-0.5 mils, qty 2
- Line 20: Part A, Coating X, Thickness 0.5-1.0 mils, qty 1
Confirm SO → 2 fp.jobs are created:
WO-XXXXX-01: qty 2, thickness 0.3-0.5WO-XXXXX-02: qty 1, thickness 0.5-1.0
Print each job's External sticker → confirm correct thickness on each.
Scenario 2 — Per-box rendering
Take Scenario 1's SO, click "Print → External Sticker" on the SO.
Confirm: 3-page PDF.
- Page 1: Line 10 box 1 → Qty row shows
1 / 2 - Page 2: Line 10 box 2 → Qty row shows
2 / 2 - Page 3: Line 20 box 1 → Qty row shows
1
Scenario 3 — Internal variant
On the same SO, click "Print → Internal Sticker".
Confirm: same 3 pages, same WO#/PO#/Customer/Part#/SN/Thickness/Qty,
but the Notes column shows x_fc_internal_description from each
line instead of name.
If x_fc_internal_description is blank on a line, Notes shows -.
Scenario 4 — Regression check (existing single-line)
Re-print SO-30019 (1 line, qty 1) → External sticker prints
single-page, no X / N indicator, Notes shows _line.name as
before. Internal variant: single-page, Notes shows x_fc_internal_description
or -.
Scenario 5 — Job-level multi-box
Take any existing fp.job with qty = 3. Print External Job Sticker.
Confirm: 3 pages, 1/3, 2/3, 3/3. Internal Job Sticker also 3
pages with the line's internal description in Notes.
Scenario 6 — Action menu visibility
On a sale order Print menu: both "External Sticker" and "Internal Sticker" appear. On an fp.job Print menu: both "External Job Sticker" and "Internal Job Sticker" appear.
Out-of-scope items (deferred)
- Per-box SN registry. Today
x_fc_serial_idis one per line. If the customer needs unique SNs per physical box (5 parts = 5 SNs), build out anfp.box.serialregistry that links to the line. Out of scope for this spec — would need workflow design (UI for assigning, where SNs print, etc.). - Box count ≠ qty. Some shops pack multiple parts per box.
Today this spec assumes 1 sticker per qty unit. If needed,
add an
x_fc_box_countfield on the line that defaults to qty but can be overridden, and the sticker loops over box_count instead. Defer until requested. - Sticker preview UI in the form view. No live preview today; operators print + visually verify. Defer.
Open questions
None — all decisions locked at spec time:
| Q | Decision |
|---|---|
| Add SN to grouping key? | Yes. Same reasoning as thickness — silent merge of different SNs is a compliance hole. |
| Per-box indicator location? | Replace Qty row value. Operator's confirmation: "we can use the quantity field portion for the box, there is room we can use rather than creating another line below and making everything smaller." |
| Box indicator format? | 1 / 5 (slash, spaces around for legibility at 50pt). When qty=1, show plain 1 (no slash) — matches current behavior. |
| Label naming convention? | Prefix. External Sticker / Internal Sticker (SO Print menu), External Job Sticker / Internal Job Sticker (fp.job Print menu). |
| Migration for existing jobs? | None. Idempotency guard in _create_fp_jobs protects them. |
| Existing action XML IDs? | Unchanged. Only labels rename — bookmarks/binding records survive. |
| Fractional qty? | Cast to int(qty) — current behavior preserved. |
| Qty=0 line? | Already filtered out by lambda l: l.x_fc_part_catalog_id (no part → no sticker). |