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

440 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```python
# 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:
```xml
<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:
```python
# 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:
```xml
<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>
```
3. Change the Qty row's value column to show `X / N` when
`_qty_total > 1`:
```xml
<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.):
```xml
<!-- 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:
```xml
<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`:
```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`:
```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`:
```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`:
```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). |