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>
This commit is contained in:
gsinghpal
2026-05-13 07:41:53 -04:00
parent 677e460438
commit 3182ca3c39

View File

@@ -0,0 +1,439 @@
# 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). |