This commit is contained in:
gsinghpal
2026-05-18 22:33:23 -04:00
parent 25f568f225
commit 091f98e1f9
76 changed files with 4521 additions and 220 deletions

View File

@@ -589,3 +589,367 @@ class TestQtyGate(TransactionCase):
with self.assertRaises(UserError) as exc:
wiz.action_commit()
self.assertIn('at least 1', str(exc.exception))
class TestCertCreationAndGates(TransactionCase):
"""2026-05-18 — cert creation bug fix + gate hardening.
Covers the fixes for the WO-30040 incident where
_fp_create_certificates raised NameError on `coating` and the cert
was never created. Also covers the new qty_received gate on
button_mark_done and the auto-fill of certified_by_id /
contact_partner_id / nc_quantity / process_description.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.signer = cls.env['res.users'].create({
'name': 'Quality Manager',
'login': 'qa_mgr_certtest',
'email': 'qa@example.com',
})
cls.contact = cls.env['res.partner'].create({
'name': 'Bob Receiver',
'email': 'bob@cust.example',
})
cls.partner = cls.env['res.partner'].create({
'name': 'CertCust',
'is_company': True,
'x_fc_send_coc': True,
'x_fc_default_coc_contact_id': cls.contact.id,
})
cls.contact.parent_id = cls.partner.id
cls.product = cls.env['product.product'].create({
'name': 'CertWidget',
})
cls.part = cls.env['fp.part.catalog'].create({
'name': 'CertPart',
'part_number': 'CP-001',
'partner_id': cls.partner.id,
'certificate_requirement': 'coc',
})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'part_catalog_id': self.part.id,
'qty': 1.0,
'qty_done': 1.0,
'qty_received': 1.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
# ---------------- bug fix regression -------------------------------
def test_create_cert_handles_job_with_no_recipe(self):
"""Regression for the `coating` NameError: cert must create
even when the job has no recipe and no coating config."""
job = self._make_job()
self.assertFalse(job.recipe_id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 1)
self.assertFalse(certs.process_description)
# ---------------- prefill -----------------------------------------
def test_create_cert_prefills_signer_from_company(self):
self.env.company.x_fc_owner_user_id = self.signer.id
job = self._make_job()
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.certified_by_id, self.signer)
def test_create_cert_prefills_contact_from_partner(self):
job = self._make_job()
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.contact_partner_id, self.contact)
def test_create_cert_computes_nc_quantity(self):
job = self._make_job(
qty=4, qty_done=3, qty_scrapped=1, qty_received=4,
qty_visual_inspection_rejects=0,
)
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.nc_quantity, 1)
# ---------------- mark_done qty_received gate ----------------------
def test_mark_done_blocks_on_blank_qty_received(self):
from odoo.exceptions import UserError
job = self._make_job(qty=1, qty_done=1, qty_received=0)
step = self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
with self.assertRaises(UserError) as exc:
job.button_mark_done()
self.assertIn('Quantity Received', str(exc.exception))
def test_mark_done_blocks_on_qty_received_mismatch(self):
from odoo.exceptions import UserError
# received 5, accounted = 3 done + 1 scrap + 0 rejects = 4
job = self._make_job(qty=5, qty_done=3, qty_scrapped=1,
qty_received=5, qty_visual_inspection_rejects=0)
self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
# base qty reconcile passes: 3+1=4 != 5 → first gate raises first
# rebalance so it passes the first check and fails the new one:
job.qty = 4
with self.assertRaises(UserError) as exc:
job.button_mark_done()
self.assertIn('qty mismatch', str(exc.exception).lower())
def test_mark_done_passes_with_clean_reconcile(self):
job = self._make_job(qty=4, qty_done=3, qty_scrapped=1,
qty_received=4, qty_visual_inspection_rejects=0)
self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
job.with_context(fp_skip_qc_gate=True).button_mark_done()
self.assertEqual(job.state, 'done')
def test_mark_done_bypass_skips_qty_received_check(self):
job = self._make_job(qty=1, qty_done=1, qty_received=0)
self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
job.with_context(
fp_skip_qty_reconcile=True,
fp_skip_qc_gate=True,
).button_mark_done()
self.assertEqual(job.state, 'done')
# ---------------- backfill action ---------------------------------
def test_backfill_creates_missing_certs(self):
"""A closed job with no cert gets one when the backfill runs."""
job = self._make_job()
job.state = 'done'
# Sanity: no cert exists
self.assertFalse(self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
]))
self.env['fp.job'].action_backfill_missing_certs()
self.assertEqual(self.env['fp.certificate'].search_count([
('x_fc_job_id', '=', job.id),
]), 1)
def test_backfill_idempotent(self):
job = self._make_job()
job.state = 'done'
job._fp_create_certificates()
before = self.env['fp.certificate'].search_count([
('x_fc_job_id', '=', job.id),
])
self.env['fp.job'].action_backfill_missing_certs()
after = self.env['fp.certificate'].search_count([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(before, after)
class TestReceivingGate(TransactionCase):
"""2026-05-18 — Hard gate on button_start / button_finish blocking
step transitions until SO receiving status = 'received'. Contract
Review steps are exempt; manager bypass via context flag
`fp_skip_receiving_gate=True`. See
docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'RecvCust'})
cls.product = cls.env['product.product'].create({'name': 'Widget'})
def _make_so(self, recv_status='not_received'):
so = self.env['sale.order'].create({'partner_id': self.partner.id})
if 'x_fc_receiving_status' in so._fields:
so.x_fc_receiving_status = recv_status
return so
def _make_job_with_step(self, recv_status='not_received',
step_state='ready', is_cr=False):
"""Build a job tied to an SO with the given receiving status,
plus a single step in the given state. Returns (job, step)."""
so = self._make_so(recv_status=recv_status)
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'sale_order_id': so.id,
})
step_vals = {
'job_id': job.id,
'name': 'Plate',
'state': step_state,
}
# If a step_kind model is available, set CR vs not via kind.
StepKind = self.env.get('fp.step.kind')
if StepKind is not None and is_cr:
cr_kind = StepKind.search(
[('code', '=', 'contract_review')], limit=1,
)
if cr_kind:
step_vals['step_kind_id'] = cr_kind.id
step = self.env['fp.job.step'].create(step_vals)
return job, step
# ---- button_start gate ------------------------------------------------
def test_start_blocks_when_not_received(self):
from odoo.exceptions import UserError
job, step = self._make_job_with_step(recv_status='not_received')
with self.assertRaises(UserError) as exc:
step.button_start()
self.assertIn('parts not received', str(exc.exception).lower())
def test_start_allows_when_received(self):
job, step = self._make_job_with_step(recv_status='received')
# Should not raise; step transitions to in_progress via super().
step.button_start()
self.assertIn(step.state, ('in_progress', 'ready'))
def test_start_skips_contract_review(self):
# CR step exempt regardless of receiving status.
job, step = self._make_job_with_step(
recv_status='not_received', is_cr=True,
)
# button_start may return an action (CR auto-open) — must not raise.
try:
step.button_start()
except Exception as e:
from odoo.exceptions import UserError
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
self.fail('CR step should be exempt from receiving gate')
# Other failures (e.g. CR auto-open quirks in test env) are
# not the gate — accept them.
def test_start_bypass_via_context(self):
job, step = self._make_job_with_step(recv_status='not_received')
step.with_context(fp_skip_receiving_gate=True).button_start()
self.assertIn(step.state, ('in_progress', 'ready'))
# ---- button_finish gate -----------------------------------------------
def test_finish_blocks_when_not_received(self):
from odoo.exceptions import UserError
job, step = self._make_job_with_step(
recv_status='not_received', step_state='in_progress',
)
with self.assertRaises(UserError) as exc:
step.button_finish()
self.assertIn('parts not received', str(exc.exception).lower())
def test_finish_allows_when_received(self):
job, step = self._make_job_with_step(
recv_status='received', step_state='in_progress',
)
step.button_finish()
self.assertIn(step.state, ('done', 'in_progress'))
def test_finish_skips_contract_review(self):
job, step = self._make_job_with_step(
recv_status='not_received', step_state='in_progress',
is_cr=True,
)
try:
step.button_finish()
except Exception as e:
from odoo.exceptions import UserError
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
self.fail('CR step should be exempt from receiving gate')
def test_finish_bypass_via_context(self):
job, step = self._make_job_with_step(
recv_status='not_received', step_state='in_progress',
)
step.with_context(fp_skip_receiving_gate=True).button_finish()
self.assertIn(step.state, ('done', 'in_progress'))
class TestCreateDeliveryShippingMirror(TransactionCase):
"""Phase A — _fp_create_delivery mirrors shipping fields from the
linked receiving onto the auto-created fp.delivery."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'MirrorCust'})
cls.product = cls.env['product.product'].create({'name': 'Widget'})
cls.carrier_ups = cls.env.ref(
'fusion_plating_receiving.delivery_carrier_ups',
)
def _make_so_with_receiving(self, carrier=None, shipment=None):
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 1,
})],
})
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'x_fc_carrier_id': carrier.id if carrier else False,
'x_fc_outbound_shipment_id': shipment.id if shipment else False,
})
return so, recv
def _make_job(self, so):
return self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'sale_order_id': so.id,
})
def test_create_delivery_mirrors_carrier_from_receiving(self):
so, recv = self._make_so_with_receiving(carrier=self.carrier_ups)
job = self._make_job(so)
job._fp_create_delivery()
self.assertTrue(job.delivery_id)
self.assertEqual(job.delivery_id.x_fc_carrier_id, self.carrier_ups)
def test_create_delivery_mirrors_outbound_shipment(self):
shipment = self.env['fusion.shipment'].create({
'sale_order_id': False,
'carrier_id': self.carrier_ups.id,
'status': 'draft',
})
so, recv = self._make_so_with_receiving(
carrier=self.carrier_ups, shipment=shipment,
)
job = self._make_job(so)
job._fp_create_delivery()
self.assertEqual(
job.delivery_id.x_fc_outbound_shipment_id, shipment,
)
def test_create_delivery_no_receiving_no_mirror(self):
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
})
job = self._make_job(so)
job._fp_create_delivery()
self.assertTrue(job.delivery_id)
self.assertFalse(job.delivery_id.x_fc_carrier_id)
self.assertFalse(job.delivery_id.x_fc_outbound_shipment_id)