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:
@@ -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
|
||||
424–441), 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 > 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). |
|
||||
Reference in New Issue
Block a user