diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 620e5eef..61e6915f 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -373,7 +373,7 @@ rewrite code as new requirements surface. Each sub-project has its own design do | 5 | Order-line fields (fp.serial registry, auto job#, coating-scoped thickness dropdown, revision picker) | **Shipped 2026-04-22** | 5, 6, Q2 | | 6 | Contact Profiles & Communication Routing (per-contact flags + per-location routing + global contact; single resolver helper) | **Shipped 2026-04-22** | client transcript A/B/C | | 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D | -| 8 | Receiving / Inspection / QC flow restructure (split receiving vs inspection; racking crew inspects, not receiver) | Pending | client transcript E | +| 8 | Receiving / Inspection / QC flow restructure (fp.receiving = box count only; new fp.racking.inspection per MO; WO soft gate; delivery box-parity warning) | **Shipped 2026-04-22** | client transcript E | | ∞ | First-off / last-off QC | Deferred | client transcript F | | ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G | diff --git a/fusion_plating/docs/superpowers/tests/2026-04-22-sub8-smoke.py b/fusion_plating/docs/superpowers/tests/2026-04-22-sub8-smoke.py new file mode 100644 index 00000000..af0631ee --- /dev/null +++ b/fusion_plating/docs/superpowers/tests/2026-04-22-sub8-smoke.py @@ -0,0 +1,168 @@ +"""Sub 8 smoke test — runs inside odoo-shell on entech.""" +env = env + +Partner = env['res.partner'] +SO = env['sale.order'] +Receiving = env['fp.receiving'] +Insp = env['fp.racking.inspection'] +Delivery = env['fusion.plating.delivery'] + +# ---- Field / model presence ----------------------------------------- +assert 'box_count_in' in Receiving._fields +assert 'x_fc_box_count_out' in Delivery._fields +assert hasattr(Insp, 'action_start') +assert hasattr(Insp, 'action_complete') +print('[OK] Models + fields present') + +# ---- Receiving state machine ----------------------------------------- +cust = Partner.create({ + 'name': 'Sub 8 Smoke Customer', + 'is_company': True, + 'customer_rank': 1, + 'email': 'sub8@test.com', +}) +Product = env['product.product'] +product = Product.search([('sale_ok', '=', True)], limit=1) +so = SO.create({ + 'partner_id': cust.id, + 'x_fc_po_number': 'PO-SUB8', + 'x_fc_po_received': True, + 'order_line': [(0, 0, { + 'product_id': product.id, + 'product_uom_qty': 10, + 'name': 'Sub 8 smoke', + 'x_fc_internal_description': 'smoke', + })], +}) + +recv = Receiving.create({'sale_order_id': so.id, 'expected_qty': 10}) +assert recv.state == 'draft' + +# Fail: mark counted without box count +try: + recv.action_mark_counted() + assert False, 'should require box_count_in' +except Exception as e: + assert 'box' in str(e).lower() or 'Boxes' in str(e) + print('[OK] mark_counted blocked without box_count_in') + +recv.box_count_in = 4 +recv.action_mark_counted() +assert recv.state == 'counted' +print(f'[OK] mark_counted → state={recv.state}, boxes={recv.box_count_in}') + +recv.action_mark_staged() +assert recv.state == 'staged' +print(f'[OK] mark_staged → state={recv.state}') + +recv.action_close() +assert recv.state == 'closed' +print(f'[OK] close → state={recv.state}') + +# ---- MO auto-creates racking inspection ------------------------------ +so.action_confirm() +MO = env['mrp.production'] +mos = MO.search([('origin', '=', so.name)]) +assert mos, 'MO should be created by SO confirm' +mo = mos[0] +print(f'[OK] MO created: {mo.name}') + +inspections = Insp.search([('production_id', '=', mo.id)]) +assert len(inspections) == 1, f'expected 1 inspection, got {len(inspections)}' +inspection = inspections[0] +assert inspection.state == 'draft' +assert len(inspection.line_ids) == 1 +assert inspection.line_ids[0].qty_expected == 10 +print(f'[OK] Racking inspection auto-created: {inspection.name}') + +# ---- Inspection lifecycle -------------------------------------------- +inspection.action_start() +assert inspection.state == 'inspecting' +assert inspection.inspector_id == env.user +print('[OK] Inspection started') + +# OK case +inspection.line_ids[0].write({'qty_found': 10, 'condition': 'ok'}) +inspection.action_complete() +assert inspection.state == 'done', f'expected done, got {inspection.state}' +print(f'[OK] Inspection complete → state={inspection.state}') + +# ---- Flag a discrepancy on a separate inspection --------------------- +# Reopen + set damage +inspection.action_reopen() +inspection.line_ids[0].write({'qty_found': 8, 'condition': 'major'}) +inspection.action_complete() +assert inspection.state == 'discrepancy_flagged' +print(f'[OK] Discrepancy flagged → state={inspection.state}') + +# ---- WO soft gate -------------------------------------------------- +# Reopen inspection to 'inspecting' to test the gate +inspection.action_reopen() +assert inspection.state == 'inspecting' +first_wo = mo.workorder_ids.sorted('sequence')[:1] +if first_wo: + try: + # Soft gate — manager bypasses. Try with a non-manager user if available. + demo_user = env['res.users'].search([('login', '=', 'demo')], limit=1) + if demo_user: + first_wo.with_user(demo_user)._fp_warn_if_racking_inspection_pending() + assert False, 'soft gate should raise for non-manager' + else: + # No demo user — check manager bypass works (env.user is admin) + first_wo._fp_warn_if_racking_inspection_pending() + print('[OK] Manager bypass (no demo user to test block)') + except Exception as e: + if 'Racking inspection' in str(e) or 'Inspecting' in str(e): + print('[OK] Soft gate blocks non-manager when inspection pending') + else: + raise +else: + print('[SKIP] No WOs on this MO to test gate') + +# Mark inspection done and verify gate is clear +inspection.line_ids[0].qty_found = 10 +inspection.line_ids[0].condition = 'ok' +inspection.action_complete() +assert inspection.state == 'done' +if first_wo: + first_wo._fp_warn_if_racking_inspection_pending() # should not raise + print('[OK] Gate clears when inspection is Done') + +# ---- Box parity on delivery ------------------------------------------ +delivery = Delivery.create({ + 'partner_id': cust.id, + 'job_ref': mo.name, + 'x_fc_box_count_out': 3, +}) +delivery._fp_check_box_parity() +# Check chatter for the warning +messages = env['mail.message'].search([ + ('model', '=', 'fusion.plating.delivery'), + ('res_id', '=', delivery.id), +]) +body = ' '.join(m.body for m in messages if m.body) +assert 'parity' in body.lower() or 'boxes' in body.lower() or 'shipped' in body.lower(), ( + f'expected parity warning; got messages: {body[:200]}' +) +print('[OK] Box-parity warning posted on delivery chatter') + +# Matching counts — no warning +delivery2 = Delivery.create({ + 'partner_id': cust.id, + 'job_ref': mo.name, + 'x_fc_box_count_out': 4, # matches receiving.box_count_in +}) +before = env['mail.message'].search_count([ + ('model', '=', 'fusion.plating.delivery'), + ('res_id', '=', delivery2.id), +]) +delivery2._fp_check_box_parity() +after = env['mail.message'].search_count([ + ('model', '=', 'fusion.plating.delivery'), + ('res_id', '=', delivery2.id), +]) +assert after == before, f'expected no new message, before={before} after={after}' +print('[OK] No warning when boxes match') + +env.cr.rollback() +print('\n=== SUB 8 SMOKE PASS — all assertions held ===') diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 907dc1f7..4c881cd0 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.11.0.0', + 'version': '19.0.12.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ @@ -42,6 +42,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_shopfloor', 'fusion_plating_configurator', 'fusion_plating_certificates', + 'fusion_plating_receiving', 'hr', # hr_attendance gives us the standard hr.attendance model # (check_in / check_out). fusion_clock builds on the same model diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index 1a5ab2ce..a86371c9 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -797,6 +797,45 @@ class MrpProduction(models.Model): recipe.name, recipe.default_lead_time), ) + # ------------------------------------------------------------------ + # Sub 8 — Auto-create a racking inspection alongside every new MO, + # regardless of how the MO came into being (bridge_mrp auto-create, + # Odoo sale_mrp procurement, manual create). One row per MO via the + # unique SQL constraint on fp.racking.inspection.production_id. + # ------------------------------------------------------------------ + @api.model_create_multi + def create(self, vals_list): + mos = super().create(vals_list) + Insp = self.env.get('fp.racking.inspection') + if Insp is None: + return mos + for mo in mos: + if Insp.search_count([('production_id', '=', mo.id)]): + continue + so_lines = mo.x_fc_sale_order_line_ids if ( + 'x_fc_sale_order_line_ids' in mo._fields + ) else self.env['sale.order.line'] + if so_lines: + insp_lines = [ + (0, 0, { + 'part_catalog_id': ln.x_fc_part_catalog_id.id + if ln.x_fc_part_catalog_id else False, + 'qty_expected': int(ln.product_uom_qty or 0), + 'condition': 'ok', + }) + for ln in so_lines + ] + else: + insp_lines = [(0, 0, { + 'qty_expected': int(mo.product_qty or 0), + 'condition': 'ok', + })] + Insp.sudo().create({ + 'production_id': mo.id, + 'line_ids': insp_lines, + }) + return mos + # ------------------------------------------------------------------ # GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py index 14d5d50f..67adc555 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py @@ -889,6 +889,10 @@ class MrpWorkorder(models.Model): required field for traceability is filled in.""" self._fp_check_required_fields_before_start() self._fp_check_operator_certification() + # Sub 8 — soft gate: block the first plating WO if the MO's + # racking inspection is still Draft or Inspecting. Non-manager + # operators get a clear error; Plating Managers override. + self._fp_warn_if_racking_inspection_pending() res = super().button_start() # Capture audit AFTER the super call so we don't stamp WOs that # the cert gate (or any other downstream check) rejected. @@ -931,6 +935,40 @@ class MrpWorkorder(models.Model): 'Request certification from your supervisor before starting this WO.' ) % (employee.name, process_type.name)) + def _fp_warn_if_racking_inspection_pending(self): + """Sub 8 — block first plating WO start if racking inspection is still + Draft or Inspecting. + + Only applies to the first-sequence WO of an MO. Later WOs assume + inspection was cleared earlier. Plating Manager bypasses. + """ + from odoo.exceptions import UserError + if self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): + return + Insp = self.env.get('fp.racking.inspection') + if Insp is None: + return + for wo in self: + mo = wo.production_id + if not mo: + continue + first_wo = mo.workorder_ids.sorted('sequence')[:1] + if wo != first_wo: + continue + inspection = Insp.search( + [('production_id', '=', mo.id)], limit=1, + ) + if not inspection or inspection.state in ('done', 'discrepancy_flagged'): + continue + state_label = dict( + inspection._fields['state'].selection + ).get(inspection.state, inspection.state) + raise UserError(_( + 'Racking inspection for MO %(mo)s is still "%(st)s". ' + 'Complete the inspection (or ask a Plating Manager to ' + 'override) before starting the first plating work order.' + ) % {'mo': mo.name, 'st': state_label}) + def _fp_check_required_fields_before_finish(self): """Block button_finish on: diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py index 294143b2..878d1ba2 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py @@ -241,6 +241,11 @@ class SaleOrder(models.Model): mo_vals['x_fc_revision_snapshot'] = primary.x_fc_revision_snapshot mo = Production.create(mo_vals) created.append((mo, tag, len(lines))) + # Sub 8 — the racking inspection is auto-created by + # mrp.production.create() (see mrp_production.py), so + # no extra work here. The hook there picks up the + # x_fc_sale_order_line_ids written above to seed the + # inspection lines correctly. self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name) except Exception as exc: self.env.cr.execute('ROLLBACK TO SAVEPOINT %s' % savepoint_name) diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index 752baaab..ceeb7479 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Logistics', - 'version': '19.0.2.0.0', + 'version': '19.0.3.0.0', 'category': 'Manufacturing/Plating', 'summary': ( 'Pickup & delivery for plating shops: vehicle master, driver ' diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index b99f2067..33cfcc8e 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -74,6 +74,17 @@ class FpDelivery(models.Model): x_fc_revision_snapshot = fields.Char( string='Revision (snapshot)', ) + + # ---- Sub 8 — box parity ------------------------------------------------ + # Shipping crew packs returns into the SAME boxes the parts arrived in + # (client requirement). Receiving captures box_count_in; we capture + # box_count_out here. action_mark_delivered posts a non-blocking + # chatter warning if they don't match. + x_fc_box_count_out = fields.Integer( + string='Boxes Out', + help='Number of boxes the shipping crew packed for return. ' + 'Should match the box count captured at receiving.', + ) scheduled_date = fields.Datetime( string='Scheduled Date', tracking=True, @@ -226,6 +237,48 @@ class FpDelivery(models.Model): or 'Driver'), to_party=rec.partner_id.display_name, ) + # Sub 8 — box-parity warning. Non-blocking; just posts to + # chatter so the shipping supervisor sees it on the record. + rec._fp_check_box_parity() + + def _fp_check_box_parity(self): + """Compare this delivery's boxes-out count to the boxes-in count + captured at receiving. Post a chatter warning if they differ. + + Never blocks — shipping has already happened by the time this + fires. The warning is for audit + shipping-supervisor review. + """ + self.ensure_one() + if not self.x_fc_box_count_out: + return + Receiving = self.env.get('fp.receiving') + if Receiving is None: + return + # Resolve SO via job_ref → MO.origin → SO.name + so_name = False + if self.job_ref: + mo = self.env['mrp.production'].search( + [('name', '=', self.job_ref)], limit=1, + ) + if mo and mo.origin: + so_name = mo.origin + if not so_name: + return + so = self.env['sale.order'].search( + [('name', '=', so_name)], limit=1, + ) + if not so: + return + recv = Receiving.search( + [('sale_order_id', '=', so.id)], limit=1, + ) + if not recv or not recv.box_count_in: + return + if recv.box_count_in != self.x_fc_box_count_out: + self.message_post(body=_( + 'Box parity check: shipped %(out)d box(es), received ' + '%(in)d. Verify consolidation was intended.' + ) % {'out': self.x_fc_box_count_out, 'in': recv.box_count_in}) def action_mark_refused(self): self.write({'state': 'refused'}) diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py index dbc4a7c9..988957e4 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.2.1.0', + 'version': '19.0.3.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.', 'description': """ @@ -38,6 +38,7 @@ Provides: 'security/ir.model.access.csv', 'data/fp_receiving_sequence_data.xml', 'views/fp_receiving_views.xml', + 'views/fp_racking_inspection_views.xml', 'views/sale_order_views.xml', 'views/fp_receiving_menu.xml', ], diff --git a/fusion_plating/fusion_plating_receiving/models/__init__.py b/fusion_plating/fusion_plating_receiving/models/__init__.py index 197c54cb..7d09fa1f 100644 --- a/fusion_plating/fusion_plating_receiving/models/__init__.py +++ b/fusion_plating/fusion_plating_receiving/models/__init__.py @@ -6,5 +6,6 @@ from . import fp_receiving_damage from . import fp_receiving_line from . import fp_receiving +from . import fp_racking_inspection from . import sale_order from . import mrp_production diff --git a/fusion_plating/fusion_plating_receiving/models/fp_racking_inspection.py b/fusion_plating/fusion_plating_receiving/models/fp_racking_inspection.py new file mode 100644 index 00000000..90ed980c --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/models/fp_racking_inspection.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +# +# Sub 8 — Racking-time inspection record. Captures the per-part +# inspection the racking crew performs when they open the customer's +# boxes (which is DIFFERENT from receiving — receiving is box count +# only). One record per MO. + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FpRackingInspection(models.Model): + _name = 'fp.racking.inspection' + _description = 'Racking-time Inspection' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'create_date desc, id desc' + + name = fields.Char(compute='_compute_name', store=True) + production_id = fields.Many2one( + 'mrp.production', + string='Manufacturing Order', + required=True, + ondelete='cascade', + index=True, + tracking=True, + ) + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + compute='_compute_sale_order', + store=True, + ) + partner_id = fields.Many2one( + 'res.partner', + string='Customer', + compute='_compute_sale_order', + store=True, + ) + receiving_id = fields.Many2one( + 'fp.receiving', + string='Source Receiving', + compute='_compute_receiving_id', + store=True, + help='The receiving record whose boxes feed this inspection.', + ) + state = fields.Selection( + [('draft', 'Draft'), + ('inspecting', 'Inspecting'), + ('done', 'Done'), + ('discrepancy_flagged', 'Discrepancy Flagged')], + default='draft', + required=True, + tracking=True, + ) + inspector_id = fields.Many2one( + 'res.users', + string='Inspector', + readonly=True, + copy=False, + tracking=True, + ) + inspection_started = fields.Datetime(readonly=True, copy=False) + inspection_completed = fields.Datetime(readonly=True, copy=False) + line_ids = fields.One2many( + 'fp.racking.inspection.line', + 'inspection_id', + copy=True, + ) + notes = fields.Text() + company_id = fields.Many2one( + 'res.company', + required=True, + default=lambda s: s.env.company, + ) + + line_count = fields.Integer(compute='_compute_line_stats') + ok_count = fields.Integer(compute='_compute_line_stats') + flagged_count = fields.Integer(compute='_compute_line_stats') + has_variance = fields.Boolean(compute='_compute_line_stats') + + _sql_constraints = [ + ('fp_racking_insp_mo_uniq', + 'unique(production_id)', + 'Only one racking inspection per manufacturing order.'), + ] + + # ---- Computes ------------------------------------------------------------ + + @api.depends('production_id.name', 'partner_id.name') + def _compute_name(self): + for rec in self: + if rec.production_id: + rec.name = _('Inspection — %s') % rec.production_id.name + else: + rec.name = _('Racking Inspection') + + @api.depends('production_id.origin') + def _compute_sale_order(self): + SO = self.env['sale.order'] + for rec in self: + so = False + if rec.production_id and rec.production_id.origin: + so = SO.search( + [('name', '=', rec.production_id.origin)], limit=1, + ) + rec.sale_order_id = so or False + rec.partner_id = so.partner_id if so else False + + @api.depends('sale_order_id') + def _compute_receiving_id(self): + Receiving = self.env['fp.receiving'] + for rec in self: + rec.receiving_id = ( + Receiving.search( + [('sale_order_id', '=', rec.sale_order_id.id)], limit=1, + ) + if rec.sale_order_id else False + ) + + @api.depends('line_ids.condition', 'line_ids.qty_variance') + def _compute_line_stats(self): + for rec in self: + rec.line_count = len(rec.line_ids) + rec.ok_count = len(rec.line_ids.filtered( + lambda l: l.condition == 'ok' and l.qty_variance == 0 + )) + rec.flagged_count = len(rec.line_ids.filtered( + lambda l: l.condition != 'ok' or l.qty_variance != 0 + )) + rec.has_variance = bool(rec.flagged_count) + + # ---- Actions ------------------------------------------------------------- + + def action_start(self): + """Racker opens the boxes and begins inspecting.""" + for rec in self: + if rec.state != 'draft': + raise UserError(_('Can only start a Draft inspection.')) + rec.write({ + 'state': 'inspecting', + 'inspector_id': self.env.user.id, + 'inspection_started': fields.Datetime.now(), + }) + rec.message_post(body=_('Inspection started by %s.') % self.env.user.name) + + def action_complete(self): + """Racker is satisfied with the inspection. Advance to Done.""" + for rec in self: + if rec.state != 'inspecting': + raise UserError(_('Can only complete an Inspecting record.')) + new_state = 'discrepancy_flagged' if rec.flagged_count else 'done' + rec.write({ + 'state': new_state, + 'inspection_completed': fields.Datetime.now(), + }) + if new_state == 'discrepancy_flagged': + rec.activity_schedule( + 'mail.mail_activity_data_todo', + summary=_('Racking discrepancy on %s') % ( + rec.production_id.name or '' + ), + note=_( + '%(n)d line(s) flagged — review before starting ' + 'the first plating WO.' + ) % {'n': rec.flagged_count}, + ) + rec.message_post(body=_( + 'Inspection completed — %(ok)d ok / %(flag)d flagged.' + ) % {'ok': rec.ok_count, 'flag': rec.flagged_count}) + + def action_reopen(self): + """Manager only — reopen a done inspection.""" + if not self.env.user.has_group( + 'fusion_plating.group_fusion_plating_manager'): + raise UserError(_('Only a Plating Manager can reopen a completed ' + 'racking inspection.')) + for rec in self: + rec.write({ + 'state': 'inspecting', + 'inspection_completed': False, + }) + rec.message_post(body=_('Reopened by %s.') % self.env.user.name) + + +class FpRackingInspectionLine(models.Model): + _name = 'fp.racking.inspection.line' + _description = 'Racking Inspection Line' + _order = 'inspection_id, sequence, id' + + inspection_id = fields.Many2one( + 'fp.racking.inspection', + required=True, + ondelete='cascade', + ) + sequence = fields.Integer(default=10) + part_catalog_id = fields.Many2one( + 'fp.part.catalog', string='Part', + ) + part_number = fields.Char( + related='part_catalog_id.part_number', store=True, + ) + part_revision = fields.Char( + related='part_catalog_id.revision', store=True, + ) + qty_expected = fields.Integer(string='Expected Qty') + qty_found = fields.Integer(string='Counted Qty') + qty_variance = fields.Integer( + compute='_compute_qty_variance', store=True, + ) + condition = fields.Selection( + [('ok', 'OK'), + ('minor', 'Minor Issue'), + ('major', 'Major Issue'), + ('reject', 'Reject')], + default='ok', + required=True, + ) + notes = fields.Char(string='Notes') + + @api.depends('qty_expected', 'qty_found') + def _compute_qty_variance(self): + for rec in self: + rec.qty_variance = (rec.qty_found or 0) - (rec.qty_expected or 0) diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index fa99d2ce..c70759b5 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -39,16 +39,32 @@ class FpReceiving(models.Model): received_date = fields.Datetime( string='Received Date', default=fields.Datetime.now, tracking=True, ) + # Sub 8 — simplified state machine. Receiving = box count only. The + # part-level inspection that used to happen here now lives on + # fp.racking.inspection (racking crew does it when they open the + # boxes). Legacy state values are kept in the Selection so existing + # records from before Sub 8 don't raise on upgrade. state = fields.Selection( [ - ('draft', 'Awaiting Parts'), - ('inspecting', 'Inspecting'), - ('accepted', 'Accepted'), - ('discrepancy', 'Discrepancy'), - ('resolved', 'Resolved'), + ('draft', 'Awaiting Parts'), + ('counted', 'Counted'), + ('staged', 'Staged for Racking'), + ('closed', 'Closed'), + # Legacy values — kept readable, never written by new code + ('inspecting', 'Inspecting (legacy)'), + ('accepted', 'Accepted (legacy)'), + ('discrepancy', 'Discrepancy (legacy)'), + ('resolved', 'Resolved (legacy)'), ], string='Status', default='draft', tracking=True, required=True, ) + box_count_in = fields.Integer( + string='Boxes Received', + tracking=True, + help='Number of boxes the receiver counted when the truck ' + 'dropped off. Receiving is box count only — parts are ' + 'inspected by the racking crew when boxes are opened.', + ) 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( @@ -96,7 +112,46 @@ class FpReceiving(models.Model): return super().create(vals_list) # ------------------------------------------------------------------------- - # State actions + # Sub 8 — box-count-only actions (new primary flow) + # ------------------------------------------------------------------------- + def action_mark_counted(self): + """Receiver has counted the boxes on the dock. Move to Counted.""" + for rec in self: + if rec.state not in ('draft', 'inspecting'): # inspecting allows legacy records + raise UserError(_('Only Awaiting-Parts or legacy-Inspecting ' + 'records can be marked Counted.')) + if not rec.box_count_in: + raise UserError(_('Set the Boxes Received count before marking Counted.')) + rec.state = 'counted' + rec.received_by_id = self.env.user + rec.received_date = fields.Datetime.now() + rec.message_post(body=_( + '%(user)s counted %(n)d box(es) at receiving.' + ) % {'user': self.env.user.name, 'n': rec.box_count_in}) + + def action_mark_staged(self): + """Boxes are in the racking area, awaiting the racking crew.""" + for rec in self: + if rec.state not in ('counted',): + raise UserError(_('Only Counted records can be marked Staged.')) + rec.state = 'staged' + rec._update_so_receiving_status() + rec.message_post(body=_('Boxes staged for racking.')) + + def action_close(self): + """Close the receiving — all boxes opened, inspection complete.""" + for rec in self: + if rec.state not in ('staged', 'accepted', 'resolved'): + raise UserError(_('Only Staged (or legacy Accepted / Resolved) ' + 'records can be closed.')) + rec.state = 'closed' + rec._update_so_receiving_status() + rec.message_post(body=_('Receiving closed.')) + + # ------------------------------------------------------------------------- + # Legacy state actions — kept for backward compatibility. + # Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection. + # Retained so existing UI bindings don't blow up. # ------------------------------------------------------------------------- def action_start_inspection(self): """Move from draft to inspecting.""" 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 8c6c47b0..4e1c7d82 100644 --- a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv @@ -8,3 +8,9 @@ access_fp_receiving_line_manager,fp.receiving.line.manager,model_fp_receiving_li access_fp_receiving_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_receiving_damage_receiver,fp.receiving.damage.receiver,model_fp_receiving_damage,group_fp_receiving,1,1,1,0 access_fp_receiving_damage_manager,fp.receiving.damage.manager,model_fp_receiving_damage,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_racking_inspection_operator,fp.racking.inspection.operator,model_fp_racking_inspection,fusion_plating.group_fusion_plating_operator,1,1,1,0 +access_fp_racking_inspection_supervisor,fp.racking.inspection.supervisor,model_fp_racking_inspection,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_racking_inspection_manager,fp.racking.inspection.manager,model_fp_racking_inspection,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_racking_inspection_line_operator,fp.racking.inspection.line.operator,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_operator,1,1,1,1 +access_fp_racking_inspection_line_supervisor,fp.racking.inspection.line.supervisor,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 +access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_receiving/views/fp_racking_inspection_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_racking_inspection_views.xml new file mode 100644 index 00000000..6ce4e721 --- /dev/null +++ b/fusion_plating/fusion_plating_receiving/views/fp_racking_inspection_views.xml @@ -0,0 +1,165 @@ + + + + + + fp.racking.inspection.form + fp.racking.inspection + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + fp.racking.inspection.list + fp.racking.inspection + + + + + + + + + + + + + + + + fp.racking.inspection.search + fp.racking.inspection + + + + + + + + + + + + + + + + + + + Racking Inspection + fp.racking.inspection + list,form + + {'search_default_filter_pending': 1} + +

+ No racking inspections yet. +

+

+ Racking inspections are auto-created when an MO is confirmed. + The racking crew opens the customer's boxes, counts parts, + and logs condition findings — the per-part quality check + that used to live on receiving. +

+
+
+ + + +
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 16afc6da..9d08c812 100644 --- a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml +++ b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml @@ -40,28 +40,50 @@
-
+

@@ -73,6 +95,9 @@ + + + @@ -81,9 +106,9 @@ - - - + + +