feat(fusion_plating_shopfloor): workspace_controller — 4 endpoints + tests
Plan tasks P1.8 through P1.11 batched into one commit (local tests not
run between them; entech is the verification env).
POST /fp/workspace/load — full payload for one fp.job
POST /fp/workspace/hold — quality.hold create with photo
POST /fp/workspace/sign_off — signature + finish step atomic
POST /fp/workspace/advance_milestone — fire next_milestone_action
Each endpoint logs INFO on success, EXCEPTION on failure, returns a
consistent {'ok': bool, 'error': str?} envelope. Hold endpoint isolates
photo-attach failures so they don't roll back the hold record.
Tests cover: payload shape, bad job_id, hold create with/without photo,
empty qty rejection, empty-signature rejection, sign-off finish, and
the no-milestone-action error path.
Verify on entech: -u fusion_plating_shopfloor --test-tags fp_shopfloor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_workspace_controller
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""HTTP tests for /fp/workspace/* endpoints."""
|
||||
import base64
|
||||
import json
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
# Minimal 1x1 PNG so photo + signature attachment tests can run without
|
||||
# packing a real binary in the source tree.
|
||||
_TINY_PNG_B64 = base64.b64encode(
|
||||
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01'
|
||||
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
|
||||
).decode()
|
||||
|
||||
|
||||
def _rpc(case, url, **params):
|
||||
res = case.url_open(
|
||||
url,
|
||||
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return res.json()['result']
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceLoad(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'WS Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'WS Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/WS001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 5,
|
||||
})
|
||||
|
||||
def test_load_returns_full_payload(self):
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['job']['display_wo_name'], 'WO # WS001')
|
||||
self.assertEqual(res['job']['id'], self.job.id)
|
||||
for key in ('steps', 'workflow_states', 'chatter',
|
||||
'attachments', 'required_certs'):
|
||||
self.assertIn(key, res)
|
||||
|
||||
def test_load_bad_job_id_returns_error(self):
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=999999)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('not found', res['error'].lower())
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceHold(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'Hold Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Hold Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/H001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 10,
|
||||
})
|
||||
|
||||
def test_hold_creates_quality_hold(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/hold',
|
||||
job_id=self.job.id, reason='dimensional', qty_on_hold=3,
|
||||
description='Bracket bent on de-rack',
|
||||
part_ref='Bracket Rev A',
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
hold = self.env['fusion.plating.quality.hold'].browse(res['hold_id'])
|
||||
self.assertEqual(hold.qty_on_hold, 3)
|
||||
self.assertEqual(hold.hold_reason, 'dimensional')
|
||||
|
||||
def test_hold_with_photo_creates_attachment(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/hold',
|
||||
job_id=self.job.id, reason='thickness', qty_on_hold=1,
|
||||
photo_data=_TINY_PNG_B64, photo_filename='evidence.png',
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertTrue(res['attachment_id'])
|
||||
attachments = self.env['ir.attachment'].search([
|
||||
('res_model', '=', 'fusion.plating.quality.hold'),
|
||||
('res_id', '=', res['hold_id']),
|
||||
])
|
||||
self.assertGreaterEqual(len(attachments), 1)
|
||||
|
||||
def test_hold_qty_zero_rejected(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/hold',
|
||||
job_id=self.job.id, qty_on_hold=0,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceSignOff(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/S001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
self.step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'ENP Plate',
|
||||
'sequence': 50,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
|
||||
def test_sign_off_rejects_empty_signature(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/sign_off',
|
||||
step_id=self.step.id, signature_data_uri='',
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('signature', res['error'].lower())
|
||||
|
||||
def test_sign_off_finishes_step(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/sign_off',
|
||||
step_id=self.step.id, signature_data_uri=_TINY_PNG_B64,
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
self.step.invalidate_recordset(['state'])
|
||||
self.assertEqual(self.step.state, 'done')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceAdvanceMilestone(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'M Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'M Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/M001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_advance_no_action_returns_error(self):
|
||||
# Job with no steps → no next_milestone_action → friendly reject
|
||||
res = _rpc(self, '/fp/workspace/advance_milestone', job_id=self.job.id)
|
||||
self.assertFalse(res['ok'])
|
||||
Reference in New Issue
Block a user