changes
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user