feat(receiving): technicians can count+close receivings from the tablet

ACL: grant group_fp_technician write+create on fp.receiving / line / damage.
sudo the internal sale.order x_fc_receiving_status write so a non-privileged
technician isn't blocked inside action_mark_counted / action_close.

Tests deferred to entech (local Docker unavailable this session).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-29 09:11:39 -04:00
parent b98ee8a6fb
commit d37f10f1c3
5 changed files with 73 additions and 9 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.28.2',
'version': '19.0.3.28.3',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -1358,17 +1358,21 @@ class FpReceiving(models.Model):
for rec in self:
if not rec.sale_order_id:
continue
# Internal denormalized status field — elevate the write so a
# non-privileged technician (tablet receiving) isn't blocked by
# sale.order ACL inside action_mark_counted / action_close.
so = rec.sale_order_id.sudo()
if rec.state == 'closed':
rec.sale_order_id.x_fc_receiving_status = 'received'
so.x_fc_receiving_status = 'received'
elif rec.state in ('counted', 'staged'):
rec.sale_order_id.x_fc_receiving_status = 'partial'
so.x_fc_receiving_status = 'partial'
# Legacy states preserved.
elif rec.state in ('accepted', 'resolved'):
rec.sale_order_id.x_fc_receiving_status = 'received'
so.x_fc_receiving_status = 'received'
elif rec.state in ('discrepancy', 'inspecting'):
rec.sale_order_id.x_fc_receiving_status = 'partial'
so.x_fc_receiving_status = 'partial'
elif rec.state == 'draft':
rec.sale_order_id.x_fc_receiving_status = 'not_received'
so.x_fc_receiving_status = 'not_received'
# Propagate the per-part qty onto the matching fp.job records
# so the 2026-05-18 mark_done gate can see what was received.
rec._update_job_qty_received()

View File

@@ -1,11 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_receiving_operator,fp.receiving.operator,model_fp_receiving,fusion_plating.group_fp_technician,1,0,0,0
access_fp_receiving_operator,fp.receiving.operator,model_fp_receiving,fusion_plating.group_fp_technician,1,1,1,0
access_fp_receiving_receiver,fp.receiving.receiver,model_fp_receiving,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_receiving_manager,fp.receiving.manager,model_fp_receiving,fusion_plating.group_fp_manager,1,1,1,1
access_fp_receiving_line_operator,fp.receiving.line.operator,model_fp_receiving_line,fusion_plating.group_fp_technician,1,0,0,0
access_fp_receiving_line_operator,fp.receiving.line.operator,model_fp_receiving_line,fusion_plating.group_fp_technician,1,1,1,0
access_fp_receiving_line_receiver,fp.receiving.line.receiver,model_fp_receiving_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_receiving_line_manager,fp.receiving.line.manager,model_fp_receiving_line,fusion_plating.group_fp_manager,1,1,1,1
access_fp_receiving_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fp_technician,1,0,0,0
access_fp_receiving_damage_operator,fp.receiving.damage.operator,model_fp_receiving_damage,fusion_plating.group_fp_technician,1,1,1,0
access_fp_receiving_damage_receiver,fp.receiving.damage.receiver,model_fp_receiving_damage,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_receiving_damage_manager,fp.receiving.damage.manager,model_fp_receiving_damage,fusion_plating.group_fp_manager,1,1,1,1
access_fp_racking_inspection_operator,fp.racking.inspection.operator,model_fp_racking_inspection,fusion_plating.group_fp_technician,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_receiving_operator fp.receiving.operator model_fp_receiving fusion_plating.group_fp_technician 1 0 1 0 1 0
3 access_fp_receiving_receiver fp.receiving.receiver model_fp_receiving fusion_plating.group_fp_shop_manager_v2 1 1 1 0
4 access_fp_receiving_manager fp.receiving.manager model_fp_receiving fusion_plating.group_fp_manager 1 1 1 1
5 access_fp_receiving_line_operator fp.receiving.line.operator model_fp_receiving_line fusion_plating.group_fp_technician 1 0 1 0 1 0
6 access_fp_receiving_line_receiver fp.receiving.line.receiver model_fp_receiving_line fusion_plating.group_fp_shop_manager_v2 1 1 1 0
7 access_fp_receiving_line_manager fp.receiving.line.manager model_fp_receiving_line fusion_plating.group_fp_manager 1 1 1 1
8 access_fp_receiving_damage_operator fp.receiving.damage.operator model_fp_receiving_damage fusion_plating.group_fp_technician 1 0 1 0 1 0
9 access_fp_receiving_damage_receiver fp.receiving.damage.receiver model_fp_receiving_damage fusion_plating.group_fp_shop_manager_v2 1 1 1 0
10 access_fp_receiving_damage_manager fp.receiving.damage.manager model_fp_receiving_damage fusion_plating.group_fp_manager 1 1 1 1
11 access_fp_racking_inspection_operator fp.racking.inspection.operator model_fp_racking_inspection fusion_plating.group_fp_technician 1 1 1 0

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_carrier_fields
from . import test_technician_receiving_acl

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Technician can receive (count + close) from the tablet.
Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
"""
from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError
class TestTechnicianReceivingAcl(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'AclCust'})
cls.product = cls.env['product.product'].create({'name': 'AclWidget'})
cls.so = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'order_line': [(0, 0, {
'product_id': cls.product.id,
'product_uom_qty': 1,
})],
})
cls.tech = cls.env['res.users'].create({
'name': 'Tech ACL',
'login': 'tech_acl_recv',
# Odoo 19: group_ids (NOT groups_id) — CLAUDE.md rule 13c.
'group_ids': [(6, 0, [
cls.env.ref('fusion_plating.group_fp_technician').id,
])],
})
def test_technician_can_count_and_close_receiving(self):
# Created as admin; the technician must be able to count + close.
rec = self.env['fp.receiving'].create({
'sale_order_id': self.so.id,
'box_count_in': 3,
})
rec_as_tech = rec.with_user(self.tech)
try:
rec_as_tech.action_mark_counted()
except AccessError as e:
self.fail("Technician blocked marking counted: %s" % e)
self.assertEqual(rec.state, 'counted')
rec_as_tech.action_close()
self.assertEqual(rec.state, 'closed')
# The SO status write inside _update_so_receiving_status must have
# gone through (it is sudo'd) — proves no AccessError on sale.order.
self.assertEqual(self.so.x_fc_receiving_status, 'received')
def test_technician_can_create_damage(self):
rec = self.env['fp.receiving'].create({'sale_order_id': self.so.id})
dmg = self.env['fp.receiving.damage'].with_user(self.tech).create({
'receiving_id': rec.id,
'description': 'scratch on flange',
})
self.assertTrue(dmg.id)