Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-13-sticker-multi-part-and-variants-design.md
gsinghpal 3182ca3c39 docs: design spec — sticker multi-part / per-box / Internal+External
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>
2026-05-13 07:41:53 -04:00

18 KiB
Raw Blame History

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:

  1. Silent thickness/SN merge bug. When two SO lines share (recipe, part, coating) but differ in thickness or serial, the current _create_fp_jobs grouping collapses them into one fp.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).
  2. No per-box stickers. A line with qty = 5 prints one sticker showing Qty: 5. Operators want one physical label per box, with a 1 / 5, 2 / 5, ... indicator.
  3. 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.job records with correct WO-XXXXX-NN naming.
  • SO sticker and Job sticker render one page per physical box, with a Box X / N indicator replacing the current Qty: 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_id is 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 to sale.order via action_report_fp_so_sticker) — iterates so.order_line.filtered(lambda l: l.x_fc_part_catalog_id), renders one inner per line.
  • report_fp_job_sticker_template (in fusion_plating_jobs/report/report_fp_job_sticker.xml, bound to fp.job via action_report_fp_job_sticker) — iterates docs, 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 424441), 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:

  1. 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).
  2. 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>
  1. Change the Qty row's value column to show X / N when _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 &gt; 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 existing if existing: return idempotency guard. Single-line and same-(thickness, SN) multi-line SOs collapse identically to before.
  • Existing XML IDs unchanged — bookmarks / binding_model_id records 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.5
  • WO-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_id is one per line. If the customer needs unique SNs per physical box (5 parts = 5 SNs), build out an fp.box.serial registry 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_count field 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).