Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-13-sticker-multi-part-and-variants-plan.md
gsinghpal 135cbd3a5c docs: implementation plan — sticker multi-part / per-box / Internal+External
7 tasks, bite-sized steps with exact code + commands. TDD on the
backend grouping change (new test_so_confirm_splits_by_thickness);
deploy-and-render-PDF on the QWeb template changes. Each task
self-contained, pushes to entech LXC 111 via the standard pct
exec + cat-pipe path, bumps the module version, and commits.

Task 7 is verification-only — creates a multi-line test SO with
two different thicknesses, renders External + Internal stickers
on both the SO and each spawned fp.job, confirms the box loop
and the Notes variant pattern both work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:47:40 -04:00

1049 lines
49 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 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix multi-thickness/multi-SN grouping in `_fp_auto_create_job`, add per-box rendering to the sticker, and add a parallel Internal variant of each sticker that shows the internal description in the Notes column.
**Architecture:** Three changes touching three files. Backend: extend the fp.job grouping key to include thickness + serial. Frontend: inner sticker template gains a `range(int(_qty_total))` loop and a fallback chain on `_notes_content`. New outer templates per variant pre-set `_notes_content` and `_qty_total`. Existing action XML IDs unchanged; new actions added for the Internal variants.
**Tech Stack:** Odoo 19 (Python/QWeb XML), wkhtmltopdf 0.12, native fp.job model (post Sub 11 — no MRP), entech LXC 111 deployment.
**Spec:** `docs/superpowers/specs/2026-05-13-sticker-multi-part-and-variants-design.md`
---
## File Structure
| File | Responsibility | Touch |
|---|---|---|
| `fusion_plating_jobs/models/sale_order.py` | `_fp_auto_create_job()` — owns the line→job grouping key | Modify ~lines 415441 |
| `fusion_plating_jobs/tests/test_fp_job_extensions.py` | Existing test suite for SO→fp.job creation | Modify (add multi-thickness test) |
| `fusion_plating_reports/report/report_fp_wo_sticker.xml` | Inner template (shared by SO+Job stickers), SO outer templates, defaults block | Modify inner + SO outer + add SO Internal outer |
| `fusion_plating_reports/report/report_actions.xml` | Action records for sale.order-bound reports | Modify SO action label + add SO Internal action |
| `fusion_plating_jobs/report/report_fp_job_sticker.xml` | Job outer template + action record | Modify Job outer + Job action label + add Job Internal outer + Job Internal action |
| `fusion_plating_jobs/__manifest__.py` | Version | Bump |
| `fusion_plating_reports/__manifest__.py` | Version | Bump |
---
## Deployment helpers
Every task that changes code pushes to entech LXC 111 + upgrades. Reusable command blocks:
**Push a file to entech:**
```bash
cat LOCAL_PATH | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > REMOTE_PATH'"
```
where `REMOTE_PATH` mirrors the repo structure under `/mnt/extra-addons/custom/`.
**Upgrade a module + restart:**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u MODULE_NAME --stop-after-init\" && systemctl start odoo'"
```
**Clear asset cache after a template/XML change:**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\"'"
```
**Render a sticker PDF via odoo shell** (substitute `TEMPLATE_REF` and `RECORD_ID`):
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''TEMPLATE_REF'\'', [RECORD_ID])
with open('\''/tmp/out.pdf'\'', '\''wb'\'') as f:
f.write(pdf)
print('\''SIZE:'\'', len(pdf))
EOF"'
```
**Pull rendered PDF locally:**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/out.pdf'" > /tmp/out.pdf
```
---
### Task 1: Backend — Add thickness + serial to fp.job grouping key
**Goal:** Lines that differ in thickness or serial number now spawn separate fp.jobs instead of silently collapsing.
**Files:**
- Modify: `fusion_plating_jobs/models/sale_order.py` (lines 415441 in `_fp_auto_create_job`)
- Modify: `fusion_plating_jobs/tests/test_fp_job_extensions.py` (add new test method)
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
- [ ] **Step 1: Add failing test for multi-thickness split**
Open `fusion_plating_jobs/tests/test_fp_job_extensions.py`. Find the class `TestSOConfirmCreatesJob` (contains `test_so_confirm_creates_job` ~line 289). Add this method to the class:
```python
def test_so_confirm_splits_by_thickness(self):
"""Two lines with same part+coating but DIFFERENT thicknesses
must produce TWO fp.jobs — silent merge was a compliance bug
(the second thickness's CoC would carry the first thickness)."""
SOL = self.env['sale.order.line']
if 'x_fc_part_catalog_id' not in SOL._fields or 'x_fc_thickness_id' not in SOL._fields:
self.skipTest('plating fields not present')
partner_for_part = self.env['res.partner'].create({'name': 'SplitTestPartner'})
part = self.env['fp.part.catalog'].create({
'name': 'SplitTestPart',
'part_number': 'STP-1',
'partner_id': partner_for_part.id,
})
thickness_a = self.env['fp.coating.thickness'].create({
'value_min': 0.3, 'value_max': 0.5, 'uom': 'mils',
})
thickness_b = self.env['fp.coating.thickness'].create({
'value_min': 0.5, 'value_max': 1.0, 'uom': 'mils',
})
so = self.env['sale.order'].create({'partner_id': self.partner.id})
SOL.create({
'order_id': so.id, 'product_id': self.product.id,
'product_uom_qty': 2.0, 'price_unit': 10.0,
'x_fc_part_catalog_id': part.id,
'x_fc_thickness_id': thickness_a.id,
})
SOL.create({
'order_id': so.id, 'product_id': self.product.id,
'product_uom_qty': 1.0, 'price_unit': 10.0,
'x_fc_part_catalog_id': part.id,
'x_fc_thickness_id': thickness_b.id,
})
so.action_confirm()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2,
'Lines with different thicknesses must spawn separate fp.jobs')
thicknesses = jobs.sale_order_line_ids.mapped('x_fc_thickness_id')
self.assertEqual(len(thicknesses), 2,
'Each job should carry its own thickness via its linked SO line')
```
- [ ] **Step 2: Run test, expect FAIL**
Push the test file to entech, then run:
```bash
cat fusion_plating_jobs/tests/test_fp_job_extensions.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_extensions.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags=/fusion_plating_jobs:TestSOConfirmCreatesJob.test_so_confirm_splits_by_thickness --stop-after-init\"'" 2>&1 | tail -30
```
Expected: `AssertionError: 1 != 2` (the current code collapses both lines into one job).
- [ ] **Step 3: Modify the grouping key in `_fp_auto_create_job`**
Open `fusion_plating_jobs/models/sale_order.py`. Find the comment block starting at line 415 (`# Group by (recipe, part, coating).`) and the loop body starting at line 426 (`for line in plating_lines:`).
Replace this block:
```python
# Group by (recipe, part, coating). Lines that share ALL THREE
# collapse into one WO. Sharing only the recipe is not enough —
# the WO header captures part_id and coating_config_id from
# first_line, and downstream the CoC prints the WO header's
# part_number on the customer-facing cert. Bundling Part A +
# Part B under one WO because they happen to share a recipe
# would put Part A's number on a cert covering both, which is
# a compliance bug (silent mis-attestation).
# No-recipe lines get their own group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
coating_id = (
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
) or False
if recipe:
key = (recipe.id, part_id, coating_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
with:
```python
# Group by (recipe, part, coating, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning
# as part_id + coating_id: bundling lines with different thicknesses
# or different serials under one WO would carry the first line's
# values onto the cert + sticker — silent mis-attestation. Sub 5
# added thickness_id + serial_id; this fix extends the grouping
# logic to honour them. No-recipe lines get their own group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
coating_id = (
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
) or False
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
if recipe:
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
- [ ] **Step 4: Bump fusion_plating_jobs version**
Open `fusion_plating_jobs/__manifest__.py`, find the `'version'` line, increment the patch component. e.g. `'19.0.8.22.10'``'19.0.8.23.0'`.
- [ ] **Step 5: Push to entech + run test, expect PASS**
```bash
cat fusion_plating_jobs/models/sale_order.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/models/sale_order.py'"
cat fusion_plating_jobs/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-tags=/fusion_plating_jobs:TestSOConfirmCreatesJob --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -30
```
Expected: tests pass (both `test_so_confirm_creates_job`, `test_so_confirm_idempotent`, `test_so_confirm_splits_by_thickness`).
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_jobs/models/sale_order.py \
fusion_plating_jobs/tests/test_fp_job_extensions.py \
fusion_plating_jobs/__manifest__.py
git commit -m "$(cat <<'EOF'
fix(jobs): split fp.jobs by thickness + serial on SO confirm
The _fp_auto_create_job grouping key was (recipe, part, coating).
Lines that shared all three but differed in thickness (or serial)
silently collapsed into one fp.job — the second line's thickness/SN
was lost, and any downstream cert printed the first line's values
across both batches. Silent mis-attestation = compliance hole.
Extended the key tuple to (recipe, part, coating, thickness, serial).
Single-line SOs and same-(thickness, SN) multi-line SOs collapse
identically to before. Only lines that previously merged when they
shouldn't have now split into their own fp.jobs.
Added test_so_confirm_splits_by_thickness covering the case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Sticker template — defaults + Notes fallback + per-box loop
**Goal:** Inner template renders N pages when `_qty_total > 1`, accepts an outer-supplied `_notes_content` override, defaults block initializes the new variables.
**Files:**
- Modify: `fusion_plating_reports/report/report_fp_wo_sticker.xml`
- Modify: `fusion_plating_reports/__manifest__.py`
- [ ] **Step 1: Add `_notes_content` + `_qty_total` to the defaults template**
Open `fusion_plating_reports/report/report_fp_wo_sticker.xml`. Find the `report_fp_wo_sticker_defaults` template (around line 360). Inside it, after the last `<t t-set="..." t-value="False"/>` line, add:
```xml
<t t-set="_notes_content" t-value="False"/>
<t t-set="_qty_total" t-value="False"/>
```
- [ ] **Step 2: Convert `_notes_content` to override-or-fallback in the inner template**
Same file. Find the inner template's existing line:
```xml
<t t-set="_notes_content" t-value="(_line and _line.name)
or (_part and _part.name)
or '-'"/>
```
Replace with:
```xml
<!-- _notes_content: outer can pre-set this (e.g. Internal variant
passes line.x_fc_internal_description). Otherwise falls back
to the customer-facing description on the SO line. -->
<t t-set="_notes_content" t-value="_notes_content
or (_line and _line.name)
or (_part and _part.name)
or '-'"/>
```
- [ ] **Step 3: Wrap the sticker body in a per-box loop**
Same file. Find the `<div class="fp-sticker">` opening tag inside the inner template (after the `</style>` close). Wrap the entire div in a `t-foreach` 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 unchanged ...
</div>
</t>
```
The closing `</t>` for the foreach goes immediately after the closing `</div>` of the sticker.
- [ ] **Step 4: Change Qty row to show "X / N" when qty>1**
Same file. Find the Qty row in the inner template body:
```xml
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</span>
</td>
</tr>
```
Replace with:
```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 int(_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>
```
- [ ] **Step 5: Bump fusion_plating_reports version**
Open `fusion_plating_reports/__manifest__.py`, increment patch component. e.g. `'19.0.10.11.0'``'19.0.10.12.0'`.
- [ ] **Step 6: Push + upgrade + clear cache**
```bash
cat fusion_plating_reports/report/report_fp_wo_sticker.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/report/report_fp_wo_sticker.xml'"
cat fusion_plating_reports/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/__manifest__.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_reports --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -3
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\"'"
```
- [ ] **Step 7: Verify regression-free on existing single-line SO**
Render the existing fp.job 2635 sticker (SO-30019, 1 line, qty 1):
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_jobs.report_fp_job_sticker_template'\'', [2635])
with open('\''/tmp/sticker_t2.pdf'\'', '\''wb'\'') as f:
f.write(pdf)
print('\''SIZE:'\'', len(pdf))
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t2.pdf'" > /tmp/sticker_t2.pdf
```
Read the PDF (`/tmp/sticker_t2.pdf`) — expected: single page, Qty row shows `1` (no slash), Notes shows `_line.name` content. Identical to the pre-Task-2 baseline. The job is qty=1 so `_qty_total = 1` → falls into the `t-else` branch.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating_reports/report/report_fp_wo_sticker.xml \
fusion_plating_reports/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(sticker): per-box render loop + Notes override hook
Inner sticker template gains two parameters that outer templates
pre-set:
_qty_total — total qty for the line/job. Inner wraps the body
in t-foreach="range(int(_qty_total or 1))" so a qty=5 line
produces 5 consecutive single-box stickers. Qty row in the
body switches from "5" to "1 / 5", "2 / 5", ... "5 / 5".
When _qty_total is missing/0/1, the Qty row keeps showing
the plain integer (regression-free).
_notes_content — Notes column source. Existing inner code
hard-read _line.name; new code accepts an outer override
and falls back to _line.name. External outers don't set it
(unchanged behaviour); the new Internal outers (Task 4+5)
pre-set it to x_fc_internal_description.
Defaults template initialises both new vars to False so the
inner's "outer-supplied OR fallback" pattern doesn't NameError
when called from existing outers that haven't been updated yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: Wire `_qty_total` in External outer templates
**Goal:** SO External + Job External outers now pass `_qty_total` so the inner's box loop activates.
**Files:**
- Modify: `fusion_plating_reports/report/report_fp_wo_sticker.xml` (SO External outer)
- Modify: `fusion_plating_jobs/report/report_fp_job_sticker.xml` (Job External outer)
- Modify: `fusion_plating_reports/__manifest__.py` (version bump)
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
- [ ] **Step 1: Add `_qty_total` to SO outer template**
Open `fusion_plating_reports/report/report_fp_wo_sticker.xml`. Find the `report_fp_so_sticker` outer template (around line 427). After the line:
```xml
<t t-set="_qty" t-value="line.product_uom_qty"/>
```
Insert immediately below:
```xml
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
```
- [ ] **Step 2: Add `_qty_total` to Job outer template**
Open `fusion_plating_jobs/report/report_fp_job_sticker.xml`. Find the `report_fp_job_sticker_template` template (around line 43). After the line:
```xml
<t t-set="_qty" t-value="job.qty"/>
```
Insert immediately below:
```xml
<t t-set="_qty_total" t-value="job.qty"/>
```
- [ ] **Step 3: Bump both module versions**
`fusion_plating_reports/__manifest__.py`: increment patch (e.g. `19.0.10.12.0``19.0.10.13.0`).
`fusion_plating_jobs/__manifest__.py`: increment patch (e.g. `19.0.8.23.0``19.0.8.24.0`).
- [ ] **Step 4: Push + upgrade both modules + clear cache**
```bash
cat fusion_plating_reports/report/report_fp_wo_sticker.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/report/report_fp_wo_sticker.xml'"
cat fusion_plating_reports/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/__manifest__.py'"
cat fusion_plating_jobs/report/report_fp_job_sticker.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/report/report_fp_job_sticker.xml'"
cat fusion_plating_jobs/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_reports,fusion_plating_jobs --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -3
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\"'"
```
- [ ] **Step 5: Verify multi-box rendering via odoo shell**
Bump fp.job 2635's `qty` to 3 temporarily, render, then restore. Use odoo shell:
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
job = env['\''fp.job'\''].browse(2635)
original_qty = job.qty
job.write({'\''qty'\'': 3.0})
env.cr.commit()
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_jobs.report_fp_job_sticker_template'\'', [2635])
with open('\''/tmp/sticker_t3_multibox.pdf'\'', '\''wb'\'') as f:
f.write(pdf)
print('\''PDF SIZE:'\'', len(pdf))
job.write({'\''qty'\'': original_qty})
env.cr.commit()
print('\''restored qty to'\'', original_qty)
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t3_multibox.pdf'" > /tmp/sticker_t3_multibox.pdf
```
Read the PDF. Expected: 3 pages. Each shows `Qty: 1 / 3`, `Qty: 2 / 3`, `Qty: 3 / 3` respectively. WO# header identical on all 3 (same fp.job, different physical box).
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_reports/report/report_fp_wo_sticker.xml \
fusion_plating_reports/__manifest__.py \
fusion_plating_jobs/report/report_fp_job_sticker.xml \
fusion_plating_jobs/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(sticker): wire _qty_total in SO + Job External outers
Activates the per-box loop landed in the prior commit. SO External
reads line.product_uom_qty; Job External reads job.qty. Inner
template now renders one sticker per physical box, marking each
with "X / N" in the Qty row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: SO Internal variant — template + action
**Goal:** New "Internal Sticker" action on `sale.order` Print menu. Same layout as External, Notes shows `x_fc_internal_description`.
**Files:**
- Modify: `fusion_plating_reports/report/report_fp_wo_sticker.xml` (add new outer template at end)
- Modify: `fusion_plating_reports/report/report_actions.xml` (add new action record)
- Modify: `fusion_plating_reports/__manifest__.py` (version bump)
- [ ] **Step 1: Add SO Internal outer template**
Open `fusion_plating_reports/report/report_fp_wo_sticker.xml`. Find the existing `report_fp_so_sticker` template. After its closing `</template>` tag (before the final `</odoo>` close), insert:
```xml
<!-- ========== Outer template — sale.order Internal variant ==========
Same layout + iteration as report_fp_so_sticker, but pre-sets
_notes_content from x_fc_internal_description (Sub 2 internal
description field) so the Notes column shows the ops-facing
description instead of line.name. ===================================-->
<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="_mo" t-value="False"/>
<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="''"/>
<!-- Internal override: read x_fc_internal_description -->
<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>
```
- [ ] **Step 2: Add SO Internal action record**
Open `fusion_plating_reports/report/report_actions.xml`. Find the existing `action_report_fp_so_sticker` record (around line 326). After its closing `</record>` tag, insert:
```xml
<!-- SO Internal sticker — same layout, prints internal description
instead of the customer-facing line.name. -->
<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>
```
- [ ] **Step 3: Bump fusion_plating_reports version**
`fusion_plating_reports/__manifest__.py`: increment patch (e.g. `19.0.10.13.0``19.0.10.14.0`).
- [ ] **Step 4: Push + upgrade + clear cache**
```bash
cat fusion_plating_reports/report/report_fp_wo_sticker.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/report/report_fp_wo_sticker.xml'"
cat fusion_plating_reports/report/report_actions.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/report/report_actions.xml'"
cat fusion_plating_reports/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/__manifest__.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_reports --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -3
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\"'"
```
- [ ] **Step 5: Verify SO Internal renders with internal description**
Find an SO line with `x_fc_internal_description` populated (or seed one):
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
so = env['\''sale.order'\''].search([('\''name'\'', '\''='\'', '\''SO-30019'\'')], limit=1)
line = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)[:1]
if '\''x_fc_internal_description'\'' in line._fields:
original_internal = line.x_fc_internal_description
line.write({'\''x_fc_internal_description'\'': '\''INTERNAL: rework if any dings on flange. Buff per WI-104.'\'' })
env.cr.commit()
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_reports.report_fp_so_sticker_internal'\'', [so.id])
with open('\''/tmp/sticker_t4_internal.pdf'\'', '\''wb'\'') as f:
f.write(pdf)
print('\''PDF SIZE:'\'', len(pdf))
line.write({'\''x_fc_internal_description'\'': original_internal or False})
env.cr.commit()
print('\''restored'\'')
else:
print('\''x_fc_internal_description not on sale.order.line — Sub 2 not landed?'\'')
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t4_internal.pdf'" > /tmp/sticker_t4_internal.pdf
```
Read the PDF. Expected: single page, Notes column shows `INTERNAL: rework if any dings on flange. Buff per WI-104.` (NOT the customer-facing description). Header `SO-30019`, body fields identical to External variant.
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_reports/report/report_fp_wo_sticker.xml \
fusion_plating_reports/report/report_actions.xml \
fusion_plating_reports/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(sticker): add Internal Sticker variant on sale.order Print menu
Same 3-cell + body layout as External; Notes column reads
x_fc_internal_description (Sub 2 internal-description field on the
SO line) instead of line.name. Shop floor gets ops-facing notes
without leaking them to the customer-facing variant.
New action record action_report_fp_so_sticker_internal — binds to
sale.order, appears in the Print menu next to the existing External
sticker. New template report_fp_so_sticker_internal that pre-sets
_notes_content before t-calling the shared inner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 5: Job Internal variant — template + action
**Goal:** New "Internal Job Sticker" action on `fp.job` Print menu. Same layout as External Job Sticker, Notes shows `x_fc_internal_description` from the first linked SO line.
**Files:**
- Modify: `fusion_plating_jobs/report/report_fp_job_sticker.xml` (add new outer template + action)
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
- [ ] **Step 1: Add Job Internal action record**
Open `fusion_plating_jobs/report/report_fp_job_sticker.xml`. Find the existing `action_report_fp_job_sticker` record. After its closing `</record>` tag (still inside `<odoo>`), insert:
```xml
<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>
```
- [ ] **Step 2: Add Job Internal template**
Same file. After the existing `report_fp_job_sticker_template` template's closing `</template>` (still inside `<odoo>`), insert:
```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="''"/>
<!-- Internal override: read x_fc_internal_description from
the first linked SO line (jobs are 1:N with SO lines
but Sub 5 thickness+serial grouping means same-x_fc
lines share a job — first line's internal_description
is representative). -->
<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>
```
- [ ] **Step 3: Bump fusion_plating_jobs version**
`fusion_plating_jobs/__manifest__.py`: increment patch (e.g. `19.0.8.24.0``19.0.8.25.0`).
- [ ] **Step 4: Push + upgrade + clear cache**
```bash
cat fusion_plating_jobs/report/report_fp_job_sticker.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/report/report_fp_job_sticker.xml'"
cat fusion_plating_jobs/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -3
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\"'"
```
- [ ] **Step 5: Verify Job Internal renders with internal description**
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
job = env['\''fp.job'\''].browse(2635)
line = job.sale_order_line_ids[:1]
original_internal = line.x_fc_internal_description if '\''x_fc_internal_description'\'' in line._fields else False
if '\''x_fc_internal_description'\'' in line._fields:
line.write({'\''x_fc_internal_description'\'': '\''INTERNAL JOB: handle with care, no rework on this batch'\'' })
env.cr.commit()
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_jobs.report_fp_job_sticker_internal_template'\'', [2635])
with open('\''/tmp/sticker_t5_job_internal.pdf'\'', '\''wb'\'') as f:
f.write(pdf)
print('\''PDF SIZE:'\'', len(pdf))
if '\''x_fc_internal_description'\'' in line._fields:
line.write({'\''x_fc_internal_description'\'': original_internal or False})
env.cr.commit()
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t5_job_internal.pdf'" > /tmp/sticker_t5_job_internal.pdf
```
Read the PDF. Expected: single page (job qty=1), Notes shows `INTERNAL JOB: handle with care, no rework on this batch`. Header `WO-30019`, body fields identical to External Job sticker.
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_jobs/report/report_fp_job_sticker.xml \
fusion_plating_jobs/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(sticker): add Internal Job Sticker variant on fp.job Print menu
Mirror of the SO Internal variant for fp.job. Same body fields,
same per-box loop; Notes column reads x_fc_internal_description
from the first linked SO line (job.sale_order_line_ids[:1]).
Operator on the shop floor sees ops-internal notes without those
ever appearing on the customer-facing External sticker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 6: Rename External action labels
**Goal:** Print menu shows "External Sticker" (was "WO Box Sticker") on `sale.order`, "External Job Sticker" (was "Job Sticker") on `fp.job`. XML IDs unchanged — bookmarks/binding records keep working.
**Files:**
- Modify: `fusion_plating_reports/report/report_actions.xml`
- Modify: `fusion_plating_jobs/report/report_fp_job_sticker.xml`
- Modify: `fusion_plating_reports/__manifest__.py`
- Modify: `fusion_plating_jobs/__manifest__.py`
- [ ] **Step 1: Rename SO External action label**
Open `fusion_plating_reports/report/report_actions.xml`. Find `action_report_fp_so_sticker`. Change:
```xml
<field name="name">WO Box Sticker</field>
```
to:
```xml
<field name="name">External Sticker</field>
```
Also update the `print_report_name` if it references the old label. The existing print_report_name is `'WO Sticker - %s' % ...` — change to:
```xml
<field name="print_report_name">'External Sticker - %s' % (object.name or '').replace('/', '-')</field>
```
- [ ] **Step 2: Rename Job External action label**
Open `fusion_plating_jobs/report/report_fp_job_sticker.xml`. Find `action_report_fp_job_sticker`. Change:
```xml
<field name="name">Job Sticker</field>
```
to:
```xml
<field name="name">External Job Sticker</field>
```
And update `print_report_name` from `'Job Sticker - %s' % ...` to:
```xml
<field name="print_report_name">'External Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
```
- [ ] **Step 3: Bump both module versions**
`fusion_plating_reports/__manifest__.py`: increment patch (e.g. `19.0.10.14.0``19.0.10.15.0`).
`fusion_plating_jobs/__manifest__.py`: increment patch (e.g. `19.0.8.25.0``19.0.8.26.0`).
- [ ] **Step 4: Push + upgrade + clear cache**
```bash
cat fusion_plating_reports/report/report_actions.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/report/report_actions.xml'"
cat fusion_plating_reports/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_reports/__manifest__.py'"
cat fusion_plating_jobs/report/report_fp_job_sticker.xml | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/report/report_fp_job_sticker.xml'"
cat fusion_plating_jobs/__manifest__.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_reports,fusion_plating_jobs --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -3
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\"'"
```
- [ ] **Step 5: Verify label rename in DB**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -t -A -c \\\"SELECT id, name, model FROM ir_act_report_xml WHERE id IN (SELECT res_id FROM ir_model_data WHERE module IN ('\''fusion_plating_reports'\'', '\''fusion_plating_jobs'\'') AND name LIKE '\''action_report_fp_%sticker%'\'') ORDER BY model, id;\\\"\"'"
```
Expected output: 4 rows.
- `External Sticker | sale.order`
- `Internal Sticker | sale.order`
- `External Job Sticker | fp.job`
- `Internal Job Sticker | fp.job`
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_reports/report/report_actions.xml \
fusion_plating_reports/__manifest__.py \
fusion_plating_jobs/report/report_fp_job_sticker.xml \
fusion_plating_jobs/__manifest__.py
git commit -m "$(cat <<'EOF'
chore(sticker): rename External action labels for the variant split
Print menu now shows External + Internal as paired entries:
sale.order: External Sticker / Internal Sticker
fp.job: External Job Sticker / Internal Job Sticker
XML IDs unchanged (action_report_fp_so_sticker /
action_report_fp_job_sticker) so existing bookmarks and
binding_model_id records keep working. print_report_name strings
also updated so the downloaded filename matches the new label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 7: End-to-end verification on a multi-line test SO
**Goal:** All six spec scenarios pass on a fresh test SO with multi-line + multi-thickness + qty>1. Catch interaction bugs.
**Files:** none modified (verification only).
- [ ] **Step 1: Create test SO via odoo shell**
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
# Reuse an existing partner + product
partner = env['\''res.partner'\''].search([('\''name'\'', '\''ilike'\'', '\''ABC'\'')], limit=1)
product = env['\''product.product'\''].search([], limit=1)
# Get an existing part + coating + 2 thicknesses
part = env['\''fp.part.catalog'\''].search([], limit=1)
coating = env['\''fp.coating.config'\''].search([], limit=1)
thick_a = env['\''fp.coating.thickness'\''].search([], order='\''id'\'', limit=1)
thick_b = env['\''fp.coating.thickness'\''].search([('\''id'\'', '\''!='\'', thick_a.id)], order='\''id'\'', limit=1)
print('\''partner='\'', partner.name, '\''product='\'', product.name)
print('\''part='\'', part.name, '\''coating='\'', coating.name)
print('\''thick_a='\'', thick_a.display_name, '\''thick_b='\'', thick_b.display_name)
# Create the SO with two lines, same part+coating, different thicknesses
so = env['\''sale.order'\''].create({'\''partner_id'\'': partner.id})
SOL = env['\''sale.order.line'\'']
line_a = SOL.create({
'\''order_id'\'': so.id, '\''product_id'\'': product.id,
'\''product_uom_qty'\'': 2.0, '\''price_unit'\'': 10.0,
'\''x_fc_part_catalog_id'\'': part.id,
'\''x_fc_coating_config_id'\'': coating.id,
'\''x_fc_thickness_id'\'': thick_a.id,
'\''x_fc_internal_description'\'': '\''TEST INTERNAL A: rework cell-1'\'' ,
'\''name'\'': '\''TEST EXTERNAL A: VALVE BODY Rev A'\'',
})
line_b = SOL.create({
'\''order_id'\'': so.id, '\''product_id'\'': product.id,
'\''product_uom_qty'\'': 1.0, '\''price_unit'\'': 10.0,
'\''x_fc_part_catalog_id'\'': part.id,
'\''x_fc_coating_config_id'\'': coating.id,
'\''x_fc_thickness_id'\'': thick_b.id,
'\''x_fc_internal_description'\'': '\''TEST INTERNAL B: extra dip cell-3'\'' ,
'\''name'\'': '\''TEST EXTERNAL B: VALVE BODY Rev B'\'',
})
so.action_confirm()
env.cr.commit()
print('\''SO created:'\'', so.name, '\''id='\'', so.id)
jobs = env['\''fp.job'\''].search([('\''sale_order_id'\'', '\''='\'', so.id)])
print('\''Jobs spawned:'\'', jobs.mapped('\''name'\''))
for j in jobs:
print('\'' '\'', j.name, '\''qty='\'', j.qty, '\''thickness='\'', j.sale_order_line_ids[:1].x_fc_thickness_id.display_name)
EOF"'
```
Expected console output: 2 jobs created (e.g. `WO-30020-01`, `WO-30020-02`), each carrying its own thickness via its linked SO line.
- [ ] **Step 2: Render SO External — expect 3 pages**
Substitute the SO id from Step 1:
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
so = env['\''sale.order'\''].search([], order='\''id desc'\'', limit=1)
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_reports.report_fp_so_sticker'\'', [so.id])
with open('\''/tmp/sticker_t7_so_ext.pdf'\'', '\''wb'\'') as f: f.write(pdf)
print('\''SO='\'', so.name, '\''SIZE='\'', len(pdf))
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t7_so_ext.pdf'" > /tmp/sticker_t7_so_ext.pdf
```
Read PDF. Expected: 3 pages.
- Page 1: line_a box 1 of 2 — Qty `1 / 2`, Notes `TEST EXTERNAL A: VALVE BODY Rev A`, Thickness shows thick_a
- Page 2: line_a box 2 of 2 — Qty `2 / 2`, same other fields
- Page 3: line_b box 1 of 1 — Qty `1` (plain), Notes `TEST EXTERNAL B: VALVE BODY Rev B`, Thickness shows thick_b
- [ ] **Step 3: Render SO Internal — expect 3 pages, different Notes**
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
so = env['\''sale.order'\''].search([], order='\''id desc'\'', limit=1)
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_reports.report_fp_so_sticker_internal'\'', [so.id])
with open('\''/tmp/sticker_t7_so_int.pdf'\'', '\''wb'\'') as f: f.write(pdf)
print('\''SIZE='\'', len(pdf))
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t7_so_int.pdf'" > /tmp/sticker_t7_so_int.pdf
```
Read PDF. Expected: 3 pages, same headers + body fields, but Notes:
- Pages 1+2: `TEST INTERNAL A: rework cell-1`
- Page 3: `TEST INTERNAL B: extra dip cell-3`
- [ ] **Step 4: Render Job External + Internal for each spawned job**
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
so = env['\''sale.order'\''].search([], order='\''id desc'\'', limit=1)
jobs = env['\''fp.job'\''].search([('\''sale_order_id'\'', '\''='\'', so.id)])
for j in jobs:
pdf_ext, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_jobs.report_fp_job_sticker_template'\'', [j.id])
pdf_int, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_jobs.report_fp_job_sticker_internal_template'\'', [j.id])
fn_ext = '\''/tmp/sticker_t7_job_'\'' + j.name.replace('\''/'\'', '\''_'\'') + '\''_ext.pdf'\''
fn_int = '\''/tmp/sticker_t7_job_'\'' + j.name.replace('\''/'\'', '\''_'\'') + '\''_int.pdf'\''
with open(fn_ext, '\''wb'\'') as f: f.write(pdf_ext)
with open(fn_int, '\''wb'\'') as f: f.write(pdf_int)
print(j.name, '\''ext='\'', len(pdf_ext), '\''int='\'', len(pdf_int))
EOF"'
```
For each job, pull both PDFs:
```bash
for fn in $(ssh pve-worker5 "pct exec 111 -- bash -c 'ls /tmp/sticker_t7_job_*.pdf'"); do
base=$(basename "$fn")
ssh pve-worker5 "pct exec 111 -- bash -c 'cat $fn'" > /tmp/$base
done
ls /tmp/sticker_t7_job_*.pdf
```
Read each PDF. Expected:
- Job 1 (line_a, qty 2): External + Internal each render 2 pages with `1 / 2`, `2 / 2`. External Notes = `TEST EXTERNAL A: ...`, Internal Notes = `TEST INTERNAL A: ...`.
- Job 2 (line_b, qty 1): External + Internal each render 1 page with `Qty: 1`. Notes differ per variant.
- [ ] **Step 5: Verify SO-30019 regression-free**
Pre-existing SO-30019 (1 line, qty 1) should render identically to its pre-Task baseline. Render and verify:
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
so = env['\''sale.order'\''].search([('\''name'\'', '\''='\'', '\''SO-30019'\'')], limit=1)
pdf, _ = env['\''ir.actions.report'\''].sudo()._render_qweb_pdf('\''fusion_plating_reports.report_fp_so_sticker'\'', [so.id])
with open('\''/tmp/sticker_t7_regression.pdf'\'', '\''wb'\'') as f: f.write(pdf)
print('\''SIZE='\'', len(pdf))
EOF"'
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /tmp/sticker_t7_regression.pdf'" > /tmp/sticker_t7_regression.pdf
```
Read PDF. Expected: single page, `Qty: 1` (plain, no slash), Notes shows the existing customer-facing description. Layout identical to last-known-good state.
- [ ] **Step 6: Clean up the test SO + jobs**
```bash
ssh pve-worker5 'pct exec 111 -- bash -c "su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" <<EOF
so = env['\''sale.order'\''].search([], order='\''id desc'\'', limit=1)
# Only delete if name carries the expected pattern (defensive)
if so.partner_id.name.startswith('\''ABC'\'') and so.amount_total &lt; 100:
jobs = env['\''fp.job'\''].search([('\''sale_order_id'\'', '\''='\'', so.id)])
print('\''deleting jobs:'\'', jobs.mapped('\''name'\''))
jobs.with_context(force_delete=True).unlink()
so.with_context(force_delete=True).unlink()
env.cr.commit()
print('\''cleanup done'\'')
else:
print('\''skipping cleanup — most recent SO doesn'\\\\\\''t match test pattern'\'')
EOF"'
```
If cleanup is blocked (e.g. by parent-number unlink guard), document the test SO id for manual cleanup later — not a blocker for the spec.
- [ ] **Step 7: Push commits to GitHub**
```bash
git push origin main
```
All six commits (Tasks 16) land on main. No further commits needed for Task 7 — it's verification-only.
---
## Self-Review
**Spec coverage check:**
| Spec section | Implemented by |
|---|---|
| Backend grouping fix (thickness + serial) | Task 1 |
| Per-box sticker render | Task 2 (inner) + Task 3 (outer wiring) |
| Internal variant on SO | Task 4 |
| Internal variant on fp.job | Task 5 |
| Action label rename | Task 6 |
| Existing XML IDs unchanged | Confirmed in Tasks 46 (new actions get `_internal` suffix; existing actions only rename `<field name="name">`) |
| Migration: none needed | Confirmed — Tasks 46 don't touch existing fp.job/sticker DB state |
| Testing scenarios | Task 7 covers Scenarios 16 from the spec |
No gaps.
**Placeholder scan:** No TBDs, no "implement appropriate", no "similar to Task N" — every step has the exact code or the exact command.
**Type consistency:** Variable names used consistently: `_qty_total` (introduced in Task 2, used in Tasks 3/4/5), `_notes_content` (introduced in Task 2, used in Tasks 4/5), `_box_idx` (introduced in Task 2's inner template, referenced in the same step). XML IDs: `action_report_fp_so_sticker_internal` + `report_fp_so_sticker_internal` (consistent in Task 4); `action_report_fp_job_sticker_internal` + `report_fp_job_sticker_internal_template` (consistent in Task 5; trailing `_template` matches existing `report_fp_job_sticker_template` convention).