diff --git a/fusion_plating/docs/superpowers/specs/2026-05-13-sticker-multi-part-and-variants-design.md b/fusion_plating/docs/superpowers/specs/2026-05-13-sticker-multi-part-and-variants-design.md new file mode 100644 index 00000000..4c798b12 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-13-sticker-multi-part-and-variants-design.md @@ -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 + +``` + +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 `
` body in a box loop: + +```xml + + +
+ ... existing structure ... +
+
+``` + +3. Change the Qty row's value column to show `X / N` when + `_qty_total > 1`: + +```xml + + Qty: + + + + / + + + + + + + +``` + +**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 + + + + + +``` + +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 + +``` + +#### 3b. New outer templates + action records + +**SO Internal** — in `fusion_plating_reports/report/report_fp_wo_sticker.xml`: + +```xml + +``` + +**SO External** — existing `report_fp_so_sticker` template gets one +addition: ``. +No other logic change (no `_notes_content` set = External default). + +**Job Internal** — in `fusion_plating_jobs/report/report_fp_job_sticker.xml`: + +```xml + +``` + +**Job External** — existing `report_fp_job_sticker_template` +template gets one addition: ``. + +**Action records — labels + new XML IDs** + +In `fusion_plating_reports/report/report_actions.xml`: + +```xml + + + External Sticker + ... + + + + + Internal Sticker + sale.order + qweb-pdf + fusion_plating_reports.report_fp_so_sticker_internal + fusion_plating_reports.report_fp_so_sticker_internal + 'Internal Sticker - %s' % (object.name or '').replace('/', '-') + + report + + +``` + +In `fusion_plating_jobs/report/report_fp_job_sticker.xml`: + +```xml + + + External Job Sticker + ... + + + + + Internal Job Sticker + fp.job + qweb-pdf + fusion_plating_jobs.report_fp_job_sticker_internal_template + fusion_plating_jobs.report_fp_job_sticker_internal_template + 'Internal Job Sticker - %s' % (object.name or '').replace('/', '-') + + report + + +``` + +## 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). |