feat(fusion_plating): box-level tracking (fp.box) + thermal job-sticker redesign
Box registry: new fp.box model (fusion_plating_receiving), one record per received box, auto-created when a receiving is marked Counted (idempotent _fp_sync_boxes — grows/shrinks with box_count_in, never touches an advanced box). Status received -> racked -> in_process -> packed -> shipped, per-box scannable QR (/fp/box/<id> controller). Backfill migration for receivings counted before tracking shipped. Boxes list/kanban/form + receiving smart button. Job stickers redesigned (thermal label, 6x4 in / 152x102mm, mm layout @ paperformat dpi=96 so mm maps 1:1 in wkhtmltopdf — see rule 14): - Internal Job Sticker = Layout A, ONE per job (shop notes from x_fc_internal_description, job QR). - External Job Sticker = Layout B, ONE per fp.box (BOX n/N, per-box QR, factory company logo, customer-facing notes). Dynamic MASK badge (x_fc_masking_enabled) + BAKE block (x_fc_bake_instructions), length-tiered notes font. Display logic in fp.job._fp_sticker_data(). Also retains the SO/WO box-sticker MemoryError fix in report_fp_wo_sticker.xml (per-box loop sourced from fp.receiving.box_count_in + 100-label safety cap). Verified live on entech: 111 boxes backfilled (31 receivings), External renders one page per box, Internal one per job, scan endpoint 303->login. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
# Box-Level Tracking + Job Sticker Redesign — Design Spec
|
||||
|
||||
Date: 2026-06-03
|
||||
Status: Approved (brainstormed with client), implementation in progress.
|
||||
|
||||
## Summary
|
||||
|
||||
Two coupled deliverables:
|
||||
|
||||
1. **Job sticker redesign** (thermal-label-friendly, 6×4 in / 152×102 mm):
|
||||
- **Internal Job Sticker → Layout A** (stacked: identity band + full-width
|
||||
instructions), printed **one per job**.
|
||||
- **External Job Sticker → Layout B** (left identity rail + tall instructions
|
||||
column), printed **one per box**, carrying the **box identity** (BOX n/N)
|
||||
and a **per-box QR**. Shows the **factory logo** (`env.company.logo`).
|
||||
2. **Box-level tracking**: a new `fp.box` registry, one record per received box,
|
||||
auto-created at receiving, with a status workflow and per-box scannable QR.
|
||||
|
||||
## Decisions (locked with client)
|
||||
|
||||
| Q | Decision |
|
||||
|---|---|
|
||||
| Label size | Keep 6×4 in (152×102 mm). |
|
||||
| Redesign goals | Readability/scan-speed + thermal print quality (no grey fills — solid-black bands + knockout white text; thick rules; bold sans). |
|
||||
| Masking on label | **MASK badge** (on/off flag) when `sale.order.line.x_fc_masking_enabled` is true. No detail text. |
|
||||
| Baking on label | **BAKE block** showing `sale.order.line.x_fc_bake_instructions` text, only when present. Also a BAKE flag for at-a-glance. |
|
||||
| Notes source | Internal = `x_fc_internal_description`; External = SO line `name` (customer-facing). |
|
||||
| Long notes | Notes-dominant zone, **length-tiered font shrink** to keep to **one label**, clip with "…see traveller" only in the extreme. |
|
||||
| Factory logo | On **External only** (header), from `env.company.logo` → `logo_web` → company partner image. Internal stays clean. Thermal caveat: prefer a mono/high-contrast logo. |
|
||||
| Box tracking depth | **Box registry** — per-box record, status, scannable QR. (Not box-contents.) |
|
||||
| Internal copies | **One per job.** |
|
||||
| External copies | **One per box.** |
|
||||
| Box QR | **Per-box** — encodes `/fp/box/<id>`. |
|
||||
|
||||
## Label layouts (approved mockups)
|
||||
|
||||
Both labels: outer 0.9 mm border, `overflow:hidden` single-page guard, dynamic
|
||||
blocks render only when their field has content.
|
||||
|
||||
**Layout A (Internal, per job):** full-width stacked rows —
|
||||
`[logo | WO# band + INTERNAL tag | QR]` → `Part# + MASK/BAKE flags` →
|
||||
one-line field strip `Customer · PO · Qty · Due · Thk` → `BAKE` block →
|
||||
`NOTES` (full width, `x_fc_internal_description`, length-tiered, bottom padding).
|
||||
|
||||
**Layout B (External, per box):** absolute two-column —
|
||||
- Left rail (50 mm): `logo` → black band `WORK ORDER <wo> | BOX n / N` →
|
||||
`MASK/BAKE` flags → per-box QR → `Part#` → `Customer` → `PO/Qty` → `Due/Thk`.
|
||||
- Right column: `BAKE` block → `NOTES` (customer description, length-tiered).
|
||||
- Full-height divider (rail `border-right`). CUSTOMER copy.
|
||||
|
||||
Reference mockups (Chrome-rendered, true 6×4):
|
||||
`~/Downloads/fusion_sticker_concepts/Sticker-A-Internal-LongNotes.*`,
|
||||
`Sticker-B-External.*`. Final proof renders through entech wkhtmltopdf.
|
||||
|
||||
## `fp.box` model (fusion_plating_receiving)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | Char | Sequence, e.g. `BOX/<wo-or-recv>/01`. |
|
||||
| `box_number` | Integer | n (1..N). |
|
||||
| `box_count` | Integer | N (related/snapshot of receiving `box_count_in`). |
|
||||
| `receiving_id` | M2O `fp.receiving` | Origin. ondelete cascade. |
|
||||
| `sale_order_id` | M2O `sale.order` | Related from receiving. |
|
||||
| `job_id` | M2O `fp.job` | Resolved (single-job SO = that job; multi-job = first/SO-level, see edge cases). |
|
||||
| `partner_id` | M2O `res.partner` | Related (customer). |
|
||||
| `state` | Selection | `received → racked → in_process → packed → shipped` (+ `lost`/`cancelled`). |
|
||||
| `qr` | Binary/compute | Encodes `<base_url>/fp/box/<id>`. |
|
||||
| `location_note` | Char | Optional free text "where is it now". |
|
||||
| `scan_event_ids` | (phase 2) | Per-scan log — deferred. |
|
||||
|
||||
Constraints: `(receiving_id, box_number)` unique. Append-only-ish; state advances.
|
||||
|
||||
## Auto-create at receiving
|
||||
|
||||
When `fp.receiving.box_count_in = N` is set and the receiving is confirmed
|
||||
(state hook — reuse the existing box-count chatter point at
|
||||
`fp_receiving.py:~1191`), create/sync N `fp.box` rows (1..N), linked to the
|
||||
receiving + resolved job. **Idempotent**: changing N adds/removes trailing rows
|
||||
(never renumbers existing tracked boxes). Manager can regenerate.
|
||||
|
||||
## Scanning
|
||||
|
||||
- Controller route `/fp/box/<int:box_id>` → resolves the box, shows its job /
|
||||
status, allows advancing state (received→…→shipped). Tie into the existing
|
||||
shopfloor scan wedge (`request.env.user` attribution — no `tablet_tech_id`).
|
||||
- **Reconciliation**: helper flags a receiving/job whose boxes haven't all
|
||||
reached `shipped` (so none are lost — matches the "ship back in the same
|
||||
boxes" Sub-8 rule).
|
||||
|
||||
## Label binding
|
||||
|
||||
- **External job sticker** (`fusion_plating_jobs.report_fp_job_sticker_template`):
|
||||
iterate the job's `fp.box` records → **one label per box** (Layout B), each
|
||||
with its `box_number/box_count` + per-box QR (`/fp/box/<id>`). Replaces the
|
||||
current `range(box_count_in)` loop in `report_fp_wo_sticker_inner`. When a job
|
||||
has no `fp.box` rows yet, fall back to a single label (BOX 1/1).
|
||||
- **Internal job sticker** (`report_fp_job_sticker_internal_template`): **one per
|
||||
job** (Layout A), job QR (`/fp/job/<id>`), no box loop.
|
||||
- Shared inner keeps the 100-label hard safety cap (defense-in-depth from the
|
||||
WO-30072 OOM fix).
|
||||
|
||||
## UI
|
||||
|
||||
- Boxes list + kanban (group by `state`) under **Operations**; form with state
|
||||
buttons + scan QR.
|
||||
- Smart buttons: box count on `fp.receiving` and `fp.job` forms.
|
||||
|
||||
## Module placement
|
||||
|
||||
- Model + auto-create + views/menu/ACL → `fusion_plating_receiving`.
|
||||
- Scan controller → `fusion_plating_receiving` (or shopfloor).
|
||||
- Label templates → `fusion_plating_jobs` (job stickers) + shared inner in
|
||||
`fusion_plating_reports`.
|
||||
|
||||
## Edge cases / open
|
||||
|
||||
- **Multi-job SO** (one SO line → multiple jobs via serial/thickness grouping):
|
||||
boxes are physical (per shipment/receiving). MVP links a box to the SO's
|
||||
primary job; the external sticker prints the SO's boxes. Revisit if a real
|
||||
multi-job-per-box case appears.
|
||||
- **Box ↔ part for multi-part SO**: out of MVP (registry, not contents).
|
||||
- Per-box qty/contents = future "registry + contents" upgrade.
|
||||
|
||||
## Deploy / verify
|
||||
|
||||
entech (LXC 111 / pve-worker5), `-u fusion_plating_receiving fusion_plating_jobs
|
||||
fusion_plating_reports` with the revert-on-failure guard. Verify: render both
|
||||
stickers for a real job through wkhtmltopdf; confirm auto-create on a test
|
||||
receiving; scan a box id.
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.12.0.1',
|
||||
'version': '19.0.12.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from . import fp_job_workflow_state # Sub 14 — must load before fp_job (FK target)
|
||||
from . import fp_job
|
||||
from . import fp_job_sticker
|
||||
from . import fp_job_step
|
||||
from . import fp_job_node_override
|
||||
from . import fp_portal_job
|
||||
|
||||
104
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
104
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Display helpers for the redesigned job stickers (Internal = Layout A,
|
||||
one per job; External = Layout B, one per box).
|
||||
|
||||
Keeps the QWeb templates thin: all field resolution, the customer
|
||||
short-code (shop-floor "secrecy cover"), em-dash/smart-quote cleanup for
|
||||
the entech wkhtmltopdf font, and the length-tiered notes font size live
|
||||
here in Python.
|
||||
"""
|
||||
from odoo import models
|
||||
|
||||
|
||||
def _clean(text):
|
||||
"""Strip the glyphs entech's wkhtmltopdf font mojibakes."""
|
||||
if not text:
|
||||
return ''
|
||||
t = str(text)
|
||||
for a, b in ((u'—', '-'), (u'–', '-'), (u'‘', "'"),
|
||||
(u'’', "'"), (u'“', '"'), (u'”', '"'),
|
||||
(u'…', '...')):
|
||||
t = t.replace(a, b)
|
||||
return t.strip()
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
def _fp_sticker_shortcode(self, partner):
|
||||
"""ABC Manufacturing Inc -> 'ABC-MANU'. First 3 of word[0] + first 4
|
||||
of word[1] (alnum-only), uppercase. Single word -> first 3."""
|
||||
name = (partner.name or '') if partner else ''
|
||||
words = [''.join(c for c in w if c.isalnum()) for w in name.split()]
|
||||
words = [w for w in words if w]
|
||||
if len(words) >= 2:
|
||||
return (words[0][:3] + '-' + words[1][:4]).upper()
|
||||
if words:
|
||||
return words[0][:3].upper()
|
||||
return name or '-'
|
||||
|
||||
def _fp_note_pt(self, text):
|
||||
"""Length-tiered notes font (pt) so long instructions stay on one
|
||||
label. Mirrors the approved mockups."""
|
||||
n = len(text or '')
|
||||
if n <= 180:
|
||||
return 11.0
|
||||
if n <= 320:
|
||||
return 10.0
|
||||
if n <= 520:
|
||||
return 9.0
|
||||
return 8.5
|
||||
|
||||
def _fp_sticker_data(self):
|
||||
"""Resolved display values for the job sticker (both variants)."""
|
||||
self.ensure_one()
|
||||
job = self
|
||||
line = job.sale_order_line_ids[:1] if 'sale_order_line_ids' in job._fields \
|
||||
else job.env['sale.order.line']
|
||||
part = (('part_catalog_id' in job._fields and job.part_catalog_id)
|
||||
or (line and 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id)
|
||||
or False)
|
||||
so = job.sale_order_id
|
||||
|
||||
rev = ''
|
||||
if part and getattr(part, 'revision', False):
|
||||
rev = (part.revision or '').strip()
|
||||
if rev.lower().startswith('rev '):
|
||||
rev = rev[4:].strip()
|
||||
|
||||
due = job.date_deadline or (so and so.commitment_date) or False
|
||||
due_s = due.strftime('%b %d %Y') if due else ''
|
||||
|
||||
thk = ''
|
||||
if line and 'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range:
|
||||
thk = _clean(line.x_fc_thickness_range)
|
||||
|
||||
q = job.qty or 0
|
||||
qty = int(q) if float(q).is_integer() else q
|
||||
|
||||
return {
|
||||
'wo': job.name or '',
|
||||
'part': ((part.part_number if part and getattr(part, 'part_number', False)
|
||||
else (part.name if part else '')) or ''),
|
||||
'rev': rev,
|
||||
'customer': self._fp_sticker_shortcode(job.partner_id),
|
||||
'customer_full': job.partner_id.name or '',
|
||||
'po': (so and getattr(so, 'x_fc_po_number', False)) or '',
|
||||
'qty': qty,
|
||||
'due': due_s,
|
||||
'thk': thk,
|
||||
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
|
||||
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
|
||||
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
|
||||
'customer_notes': _clean(line.name) if line else '',
|
||||
}
|
||||
|
||||
def _fp_sticker_boxes(self):
|
||||
"""The job's tracked boxes (External sticker prints one label each).
|
||||
Empty recordset when none yet — the template falls back to 1/1."""
|
||||
self.ensure_one()
|
||||
if self.sale_order_id and 'fp.box' in self.env:
|
||||
return self.env['fp.box'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='box_number')
|
||||
return self.env['fp.box'] if 'fp.box' in self.env else self.browse()
|
||||
@@ -3,12 +3,17 @@
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Native fp.job sticker — reuses the canonical box-sticker design from
|
||||
fusion_plating_reports.report_fp_wo_sticker_inner. The visual layout
|
||||
(logo + WO# stack on the left, big QR on the right, 7-row body table
|
||||
underneath, all wrapped in a 2px border) is the one shop staff have
|
||||
been printing since the mrp.production days; we just feed it from
|
||||
fp.job fields here instead of mrp.production.
|
||||
Redesigned job stickers (thermal label, 6x4 in / 152x102 mm):
|
||||
* Internal Job Sticker — Layout A (stacked, full-width notes),
|
||||
ONE label per job. Shop copy: x_fc_internal_description notes,
|
||||
job QR (/fp/job/<id>).
|
||||
* External Job Sticker — Layout B (left rail + tall notes),
|
||||
ONE label per fp.box. Customer copy: factory logo, BOX n/N,
|
||||
per-box QR (/fp/box/<id>), customer-facing description notes.
|
||||
|
||||
Dynamic: MASK badge when masking enabled, BAKE block when bake
|
||||
instructions present, length-tiered notes font. Field resolution +
|
||||
short-code + cleanup live in fp.job._fp_sticker_data() (Python).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
@@ -25,8 +30,12 @@
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">0</field>
|
||||
<field name="disable_shrinking" eval="True"/>
|
||||
<!-- dpi=300 calibrated — see CLAUDE.md rule 14, 600 broke layout. -->
|
||||
<field name="dpi">300</field>
|
||||
<!-- dpi=96 (NOT 300): this label is laid out in mm (matches the
|
||||
approved Chrome-rendered mockups). At dpi=300 wkhtmltopdf shrinks
|
||||
mm content to ~96/300 of true size (CLAUDE.md rule 14). 96 maps
|
||||
mm 1:1 so it fills the page; QR/logo stay crisp (embedded at their
|
||||
own resolution, text is vector). Legacy px-based stickers keep 300. -->
|
||||
<field name="dpi">96</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
@@ -41,49 +50,6 @@
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_sticker_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<!-- Defaults block initialises every var the inner
|
||||
reads (so `_so or ...` doesn't NameError). We
|
||||
then override the ones we have data for. -->
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<!-- Multi-line trigger: parent SO has 2+ part-bearing lines.
|
||||
Even though this job is for a single specific part (jobs
|
||||
are grouped by recipe+part+coating+thickness+SN), the
|
||||
consolidated PO sticker is the requested behaviour. -->
|
||||
<t t-set="_so_part_lines" t-value="job.sale_order_id
|
||||
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
|
||||
or job.env['sale.order.line']"/>
|
||||
<t t-set="_multi_line" t-value="len(_so_part_lines) >= 2"/>
|
||||
<!-- Pre-resolve the variables the shared inner template
|
||||
expects, sourcing them from fp.job's native fields. -->
|
||||
<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="False if _multi_line else job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
|
||||
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
|
||||
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
|
||||
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
|
||||
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
|
||||
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<!-- The fp.job's own name (WH/JOB/00033) is already
|
||||
printed in the header as "WO #...", so suppress
|
||||
the muted "(WH/MO/...)" suffix on the PO row. -->
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Internal Job sticker — same fields as External, but the Notes
|
||||
column reads x_fc_internal_description from the first linked
|
||||
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
|
||||
share a job, so first-line is representative). -->
|
||||
<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>
|
||||
@@ -96,36 +62,191 @@
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================ Shared CSS ============================ -->
|
||||
<template id="fp_job_sticker_styles">
|
||||
<style>
|
||||
@page { size: 152mm 102mm; margin: 0; }
|
||||
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
|
||||
.label-page { width: 152mm; height: 102mm; position: relative; overflow: hidden; page-break-after: always; }
|
||||
.label { position: absolute; top: 2mm; left: 2mm; right: 2mm; bottom: 2mm; border: 0.9mm solid #000; overflow: hidden; }
|
||||
.fpt { border-collapse: collapse; width: 100%; }
|
||||
.fpt td { vertical-align: middle; }
|
||||
.lbl { font-size: 7.5pt; font-weight: bold; letter-spacing: 0.4pt; text-transform: uppercase; display: block; }
|
||||
.band { background: #000; color: #fff; }
|
||||
.pad { padding: 1mm 2.5mm; }
|
||||
.vrule { border-right: 0.5mm solid #000; }
|
||||
.rule { border-bottom: 0.6mm solid #000; }
|
||||
.badge { display: inline-block; background: #000; color: #fff; font-size: 10pt; font-weight: 900; padding: 0.6mm 2.2mm; margin-left: 1.5mm; }
|
||||
.tag { display: inline-block; background: transparent; color: #fff; border: 0.5mm solid #fff; font-size: 8pt; font-weight: bold; padding: 0.4mm 2mm; }
|
||||
.inshead { font-size: 8.5pt; font-weight: 900; letter-spacing: 0.6pt; background: #000; color: #fff; display: inline-block; padding: 0.5mm 2.5mm; }
|
||||
.instext { font-weight: bold; }
|
||||
/* Layout B rail + main */
|
||||
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
|
||||
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
|
||||
.r-logo { height: 12mm; line-height: 12mm; text-align: center; }
|
||||
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 18mm; background: #000; color: #fff; padding: 0; }
|
||||
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
|
||||
.r-flags { height: 8mm; text-align: center; padding-top: 1.2mm; }
|
||||
.r-flags .badge { margin: 0 1mm; font-size: 10pt; padding: 0.6mm 2.4mm; }
|
||||
.r-qr { height: 19mm; line-height: 19mm; text-align: center; }
|
||||
.r-qr img { width: 17mm; height: 17mm; vertical-align: middle; }
|
||||
.r-fld { padding: 1mm 2.2mm; }
|
||||
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal body — Layout A ===================== -->
|
||||
<template id="fp_job_internal_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<table class="fpt">
|
||||
<tr style="height:22mm" class="rule">
|
||||
<td class="band pad">
|
||||
<span style="float:right"><span class="tag">INTERNAL</span></span>
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
|
||||
</td>
|
||||
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;padding:1mm">
|
||||
<img t-att-src="_qr" style="width:29mm;height:29mm"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:12mm" class="rule">
|
||||
<td class="pad" colspan="2">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
<span style="float:right">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:10mm" class="rule">
|
||||
<td style="padding:0" colspan="2"><table class="fpt"><tr>
|
||||
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
|
||||
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
|
||||
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></td>
|
||||
</tr>
|
||||
<t t-if="d['bake']">
|
||||
<tr style="height:13mm" class="rule">
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
|
||||
<span class="inshead">BAKE</span>
|
||||
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
|
||||
<span class="inshead">NOTES</span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External body — Layout B ===================== -->
|
||||
<template id="fp_job_external_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<div class="rail">
|
||||
<div class="r-logo rule">
|
||||
<img t-if="_logo" t-att-src="image_data_uri(_logo)"/>
|
||||
<span t-if="not _logo" style="font-size:11pt;font-weight:900"><t t-esc="d['customer_full'][:18]"/></span>
|
||||
</div>
|
||||
<div class="r-wo">
|
||||
<table class="wobtbl"><tr>
|
||||
<td class="vrule" style="width:52%;border-right-color:#fff">
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<span class="bignum"><t t-esc="d['wo'].split('-')[-1].split('/')[-1]"/></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="lbl" style="color:#fff">Box</span>
|
||||
<span class="bignum"><t t-esc="_box_num"/> / <t t-esc="_box_cnt"/></span>
|
||||
</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
<div class="r-flags rule">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</div>
|
||||
<div class="r-qr rule"><img t-att-src="_qr"/></div>
|
||||
<div class="r-fld rule">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:11.5pt;font-weight:900"><t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:8.5pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
</div>
|
||||
<div class="r-fld rule"><span class="lbl">Customer</span><span style="font-size:11pt;font-weight:900"><t t-esc="d['customer']"/></span></div>
|
||||
<div class="rule" style="height:9.5mm"><table class="gtbl"><tr>
|
||||
<td class="vrule" style="width:55%"><span class="lbl">PO#</span><span style="font-size:9.5pt;font-weight:bold;display:block"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
|
||||
</tr></table></div>
|
||||
<div style="height:9.5mm"><table class="gtbl"><tr>
|
||||
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<t t-if="d['bake']">
|
||||
<div class="m-bake rule"><span class="inshead">BAKE</span>
|
||||
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="m-notes"><span class="inshead">NOTES</span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal outer (per job) ===================== -->
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_so_part_lines" t-value="job.sale_order_id
|
||||
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
|
||||
or job.env['sale.order.line']"/>
|
||||
<t t-set="_multi_line" t-value="len(_so_part_lines) >= 2"/>
|
||||
<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="False if _multi_line else job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
|
||||
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
|
||||
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
|
||||
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
|
||||
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
|
||||
<t t-set="_qty_total" t-value="1 if _multi_line else 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. Multi-line PO blanks it
|
||||
since each line has its own description. -->
|
||||
<t t-set="_notes_content" t-value="'-' if _multi_line else
|
||||
((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-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['internal_notes']"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=300, height=300)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_internal_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External outer (per box) ===================== -->
|
||||
<template id="report_fp_job_sticker_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['customer_notes']"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=300, height=300)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=300, height=300)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
from . import controllers
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.28.5',
|
||||
'version': '19.0.3.29.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
@@ -44,6 +44,7 @@ Provides:
|
||||
'views/fp_racking_inspection_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_menu.xml',
|
||||
'views/fp_box_views.xml',
|
||||
'views/fusion_shipment_inherit_views.xml',
|
||||
'wizards/fp_label_manual_wizard_views.xml',
|
||||
'wizards/fp_label_generate_wizard_views.xml',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
from . import fp_box_controller
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Box scan endpoint. The per-box QR on the External Job Sticker encodes
|
||||
``/fp/box/<id>``; scanning it (logged-in operator on the tablet) lands on
|
||||
the box's backend form where they can advance its status."""
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpBoxScan(http.Controller):
|
||||
|
||||
@http.route(['/fp/box/<int:box_id>'], type='http', auth='user', website=False)
|
||||
def fp_box_scan(self, box_id, **kw):
|
||||
box = request.env['fp.box'].sudo().browse(box_id).exists()
|
||||
if not box:
|
||||
return request.not_found()
|
||||
# Land on the box form in the web client (operator advances status there).
|
||||
return request.redirect('/web#id=%s&model=fp.box&view_type=form' % box.id)
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# Backfill fp.box records for receivings that were counted BEFORE box-level
|
||||
# tracking shipped. Idempotent: skips any receiving that already has boxes.
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
from odoo import api, SUPERUSER_ID
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
recs = env['fp.receiving'].search([('box_count_in', '>', 0)])
|
||||
done = 0
|
||||
for rec in recs:
|
||||
if not rec.box_ids:
|
||||
rec._fp_sync_boxes()
|
||||
done += 1
|
||||
_logger.info('fp.box backfill: created boxes for %s receiving(s)', done)
|
||||
@@ -6,6 +6,7 @@
|
||||
from . import fp_receiving_damage
|
||||
from . import fp_receiving_line
|
||||
from . import fp_outbound_package
|
||||
from . import fp_box
|
||||
from . import fp_receiving
|
||||
from . import fp_racking_inspection
|
||||
from . import sale_order
|
||||
|
||||
111
fusion_plating/fusion_plating_receiving/models/fp_box.py
Normal file
111
fusion_plating/fusion_plating_receiving/models/fp_box.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Per-box registry for box-level tracking.
|
||||
|
||||
One `fp.box` per physical box received against a `fp.receiving`. Auto-created
|
||||
when the receiver enters `box_count_in` and marks the receiving Counted
|
||||
(see `fp.receiving._fp_sync_boxes`). Each box carries a sequence number
|
||||
(n of N), a status that advances through the shop, and a scannable identity
|
||||
(`/fp/box/<id>`) printed on the External Job Sticker — one label per box.
|
||||
|
||||
Box-level tracking (not box CONTENTS): we track WHICH box and WHERE it is,
|
||||
not the per-box part breakdown. The same boxes go back to the customer
|
||||
(Sub 8), so reconciliation flags any box that never reaches `shipped`.
|
||||
"""
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
STATE_ORDER = ['received', 'racked', 'in_process', 'packed', 'shipped']
|
||||
|
||||
|
||||
class FpBox(models.Model):
|
||||
_name = 'fp.box'
|
||||
_description = 'Fusion Plating — Tracked Box'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'receiving_id, box_number'
|
||||
|
||||
name = fields.Char(string='Box', compute='_compute_name', store=True)
|
||||
box_number = fields.Integer(string='Box #', required=True, default=1, tracking=True)
|
||||
box_count = fields.Integer(string='Of', tracking=True,
|
||||
help='Total boxes in this receiving (N in "n of N").')
|
||||
|
||||
receiving_id = fields.Many2one('fp.receiving', string='Receiving', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order',
|
||||
related='receiving_id.sale_order_id', store=True, index=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Customer',
|
||||
related='receiving_id.partner_id', store=True)
|
||||
job_id = fields.Many2one('fp.job', string='Work Order', index=True,
|
||||
help='Resolved job for this box (single-job SO). '
|
||||
'The sticker resolves boxes via the SO when blank.')
|
||||
company_id = fields.Many2one('res.company', string='Company',
|
||||
default=lambda self: self.env.company, index=True)
|
||||
|
||||
state = fields.Selection([
|
||||
('received', 'Received'),
|
||||
('racked', 'Racked'),
|
||||
('in_process', 'In Process'),
|
||||
('packed', 'Packed'),
|
||||
('shipped', 'Shipped'),
|
||||
('lost', 'Lost'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='received', required=True, tracking=True, index=True)
|
||||
|
||||
location_note = fields.Char(string='Location / Note', tracking=True,
|
||||
help='Free text — where is this box now (rack, bay, shelf).')
|
||||
scan_url = fields.Char(string='Scan URL', compute='_compute_scan_url')
|
||||
|
||||
_box_uniq = models.Constraint(
|
||||
'UNIQUE(receiving_id, box_number)',
|
||||
'Box number must be unique within a receiving.')
|
||||
|
||||
# ------------------------------------------------------------------ computes
|
||||
@api.depends('box_number', 'box_count', 'receiving_id.name', 'sale_order_id.name')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
base = rec.receiving_id.name or (rec.sale_order_id.name if rec.sale_order_id else '') or 'BOX'
|
||||
rec.name = '%s · Box %d/%d' % (base, rec.box_number or 1, rec.box_count or 1)
|
||||
|
||||
def _compute_scan_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
for rec in self:
|
||||
rec.scan_url = ('%s/fp/box/%s' % (base, rec.id)) if rec.id else ''
|
||||
|
||||
# ------------------------------------------------------------------ workflow
|
||||
def _set_state(self, new_state):
|
||||
for rec in self:
|
||||
old = dict(rec._fields['state'].selection).get(rec.state, rec.state)
|
||||
new = dict(rec._fields['state'].selection).get(new_state, new_state)
|
||||
rec.state = new_state
|
||||
rec.message_post(body=_(
|
||||
'Box %(n)s/%(N)s: %(old)s → %(new)s by %(u)s'
|
||||
) % {'n': rec.box_number, 'N': rec.box_count,
|
||||
'old': old, 'new': new, 'u': self.env.user.name})
|
||||
|
||||
def action_set_racked(self):
|
||||
self._set_state('racked')
|
||||
|
||||
def action_set_in_process(self):
|
||||
self._set_state('in_process')
|
||||
|
||||
def action_set_packed(self):
|
||||
self._set_state('packed')
|
||||
|
||||
def action_set_shipped(self):
|
||||
self._set_state('shipped')
|
||||
|
||||
def action_set_lost(self):
|
||||
self._set_state('lost')
|
||||
|
||||
def action_reset_received(self):
|
||||
self._set_state('received')
|
||||
|
||||
def action_open_record(self):
|
||||
"""Used by the /fp/box/<id> scan endpoint to land on the box form."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.box',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -86,6 +86,14 @@ class FpReceiving(models.Model):
|
||||
'dropped off. Receiving is box count only — parts are '
|
||||
'inspected by the racking crew when boxes are opened.',
|
||||
)
|
||||
box_ids = fields.One2many(
|
||||
'fp.box', 'receiving_id', string='Tracked Boxes',
|
||||
help='One record per physical box (box-level tracking). Auto-created '
|
||||
'when the receiving is marked Counted.',
|
||||
)
|
||||
box_count_tracked = fields.Integer(
|
||||
string='Boxes Tracked', compute='_compute_box_count_tracked',
|
||||
)
|
||||
expected_qty = fields.Integer(string='Expected Qty', help='Total quantity expected from the sale order.')
|
||||
received_qty = fields.Integer(string='Received Qty', help='Total quantity actually received.')
|
||||
qty_match = fields.Boolean(
|
||||
@@ -1182,6 +1190,56 @@ class FpReceiving(models.Model):
|
||||
# -------------------------------------------------------------------------
|
||||
# Sub 8 — box-count-only actions (new primary flow)
|
||||
# -------------------------------------------------------------------------
|
||||
@api.depends('box_ids')
|
||||
def _compute_box_count_tracked(self):
|
||||
for rec in self:
|
||||
rec.box_count_tracked = len(rec.box_ids)
|
||||
|
||||
def _fp_sync_boxes(self):
|
||||
"""Create/sync one fp.box per received box (idempotent).
|
||||
|
||||
Grows the box set when box_count_in increases; removes only the
|
||||
trailing boxes that are still 'received' when it shrinks (never
|
||||
touches a box that has already advanced through the shop).
|
||||
Resolves job_id from the SO's first job when one exists.
|
||||
"""
|
||||
Box = self.env['fp.box']
|
||||
for rec in self:
|
||||
n = int(rec.box_count_in or 0)
|
||||
existing = rec.box_ids.sorted('box_number')
|
||||
if existing:
|
||||
existing.write({'box_count': n})
|
||||
job = False
|
||||
if rec.sale_order_id and 'fp.job' in self.env:
|
||||
job = self.env['fp.job'].sudo().search(
|
||||
[('sale_order_id', '=', rec.sale_order_id.id)], limit=1)
|
||||
cur = len(existing)
|
||||
if n > cur:
|
||||
Box.create([{
|
||||
'receiving_id': rec.id,
|
||||
'box_number': i,
|
||||
'box_count': n,
|
||||
'job_id': job.id if job else False,
|
||||
} for i in range(cur + 1, n + 1)])
|
||||
elif n < cur:
|
||||
drop = existing.filtered(
|
||||
lambda b: b.box_number > n and b.state == 'received')
|
||||
drop.unlink()
|
||||
if job:
|
||||
rec.box_ids.filtered(lambda b: not b.job_id).write({'job_id': job.id})
|
||||
|
||||
def action_view_boxes(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Boxes'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.box',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('receiving_id', '=', self.id)],
|
||||
'context': {'default_receiving_id': self.id,
|
||||
'default_box_count': self.box_count_in or 1},
|
||||
}
|
||||
|
||||
def action_mark_counted(self):
|
||||
"""Receiver has counted the boxes on the dock. Move to Counted."""
|
||||
for rec in self:
|
||||
@@ -1197,6 +1255,7 @@ class FpReceiving(models.Model):
|
||||
rec.message_post(body=_(
|
||||
'%(user)s counted %(n)d box(es) at receiving.'
|
||||
) % {'user': self.env.user.name, 'n': rec.box_count_in})
|
||||
rec._fp_sync_boxes()
|
||||
|
||||
def action_mark_staged(self):
|
||||
"""Deprecated 2026-05-20 — `staged` state was dead ceremony
|
||||
|
||||
@@ -26,3 +26,6 @@ access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_f
|
||||
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_box_operator,fp.box.operator,model_fp_box,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_box_supervisor,fp.box.supervisor,model_fp_box,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_box_manager,fp.box.manager,model_fp_box,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
145
fusion_plating/fusion_plating_receiving/views/fp_box_views.xml
Normal file
145
fusion_plating/fusion_plating_receiving/views/fp_box_views.xml
Normal file
@@ -0,0 +1,145 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Box-level tracking — fp.box list / form / search / kanban + menu.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== List ===== -->
|
||||
<record id="fp_box_view_list" model="ir.ui.view">
|
||||
<field name="name">fp.box.list</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Boxes" decoration-muted="state in ('shipped','cancelled')" decoration-danger="state == 'lost'">
|
||||
<field name="box_number"/>
|
||||
<field name="box_count"/>
|
||||
<field name="name"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="job_id"/>
|
||||
<field name="location_note"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'received'"
|
||||
decoration-warning="state in ('racked','in_process','packed')"
|
||||
decoration-success="state == 'shipped'"
|
||||
decoration-danger="state == 'lost'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Form ===== -->
|
||||
<record id="fp_box_view_form" model="ir.ui.view">
|
||||
<field name="name">fp.box.form</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_set_racked" type="object" string="Mark Racked"
|
||||
class="btn-primary" invisible="state != 'received'"/>
|
||||
<button name="action_set_in_process" type="object" string="Mark In Process"
|
||||
class="btn-primary" invisible="state != 'racked'"/>
|
||||
<button name="action_set_packed" type="object" string="Mark Packed"
|
||||
class="btn-primary" invisible="state != 'in_process'"/>
|
||||
<button name="action_set_shipped" type="object" string="Mark Shipped"
|
||||
class="btn-primary" invisible="state != 'packed'"/>
|
||||
<button name="action_set_lost" type="object" string="Flag Lost"
|
||||
invisible="state in ('shipped','lost','cancelled')"/>
|
||||
<button name="action_reset_received" type="object" string="Reset to Received"
|
||||
groups="fusion_plating.group_fp_shop_manager_v2"
|
||||
invisible="state == 'received'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="received,racked,in_process,packed,shipped"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<label for="box_number" string="Box"/>
|
||||
<div>
|
||||
<field name="box_number" class="oe_inline"/> /
|
||||
<field name="box_count" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="receiving_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="job_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="location_note"/>
|
||||
<field name="scan_url" widget="url" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Search ===== -->
|
||||
<record id="fp_box_view_search" model="ir.ui.view">
|
||||
<field name="name">fp.box.search</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="job_id"/>
|
||||
<field name="receiving_id"/>
|
||||
<filter name="open" string="Open (not shipped)" domain="[('state','not in',('shipped','cancelled'))]"/>
|
||||
<filter name="received" string="Received" domain="[('state','=','received')]"/>
|
||||
<filter name="in_process" string="In Process" domain="[('state','in',('racked','in_process','packed'))]"/>
|
||||
<filter name="shipped" string="Shipped" domain="[('state','=','shipped')]"/>
|
||||
<filter name="lost" string="Lost" domain="[('state','=','lost')]"/>
|
||||
<group>
|
||||
<filter name="g_state" string="Status" context="{'group_by':'state'}"/>
|
||||
<filter name="g_customer" string="Customer" context="{'group_by':'partner_id'}"/>
|
||||
<filter name="g_receiving" string="Receiving" context="{'group_by':'receiving_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Kanban (by status) ===== -->
|
||||
<record id="fp_box_view_kanban" model="ir.ui.view">
|
||||
<field name="name">fp.box.kanban</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state" class="o_kanban_small_column">
|
||||
<field name="state"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_content">
|
||||
<strong><field name="name"/></strong>
|
||||
<div><field name="partner_id"/></div>
|
||||
<div t-if="record.location_note.raw_value">
|
||||
<span class="text-muted">@ </span><field name="location_note"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Action ===== -->
|
||||
<record id="action_fp_box" model="ir.actions.act_window">
|
||||
<field name="name">Boxes</field>
|
||||
<field name="res_model">fp.box</field>
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="search_view_id" ref="fp_box_view_search"/>
|
||||
<field name="context">{'search_default_open': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Menu ===== -->
|
||||
<menuitem id="menu_fp_box"
|
||||
name="Boxes"
|
||||
parent="menu_fp_receiving_root"
|
||||
action="action_fp_box"
|
||||
sequence="35"/>
|
||||
|
||||
</odoo>
|
||||
@@ -125,6 +125,15 @@
|
||||
</div>
|
||||
<field name="x_fc_has_label_zpl" invisible="1"/>
|
||||
</button>
|
||||
<button name="action_view_boxes"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cubes"
|
||||
invisible="box_count_tracked == 0">
|
||||
<field name="box_count_tracked"
|
||||
widget="statinfo"
|
||||
string="Boxes"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
|
||||
@@ -274,7 +274,14 @@
|
||||
<!-- Per-box loop: renders one sticker page per physical box in
|
||||
the line/job qty. When _qty_total is missing/0/1, falls
|
||||
back to a single render (no "X / N" indicator). -->
|
||||
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
|
||||
<!-- Hard safety cap (defense in depth): never render more than 100
|
||||
label pages in one pass, regardless of what _qty_total resolves
|
||||
to. A sticker is a per-box identification label; rendering
|
||||
thousands (each with an inlined logo + QR data-URI) OOMs the
|
||||
worker. WO-30072 (qty 2000 parts) crashed the PDF engine here. -->
|
||||
<t t-set="_label_count_raw" t-value="int(_qty_total or 1)"/>
|
||||
<t t-set="_label_count" t-value="100 if _label_count_raw > 100 else (1 if _label_count_raw < 1 else _label_count_raw)"/>
|
||||
<t t-foreach="range(_label_count)" t-as="_box_idx0">
|
||||
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
|
||||
<div class="fp-sticker">
|
||||
<!-- 3-cell header: Logo | WO# | QR -->
|
||||
@@ -517,7 +524,13 @@
|
||||
<t t-set="_spec" t-value="line.x_fc_customer_spec_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"/>
|
||||
<!-- One label per physical BOX (box_count_in on the
|
||||
SO's receiving), NOT per part. Was
|
||||
line.product_uom_qty, which rendered one label per
|
||||
part and OOM'd on large qty (WO-30072 = 2000).
|
||||
Falls back to 1 when no box count is recorded. -->
|
||||
<t t-set="_box_count" t-value="int(sum(so.env['fp.receiving'].sudo().search([('sale_order_id', '=', so.id)]).mapped('box_count_in')) or 0) if 'fp.receiving' in so.env else 0"/>
|
||||
<t t-set="_qty_total" t-value="_box_count if _box_count > 0 else 1"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
@@ -572,7 +585,13 @@
|
||||
<t t-set="_spec" t-value="line.x_fc_customer_spec_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"/>
|
||||
<!-- One label per physical BOX (box_count_in on the
|
||||
SO's receiving), NOT per part. Was
|
||||
line.product_uom_qty, which rendered one label per
|
||||
part and OOM'd on large qty (WO-30072 = 2000).
|
||||
Falls back to 1 when no box count is recorded. -->
|
||||
<t t-set="_box_count" t-value="int(sum(so.env['fp.receiving'].sudo().search([('sale_order_id', '=', so.id)]).mapped('box_count_in')) or 0) if 'fp.receiving' in so.env else 0"/>
|
||||
<t t-set="_qty_total" t-value="_box_count if _box_count > 0 else 1"/>
|
||||
<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 -->
|
||||
|
||||
Reference in New Issue
Block a user