diff --git a/fusion_plating/docs/superpowers/specs/2026-06-03-box-tracking-sticker-redesign-design.md b/fusion_plating/docs/superpowers/specs/2026-06-03-box-tracking-sticker-redesign-design.md new file mode 100644 index 00000000..e803defc --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-06-03-box-tracking-sticker-redesign-design.md @@ -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/`. | + +## 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 | 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//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 `/fp/box/`. | +| `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/` → 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/`). 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/`), 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. diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 52f4ef4d..d2c67060 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_jobs/models/__init__.py b/fusion_plating/fusion_plating_jobs/models/__init__.py index d17b6a7b..ec2eac74 100644 --- a/fusion_plating/fusion_plating_jobs/models/__init__.py +++ b/fusion_plating/fusion_plating_jobs/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py b/fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py new file mode 100644 index 00000000..6c5dacfd --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py @@ -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() diff --git a/fusion_plating/fusion_plating_jobs/report/report_fp_job_sticker.xml b/fusion_plating/fusion_plating_jobs/report/report_fp_job_sticker.xml index b45fe3d5..be7f58b6 100644 --- a/fusion_plating/fusion_plating_jobs/report/report_fp_job_sticker.xml +++ b/fusion_plating/fusion_plating_jobs/report/report_fp_job_sticker.xml @@ -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/). + * 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/), 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). --> @@ -25,8 +30,12 @@ 0 - - 300 + + 96 @@ -41,49 +50,6 @@ - - - Internal Job Sticker fp.job @@ -96,36 +62,191 @@ + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_receiving/__init__.py b/fusion_plating/fusion_plating_receiving/__init__.py index cf9f201b..b2a77eff 100644 --- a/fusion_plating/fusion_plating_receiving/__init__.py +++ b/fusion_plating/fusion_plating_receiving/__init__.py @@ -5,3 +5,4 @@ from . import models from . import wizards +from . import controllers diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py index e4fd61fc..73e79bc9 100644 --- a/fusion_plating/fusion_plating_receiving/__manifest__.py +++ b/fusion_plating/fusion_plating_receiving/__manifest__.py @@ -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', diff --git a/fusion_plating/fusion_plating_receiving/controllers/__init__.py b/fusion_plating/fusion_plating_receiving/controllers/__init__.py new file mode 100644 index 00000000..bc7611e0 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +from . import fp_box_controller diff --git a/fusion_plating/fusion_plating_receiving/controllers/fp_box_controller.py b/fusion_plating/fusion_plating_receiving/controllers/fp_box_controller.py new file mode 100644 index 00000000..9e117197 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/controllers/fp_box_controller.py @@ -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/``; 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/'], 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) diff --git a/fusion_plating/fusion_plating_receiving/migrations/19.0.3.29.1/post-migrate.py b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.29.1/post-migrate.py new file mode 100644 index 00000000..b8d1b17f --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.29.1/post-migrate.py @@ -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) diff --git a/fusion_plating/fusion_plating_receiving/models/__init__.py b/fusion_plating/fusion_plating_receiving/models/__init__.py index ee87ba4e..a884eba7 100644 --- a/fusion_plating/fusion_plating_receiving/models/__init__.py +++ b/fusion_plating/fusion_plating_receiving/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_receiving/models/fp_box.py b/fusion_plating/fusion_plating_receiving/models/fp_box.py new file mode 100644 index 00000000..6bab3396 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/models/fp_box.py @@ -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/`) 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/ 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', + } diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index 2168e531..2cda04b4 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -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 diff --git a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv index 8b780b1e..94fc2081 100644 --- a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating_receiving/views/fp_box_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_box_views.xml new file mode 100644 index 00000000..e5efcc46 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/views/fp_box_views.xml @@ -0,0 +1,145 @@ + + + + + + + fp.box.list + fp.box + + + + + + + + + + + + + + + + + fp.box.form + fp.box + +
+
+
+ +
+

+
+ + + + + + + + + + +
+ + +
+
+ + + + fp.box.search + fp.box + + + + + + + + + + + + + + + + + + + + + + + + fp.box.kanban + fp.box + + + + + +
+ +
+
+ @ +
+
+
+
+
+
+
+ + + + Boxes + fp.box + list,kanban,form + + {'search_default_open': 1} + + + + + +
diff --git a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml index a52ff8f5..92a457c3 100644 --- a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml +++ b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml @@ -125,6 +125,15 @@ +