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:
gsinghpal
2026-05-22 21:50:09 -04:00
parent a61bd05a5c
commit eae6a471e8
4 changed files with 504 additions and 0 deletions

View File

@@ -1 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_workspace_controller

View File

@@ -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'])