feat(plating): Sub 8 — split receiving vs inspection + box parity

fp.receiving simplifies to box-count-only (new primary state
machine: draft → counted → staged → closed). Legacy
inspecting/accepted/discrepancy/resolved states stay in the
Selection so existing records load without error but are surfaced
behind a manager-only toggle. New box_count_in field + banner
that tells the receiver "count boxes only — parts are inspected
by the racking crew."

New fp.racking.inspection + fp.racking.inspection.line models —
one record per MO, auto-created by mrp.production.create() with
one line per contributing SO line (qty_expected seeded, qty_found
+ condition filled in by the racking crew when they open the boxes).
State: draft → inspecting → done | discrepancy_flagged (flagged
when any line has a non-ok condition or qty variance). Reopen
restricted to Plating Manager.

WO soft gate: first plating WO button_start raises a UserError
when the MO's racking inspection is still Draft or Inspecting.
Plating Manager bypasses; later WOs are not gated.

fp.delivery gains x_fc_box_count_out. action_mark_delivered calls
_fp_check_box_parity which posts a non-blocking chatter warning
when boxes out ≠ boxes in (resolved via job_ref → MO.origin → SO
→ receiving). Warning only — never blocks shipping.

Menu entry: Plating → Operations → Racking Inspection.

Module version bumps:
  fusion_plating_receiving  → 19.0.3.0.0
  fusion_plating_logistics  → 19.0.3.0.0
  fusion_plating_bridge_mrp → 19.0.12.0.0 (+depends receiving)

Smoke on entech: 12/12 assertions pass (one gate test skipped —
MO had no WOs to test) including box-count state machine, inspection
auto-create, lifecycle, discrepancy flag, and box-parity chatter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-23 00:30:36 -04:00
parent 392359d2c4
commit 2bfabfe135
15 changed files with 808 additions and 25 deletions

View File

@@ -0,0 +1,168 @@
"""Sub 8 smoke test — runs inside odoo-shell on entech."""
env = env
Partner = env['res.partner']
SO = env['sale.order']
Receiving = env['fp.receiving']
Insp = env['fp.racking.inspection']
Delivery = env['fusion.plating.delivery']
# ---- Field / model presence -----------------------------------------
assert 'box_count_in' in Receiving._fields
assert 'x_fc_box_count_out' in Delivery._fields
assert hasattr(Insp, 'action_start')
assert hasattr(Insp, 'action_complete')
print('[OK] Models + fields present')
# ---- Receiving state machine -----------------------------------------
cust = Partner.create({
'name': 'Sub 8 Smoke Customer',
'is_company': True,
'customer_rank': 1,
'email': 'sub8@test.com',
})
Product = env['product.product']
product = Product.search([('sale_ok', '=', True)], limit=1)
so = SO.create({
'partner_id': cust.id,
'x_fc_po_number': 'PO-SUB8',
'x_fc_po_received': True,
'order_line': [(0, 0, {
'product_id': product.id,
'product_uom_qty': 10,
'name': 'Sub 8 smoke',
'x_fc_internal_description': 'smoke',
})],
})
recv = Receiving.create({'sale_order_id': so.id, 'expected_qty': 10})
assert recv.state == 'draft'
# Fail: mark counted without box count
try:
recv.action_mark_counted()
assert False, 'should require box_count_in'
except Exception as e:
assert 'box' in str(e).lower() or 'Boxes' in str(e)
print('[OK] mark_counted blocked without box_count_in')
recv.box_count_in = 4
recv.action_mark_counted()
assert recv.state == 'counted'
print(f'[OK] mark_counted → state={recv.state}, boxes={recv.box_count_in}')
recv.action_mark_staged()
assert recv.state == 'staged'
print(f'[OK] mark_staged → state={recv.state}')
recv.action_close()
assert recv.state == 'closed'
print(f'[OK] close → state={recv.state}')
# ---- MO auto-creates racking inspection ------------------------------
so.action_confirm()
MO = env['mrp.production']
mos = MO.search([('origin', '=', so.name)])
assert mos, 'MO should be created by SO confirm'
mo = mos[0]
print(f'[OK] MO created: {mo.name}')
inspections = Insp.search([('production_id', '=', mo.id)])
assert len(inspections) == 1, f'expected 1 inspection, got {len(inspections)}'
inspection = inspections[0]
assert inspection.state == 'draft'
assert len(inspection.line_ids) == 1
assert inspection.line_ids[0].qty_expected == 10
print(f'[OK] Racking inspection auto-created: {inspection.name}')
# ---- Inspection lifecycle --------------------------------------------
inspection.action_start()
assert inspection.state == 'inspecting'
assert inspection.inspector_id == env.user
print('[OK] Inspection started')
# OK case
inspection.line_ids[0].write({'qty_found': 10, 'condition': 'ok'})
inspection.action_complete()
assert inspection.state == 'done', f'expected done, got {inspection.state}'
print(f'[OK] Inspection complete → state={inspection.state}')
# ---- Flag a discrepancy on a separate inspection ---------------------
# Reopen + set damage
inspection.action_reopen()
inspection.line_ids[0].write({'qty_found': 8, 'condition': 'major'})
inspection.action_complete()
assert inspection.state == 'discrepancy_flagged'
print(f'[OK] Discrepancy flagged → state={inspection.state}')
# ---- WO soft gate --------------------------------------------------
# Reopen inspection to 'inspecting' to test the gate
inspection.action_reopen()
assert inspection.state == 'inspecting'
first_wo = mo.workorder_ids.sorted('sequence')[:1]
if first_wo:
try:
# Soft gate — manager bypasses. Try with a non-manager user if available.
demo_user = env['res.users'].search([('login', '=', 'demo')], limit=1)
if demo_user:
first_wo.with_user(demo_user)._fp_warn_if_racking_inspection_pending()
assert False, 'soft gate should raise for non-manager'
else:
# No demo user — check manager bypass works (env.user is admin)
first_wo._fp_warn_if_racking_inspection_pending()
print('[OK] Manager bypass (no demo user to test block)')
except Exception as e:
if 'Racking inspection' in str(e) or 'Inspecting' in str(e):
print('[OK] Soft gate blocks non-manager when inspection pending')
else:
raise
else:
print('[SKIP] No WOs on this MO to test gate')
# Mark inspection done and verify gate is clear
inspection.line_ids[0].qty_found = 10
inspection.line_ids[0].condition = 'ok'
inspection.action_complete()
assert inspection.state == 'done'
if first_wo:
first_wo._fp_warn_if_racking_inspection_pending() # should not raise
print('[OK] Gate clears when inspection is Done')
# ---- Box parity on delivery ------------------------------------------
delivery = Delivery.create({
'partner_id': cust.id,
'job_ref': mo.name,
'x_fc_box_count_out': 3,
})
delivery._fp_check_box_parity()
# Check chatter for the warning
messages = env['mail.message'].search([
('model', '=', 'fusion.plating.delivery'),
('res_id', '=', delivery.id),
])
body = ' '.join(m.body for m in messages if m.body)
assert 'parity' in body.lower() or 'boxes' in body.lower() or 'shipped' in body.lower(), (
f'expected parity warning; got messages: {body[:200]}'
)
print('[OK] Box-parity warning posted on delivery chatter')
# Matching counts — no warning
delivery2 = Delivery.create({
'partner_id': cust.id,
'job_ref': mo.name,
'x_fc_box_count_out': 4, # matches receiving.box_count_in
})
before = env['mail.message'].search_count([
('model', '=', 'fusion.plating.delivery'),
('res_id', '=', delivery2.id),
])
delivery2._fp_check_box_parity()
after = env['mail.message'].search_count([
('model', '=', 'fusion.plating.delivery'),
('res_id', '=', delivery2.id),
])
assert after == before, f'expected no new message, before={before} after={after}'
print('[OK] No warning when boxes match')
env.cr.rollback()
print('\n=== SUB 8 SMOKE PASS — all assertions held ===')