This commit is contained in:
gsinghpal
2026-04-27 00:11:18 -04:00
parent d9f58b9851
commit f08f328688
116 changed files with 9891 additions and 359 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.3.0.0',
'version': '19.0.4.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -69,6 +69,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_configurator',
'fusion_plating_certificates', # fp.thickness.reading link from QC
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
'fusion_plating_receiving', # rma_id on fp.receiving (Sub 12 Phase A)
# NB: deliberately NOT depending on fusion_plating_jobs — it depends
# on us already (extends fusion.plating.quality.hold). Many2one('fp.job')
# on fp.rma is resolved by the registry once jobs loads after us.
'mail',
],
'data': [
@@ -76,6 +80,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_quality_hold_sequence_data.xml',
'data/fp_rma_sequence.xml',
'data/fp_quality_categorisation_data.xml',
'data/fp_qc_data.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_hold_views.xml',
@@ -93,6 +99,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_contract_review_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_quality_check_views.xml',
'views/fp_rma_views.xml',
'views/fp_quality_categorisation_views.xml',
'views/fp_quality_point_views.xml',
'views/fp_quality_smart_button_views.xml',
'views/fp_quality_dashboard_views.xml',
'reports/fp_contract_review_report.xml',
'reports/fp_contract_review_template.xml',
'views/fp_menu.xml',
@@ -107,6 +118,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_quality/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_quality/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_quality/static/src/js/fp_qc_checklist.js',
# Sub 12 Phase D — Unified Quality Dashboard.
'fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss',
'fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml',
'fusion_plating_quality/static/src/js/fp_quality_dashboard.js',
],
},
'installable': True,

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import fp_qc_controller
from . import fp_quality_dashboard

View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — counts endpoint for the Unified Quality Dashboard.
from odoo import fields, http
from odoo.http import request
class FpQualityDashboardController(http.Controller):
@http.route('/fp/quality/dashboard/counts',
type='jsonrpc', auth='user', methods=['POST'])
def counts(self):
"""Return per-tab open + overdue counts for the dashboard.
"Overdue" definition:
- Hold: state='on_hold' for > 3 days
- Check: state='pending' for > 1 day
- NCR: state in (open, containment, disposition) AND reported >7d
- CAPA: due_date < today AND state not in (effective, closed)
- RMA: state='received' for > 5 days (triage past due) OR
state in (authorised, shipped_to_us) for > 14 days
"""
env = request.env
today = fields.Date.context_today(env.user)
now = fields.Datetime.now()
Hold = env['fusion.plating.quality.hold']
Check = env['fusion.plating.quality.check']
Ncr = env['fusion.plating.ncr']
Capa = env['fusion.plating.capa']
Rma = env['fusion.plating.rma']
d3 = fields.Datetime.subtract(now, days=3)
d1 = fields.Datetime.subtract(now, days=1)
d7 = fields.Datetime.subtract(now, days=7)
d5 = fields.Datetime.subtract(now, days=5)
d14 = fields.Datetime.subtract(now, days=14)
return {
'holds': {
'open': Hold.search_count(
[('state', 'in', ('on_hold', 'under_review'))]),
'overdue': Hold.search_count([
('state', 'in', ('on_hold', 'under_review')),
('create_date', '<', d3),
]),
},
'checks': {
'open': Check.search_count([('state', '=', 'pending')]),
'overdue': Check.search_count([
('state', '=', 'pending'),
('create_date', '<', d1),
]),
},
'ncrs': {
'open': Ncr.search_count([
('state', 'in', ('open', 'containment', 'disposition')),
]),
'overdue': Ncr.search_count([
('state', 'in', ('open', 'containment', 'disposition')),
('reported_date', '<', d7),
]),
},
'capas': {
'open': Capa.search_count([
('state', 'not in', ('effective', 'closed')),
]),
'overdue': Capa.search_count([
('state', 'not in', ('effective', 'closed')),
('due_date', '<', today),
('due_date', '!=', False),
]),
},
'rmas': {
'open': Rma.search_count([
('state', 'not in', ('closed', 'cancelled')),
]),
'overdue': Rma.search_count([
'|',
'&', ('state', '=', 'received'),
('create_date', '<', d5),
'&', ('state', 'in', ('authorised', 'shipped_to_us')),
('create_date', '<', d14),
]),
},
}

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase B — seed data for the kanban stage namespace + a small
starter set of tags, reasons, and one default quality team. All are
`noupdate=1` so a customer's edits survive module upgrades.
-->
<odoo noupdate="1">
<!-- ============================================ STAGES ===== -->
<record id="stage_new" model="fp.quality.alert.stage">
<field name="name">New</field>
<field name="code">new</field>
<field name="sequence">10</field>
</record>
<record id="stage_investigating" model="fp.quality.alert.stage">
<field name="name">Investigating</field>
<field name="code">investigating</field>
<field name="sequence">20</field>
</record>
<record id="stage_containment" model="fp.quality.alert.stage">
<field name="name">Containment</field>
<field name="code">containment</field>
<field name="sequence">30</field>
</record>
<record id="stage_disposition" model="fp.quality.alert.stage">
<field name="name">Disposition</field>
<field name="code">disposition</field>
<field name="sequence">40</field>
</record>
<record id="stage_awaiting_signoff" model="fp.quality.alert.stage">
<field name="name">Awaiting Sign-off</field>
<field name="code">awaiting_signoff</field>
<field name="sequence">50</field>
</record>
<record id="stage_closed" model="fp.quality.alert.stage">
<field name="name">Closed</field>
<field name="code">closed</field>
<field name="sequence">60</field>
<field name="fold" eval="True"/>
</record>
<record id="stage_cancelled" model="fp.quality.alert.stage">
<field name="name">Cancelled</field>
<field name="code">cancelled</field>
<field name="sequence">70</field>
<field name="fold" eval="True"/>
</record>
<!-- ============================================== TAGS ===== -->
<record id="tag_customer_complaint" model="fp.quality.tag">
<field name="name">Customer Complaint</field>
<field name="color">2</field>
</record>
<record id="tag_thickness" model="fp.quality.tag">
<field name="name">Thickness</field>
<field name="color">3</field>
</record>
<record id="tag_appearance" model="fp.quality.tag">
<field name="name">Appearance</field>
<field name="color">4</field>
</record>
<record id="tag_adhesion" model="fp.quality.tag">
<field name="name">Adhesion</field>
<field name="color">5</field>
</record>
<record id="tag_corrosion" model="fp.quality.tag">
<field name="name">Corrosion</field>
<field name="color">1</field>
</record>
<record id="tag_repeat_offender" model="fp.quality.tag">
<field name="name">Repeat Offender</field>
<field name="color">1</field>
<field name="description">Same customer + part has had &gt; 2 issues in 90 days.</field>
</record>
<record id="tag_audit_finding" model="fp.quality.tag">
<field name="name">Audit Finding</field>
<field name="color">6</field>
</record>
<record id="tag_first_off" model="fp.quality.tag">
<field name="name">First-Off Inspection</field>
<field name="color">7</field>
</record>
<!-- ========================================== REASONS ===== -->
<record id="reason_chemistry_drift" model="fp.quality.reason">
<field name="name">Bath Chemistry Drift</field>
<field name="category">process</field>
<field name="description">Concentration, pH, or temperature outside spec window.</field>
</record>
<record id="reason_contamination" model="fp.quality.reason">
<field name="name">Bath Contamination</field>
<field name="category">process</field>
</record>
<record id="reason_temperature" model="fp.quality.reason">
<field name="name">Temperature Excursion</field>
<field name="category">process</field>
</record>
<record id="reason_supplier_inbound" model="fp.quality.reason">
<field name="name">Inbound Material Defect</field>
<field name="category">supplier</field>
</record>
<record id="reason_calibration" model="fp.quality.reason">
<field name="name">Out-of-Calibration Equipment</field>
<field name="category">equipment</field>
</record>
<record id="reason_rectifier" model="fp.quality.reason">
<field name="name">Rectifier / Power Supply Issue</field>
<field name="category">equipment</field>
</record>
<record id="reason_misload" model="fp.quality.reason">
<field name="name">Mis-load / Mis-rack</field>
<field name="category">human</field>
</record>
<record id="reason_training_gap" model="fp.quality.reason">
<field name="name">Training Gap</field>
<field name="category">human</field>
</record>
<record id="reason_recipe_violation" model="fp.quality.reason">
<field name="name">Recipe Step Skipped</field>
<field name="category">human</field>
</record>
<record id="reason_part_defect" model="fp.quality.reason">
<field name="name">Customer Part Defect</field>
<field name="category">material</field>
</record>
<!-- ============================================ TEAMS ===== -->
<record id="team_default_qa" model="fp.quality.team">
<field name="name">Quality Assurance</field>
<field name="sequence">10</field>
<field name="description">Default quality team. Assign every new NCR/RMA here unless the issue clearly belongs to a process-specific team.</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo noupdate="1">
<record id="seq_fp_rma" model="ir.sequence">
<field name="name">Fusion Plating: RMA</field>
<field name="code">fusion.plating.rma</field>
<field name="prefix">RMA/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -23,3 +23,23 @@ from . import fp_part_catalog
from . import fp_qc_template
from . import fp_thickness_reading
from . import fp_quality_check
# Sub 12 Phase A — native RMA + the inverse fields it hangs off existing
# quality and receiving models.
from . import fp_rma
from . import fp_rma_links
# Sub 12 Phase B — categorisation primitives + cross-model link fields.
from . import fp_quality_tag
from . import fp_quality_reason
from . import fp_quality_team
from . import fp_quality_alert_stage
from . import fp_quality_categorisation_links
# Sub 12 Phase C — trigger-based quality points.
from . import fp_quality_point
from . import fp_quality_point_hooks
# Sub 12 Phase D — smart-button counts + cross-creation actions.
from . import fp_quality_smart_buttons
from . import fp_quality_cross_creation

View File

@@ -44,28 +44,29 @@ class FpPartCatalog(models.Model):
# ---- Computes ------------------------------------------------------------
def _compute_has_confirmed_mo(self):
"""True if this part is referenced by at least one non-draft MO.
"""True if this part is referenced by at least one live fp.job.
Trace: fp.part.catalog → sale.order.line (x_fc_part_catalog_id)
→ sale.order → mrp.production (via origin name match).
Cheap: two bounded search_counts. Kept store=False so MO state
changes don't write-amplify through every part record.
Sub 11 — replaced mrp.production lookup with fp.job. Trace:
fp.part.catalog → sale.order.line (x_fc_part_catalog_id) →
sale.order → fp.job (via origin name match).
"""
SO = self.env['sale.order']
MO = self.env['mrp.production']
live_states = ('confirmed', 'progress', 'to_close', 'done')
live_states = ('confirmed', 'in_progress', 'on_hold', 'done')
for part in self:
part.x_fc_has_confirmed_mo = False
if 'fp.job' not in self.env:
return
Job = self.env['fp.job']
for part in self:
if not part.id:
part.x_fc_has_confirmed_mo = False
continue
so_names = SO.search([
('order_line.x_fc_part_catalog_id', '=', part.id),
('state', 'in', ('sale', 'done')),
]).mapped('name')
if not so_names:
part.x_fc_has_confirmed_mo = False
continue
part.x_fc_has_confirmed_mo = bool(MO.search_count([
part.x_fc_has_confirmed_mo = bool(Job.search_count([
('origin', 'in', so_names),
('state', 'in', live_states),
]))

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality alert stage.
#
# Shared kanban-stage namespace used by both NCR and RMA. Each model has
# its own state Selection (state machine guards) AND a stage_id Many2one
# (kanban-draggable). The two stay in sync — see fp_quality_categorisation_links.
from odoo import fields, models
class FpQualityAlertStage(models.Model):
_name = 'fp.quality.alert.stage'
_description = 'Fusion Plating — Quality Alert Stage'
_order = 'sequence, id'
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10, index=True)
fold = fields.Boolean(
string='Fold by Default',
help='If checked the stage is collapsed by default in kanban views.',
)
code = fields.Char(
string='Code',
index=True,
help='Stable machine identifier used by the state ↔ stage_id sync. '
'Examples: new / investigating / containment / disposition / '
'awaiting_signoff / closed / cancelled.',
)
description = fields.Text(translate=True)
active = fields.Boolean(default=True)
_sql_constraints = [
('code_uniq', 'unique(code)', 'A stage with that code already exists.'),
]

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — categorisation field extensions.
#
# Adds the cross-cutting tag_ids / reason_id / team_id fields to all five
# quality records (NCR, CAPA, Hold, Check, RMA). Adds stage_id (kanban
# stage) to NCR + RMA with state ↔ stage_id sync.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# ----- helper mapping (keeps stage codes consistent across models) -----
NCR_STATE_TO_STAGE_CODE = {
'draft': 'new',
'open': 'investigating',
'containment': 'containment',
'disposition': 'disposition',
'closed': 'closed',
}
NCR_STAGE_CODE_TO_STATE = {v: k for k, v in NCR_STATE_TO_STAGE_CODE.items()}
RMA_STATE_TO_STAGE_CODE = {
'draft': 'new',
'authorised': 'investigating',
'shipped_to_us': 'investigating',
'received': 'containment',
'triaged': 'disposition',
'resolving': 'disposition',
'resolved': 'awaiting_signoff',
'closed': 'closed',
'cancelled': 'cancelled',
}
def _stage_for_code(env, code):
if not code:
return env['fp.quality.alert.stage']
return env['fp.quality.alert.stage'].sudo().search(
[('code', '=', code)], limit=1,
)
# ============================================================ NCR ===
class FpNcrCategorisation(models.Model):
_inherit = 'fusion.plating.ncr'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_ncr_tag_rel', 'ncr_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team',
tracking=True)
stage_id = fields.Many2one(
'fp.quality.alert.stage', string='Stage',
compute='_compute_stage_id', inverse='_inverse_stage_id',
store=True, tracking=True, group_expand='_read_group_stage_ids',
)
@api.depends('state')
def _compute_stage_id(self):
for rec in self:
code = NCR_STATE_TO_STAGE_CODE.get(rec.state)
rec.stage_id = _stage_for_code(self.env, code) if code else False
def _inverse_stage_id(self):
for rec in self:
if not rec.stage_id or not rec.stage_id.code:
continue
new_state = NCR_STAGE_CODE_TO_STATE.get(rec.stage_id.code)
if new_state and new_state != rec.state:
# Use direct write to avoid the action_close UserError
# guards — kanban drag is an explicit user intent.
super(FpNcrCategorisation, rec).write({'state': new_state})
@api.model
def _read_group_stage_ids(self, stages, domain):
return self.env['fp.quality.alert.stage'].sudo().search([])
# ============================================================ CAPA ===
class FpCapaCategorisation(models.Model):
_inherit = 'fusion.plating.capa'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_capa_tag_rel', 'capa_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
# ============================================================ HOLD ===
class FpQualityHoldCategorisation(models.Model):
_inherit = 'fusion.plating.quality.hold'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_hold_tag_rel', 'hold_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
# =========================================================== CHECK ===
class FpQualityCheckCategorisation(models.Model):
_inherit = 'fusion.plating.quality.check'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_check_tag_rel', 'check_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Failure Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
# ============================================================ RMA ===
class FpRmaCategorisation(models.Model):
_inherit = 'fusion.plating.rma'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_rma_tag_rel', 'rma_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team',
tracking=True)
stage_id = fields.Many2one(
'fp.quality.alert.stage', string='Stage',
compute='_compute_stage_id', store=True, tracking=True,
group_expand='_read_group_stage_ids',
help='Computed from state. RMA state machine has guards (use the '
'lifecycle buttons for valid transitions); the stage field is '
'read-mostly here so the unified Quality Dashboard can group '
'NCR + RMA cards in one kanban.',
)
@api.depends('state')
def _compute_stage_id(self):
for rec in self:
code = RMA_STATE_TO_STAGE_CODE.get(rec.state)
rec.stage_id = _stage_for_code(self.env, code) if code else False
@api.model
def _read_group_stage_ids(self, stages, domain):
return self.env['fp.quality.alert.stage'].sudo().search([])

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — cross-creation actions and CAPA closure-loop linkage.
#
# - NCR.action_spawn_capa: creates a draft CAPA pre-filled from the NCR.
# - CAPA.action_mark_not_effective override: auto-creates a follow-up NCR
# linked back to the original NCR. Closes the loop "we said we fixed it
# but it happened again."
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpNcrCrossCreation(models.Model):
_inherit = 'fusion.plating.ncr'
def action_spawn_capa(self):
"""Create a draft CAPA pre-filled from this NCR. Visible from form
when state ∈ {disposition, closed} and severity ≥ medium (gating
lives in the view; this method is a helper)."""
self.ensure_one()
Capa = self.env['fusion.plating.capa']
existing = Capa.search([('ncr_id', '=', self.id)], limit=1)
if existing:
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'form',
'res_id': existing.id,
}
capa = Capa.create({
'ncr_id': self.id,
'description': self.description,
'type': 'corrective',
'state': 'draft',
'team_id': self.team_id.id if self.team_id else False,
'reason_id': self.reason_id.id if self.reason_id else False,
})
self.message_post(
body=Markup('Spawned CAPA <b>%s</b> from this NCR.') % capa.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'form',
'res_id': capa.id,
}
class FpCapaCrossCreation(models.Model):
_inherit = 'fusion.plating.capa'
follow_up_ncr_id = fields.Many2one(
'fusion.plating.ncr', string='Follow-up NCR',
ondelete='set null',
help='When effectiveness verification fails, a new NCR is auto-spawned '
'linked back to the original. This field tracks that follow-up.',
)
def action_mark_not_effective(self):
"""Override to auto-spawn a follow-up NCR linked to the original.
Closes the closed-loop CAPA discipline: if a fix didn't work, the
next NCR gets a clear lineage back to the failed CAPA, so root-
cause analysis can dig deeper next time.
"""
super().action_mark_not_effective()
Ncr = self.env['fusion.plating.ncr']
for rec in self:
if rec.follow_up_ncr_id:
continue
if not rec.ncr_id:
_logger.info(
'CAPA %s marked not_effective but has no source NCR; '
'no follow-up NCR created.', rec.name,
)
continue
src = rec.ncr_id
ncr = Ncr.create({
'facility_id': src.facility_id.id,
'source': src.source,
'severity': src.severity,
'part_ref': src.part_ref,
'quantity_affected': src.quantity_affected,
'customer_partner_id': src.customer_partner_id.id,
'bath_id': src.bath_id.id if src.bath_id else False,
'description': Markup(
'<p><strong>Follow-up NCR auto-created from CAPA %s '
'(verification failed).</strong></p>'
) % rec.name,
'team_id': rec.team_id.id if rec.team_id else False,
'reason_id': rec.reason_id.id if rec.reason_id else False,
'tag_ids': [(6, 0, src.tag_ids.ids)],
})
rec.follow_up_ncr_id = ncr.id
rec.message_post(
body=Markup(
'Effectiveness verification failed. Spawned follow-up '
'<b>NCR %s</b> for re-investigation.'
) % ncr.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
ncr.message_post(
body=Markup(
'Auto-created from <b>CAPA %s</b> after effectiveness '
'verification failed. Original NCR was %s.'
) % (rec.name, src.name),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
def action_verify_effectiveness(self):
"""Schedule a follow-up activity on the originating NCR.
Used from the CAPA form to remind the QA team to come back and
confirm the corrective action actually held.
"""
from datetime import timedelta
self.ensure_one()
if not self.ncr_id:
raise UserError(_(
'CAPA %s has no source NCR — verification activity '
'cannot be scheduled.'
) % self.display_name)
deadline = fields.Date.context_today(self) + timedelta(days=30)
self.ncr_id.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=deadline,
summary=_('Verify CAPA %s effectiveness') % self.name,
note=_(
'Confirm that the corrective action from CAPA %s is still '
'holding. If issue recurs, mark CAPA as Not Effective '
'(auto-spawns a follow-up NCR).'
) % self.name,
user_id=(self.owner_id.id if self.owner_id else self.env.user.id),
)
self.message_post(
body=Markup(
'Verification activity scheduled on source <b>NCR %s</b> '
'(due %s).'
) % (self.ncr_id.name, deadline),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
return True

View File

@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase C — trigger-based quality points.
#
# Replaces the old "customer.x_fc_requires_qc + customer.x_fc_qc_template_id"
# direct binding. Now an admin defines fp.quality.point rules with filters
# (partner / part / coating / step kind) and a trigger event; matching
# records spawn fusion.plating.quality.check rows from the chosen template.
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
TRIGGER_TYPES = [
('manual', 'Manual'),
('so_confirmed', 'Sale Order Confirmed'),
('receiving_done', 'Receiving Closed'),
('job_confirmed', 'Job Confirmed'),
('job_step_done', 'Job Step Finished'),
('job_done', 'Job Completed'),
]
STEP_KINDS = [
('wet', 'Wet Process'),
('bake', 'Bake / Cure'),
('inspect', 'Inspection'),
('mask', 'Masking'),
('post', 'Post-Treatment'),
('other', 'Other'),
]
class FpQualityPoint(models.Model):
_name = 'fp.quality.point'
_description = 'Fusion Plating — Quality Point'
_inherit = ['mail.thread']
_order = 'sequence, name'
name = fields.Char(required=True, translate=True, tracking=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True, tracking=True)
description = fields.Text(translate=True)
trigger_type = fields.Selection(
TRIGGER_TYPES, string='Trigger', required=True,
default='job_confirmed', tracking=True,
help='When this point fires. "manual" never auto-fires.',
)
# ----- Filters (all optional; empty == match all) -----
partner_ids = fields.Many2many(
'res.partner', 'fp_quality_point_partner_rel',
'point_id', 'partner_id', string='Customers',
)
part_catalog_ids = fields.Many2many(
'fp.part.catalog', 'fp_quality_point_part_rel',
'point_id', 'part_id', string='Parts',
)
coating_config_ids = fields.Many2many(
'fp.coating.config', 'fp_quality_point_coating_rel',
'point_id', 'coating_id', string='Coatings',
)
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Checklist Template',
required=True, ondelete='restrict',
)
assignee_user_id = fields.Many2one(
'res.users', string='Default Inspector',
help='If set, the auto-spawned QC check is pre-assigned here.',
)
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_quality_point_tag_rel',
'point_id', 'tag_id', string='Tags',
)
# Stats
spawn_count = fields.Integer(
string='Checks Spawned', compute='_compute_spawn_count',
)
@api.depends('template_id')
def _compute_spawn_count(self):
Check = self.env['fusion.plating.quality.check']
for rec in self:
if not rec.template_id:
rec.spawn_count = 0
continue
rec.spawn_count = Check.search_count([
('template_id', '=', rec.template_id.id),
])
# ------------------------------------------------------------------
# Matching + spawning
# ------------------------------------------------------------------
def _matches(self, partner=None, part=None, coating=None, step=None):
"""Return True if this point's filters all pass against the supplied
context. Empty filter == match anything.
"""
self.ensure_one()
if self.partner_ids and (not partner or partner not in self.partner_ids):
return False
if self.part_catalog_ids and (
not part or part not in self.part_catalog_ids):
return False
if self.coating_config_ids and (
not coating or coating not in self.coating_config_ids):
return False
if self.step_kind and step and getattr(step, 'kind', None) \
and step.kind != self.step_kind:
return False
return True
@api.model
def _find_matching(self, trigger, partner=None, part=None, coating=None,
step=None):
"""Return active points whose trigger + filters match the context."""
candidates = self.search([
('active', '=', True),
('trigger_type', '=', trigger),
])
return candidates.filtered(lambda p: p._matches(
partner=partner, part=part, coating=coating, step=step,
))
def _spawn_check_for(self, source, partner=None, job=None, step=None):
"""Create a fusion.plating.quality.check from this point's template.
Idempotent per (point, source): if a check already exists with the
same template_id and the same job/step binding, no new one is
created (returns the existing one).
"""
self.ensure_one()
Check = self.env['fusion.plating.quality.check']
if not self.template_id:
_logger.warning(
'fp.quality.point %s: no template_id set, skipping spawn.',
self.name,
)
return False
domain = [('template_id', '=', self.template_id.id)]
if job:
domain.append(('job_id', '=', job.id))
if step and 'step_id' in Check._fields:
domain.append(('step_id', '=', step.id))
existing = Check.search(domain, limit=1)
if existing:
return existing
vals = {
'template_id': self.template_id.id,
}
# Best-effort field bindings — survives schema variations.
if 'partner_id' in Check._fields and partner:
vals['partner_id'] = partner.id
if 'job_id' in Check._fields and job:
vals['job_id'] = job.id
if 'step_id' in Check._fields and step:
vals['step_id'] = step.id
if 'state' in Check._fields:
vals['state'] = 'pending'
if 'inspector_id' in Check._fields and self.assignee_user_id:
vals['inspector_id'] = self.assignee_user_id.id
if 'team_id' in Check._fields and self.team_id:
vals['team_id'] = self.team_id.id
if 'tag_ids' in Check._fields and self.tag_ids:
vals['tag_ids'] = [(6, 0, self.tag_ids.ids)]
try:
return Check.create(vals)
except Exception as e:
_logger.warning(
'fp.quality.point %s: spawn failed for %s%s',
self.name, source.display_name if source else '?', e,
)
return False
def action_spawn_manual(self):
"""Manual fire — present from the form view button. No source ctx."""
for rec in self:
rec._spawn_check_for(source=rec)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Quality Point fired'),
'message': _('Spawned %s check(s).') % len(self),
'type': 'success',
},
}

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase C — trigger-hook overrides on receiving / job / step / SO.
# Each hook walks fp.quality.point with the matching trigger_type and
# spawns a quality check for every match. Best-effort: failures are
# logged but never block the underlying state transition.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# ============================================================ RECEIVING ===
class FpReceivingPointHook(models.Model):
_inherit = 'fp.receiving'
def write(self, vals):
"""When state flips to closed, fire receiving_done points."""
prev_states = {rec.id: rec.state for rec in self}
result = super().write(vals)
if 'state' not in vals or vals.get('state') != 'closed':
return result
Point = self.env['fp.quality.point']
for rec in self:
if prev_states.get(rec.id) == 'closed':
continue
partner = rec.partner_id
points = Point._find_matching(
trigger='receiving_done', partner=partner,
)
for point in points:
point._spawn_check_for(
source=rec, partner=partner,
)
return result
# ================================================================== SO ===
class SaleOrderPointHook(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
result = super().action_confirm()
Point = self.env['fp.quality.point']
for so in self:
partner = so.partner_id
# Walk lines for part / coating context.
parts = so.order_line.mapped('x_fc_part_catalog_id') \
if 'x_fc_part_catalog_id' in so.order_line._fields else False
coatings = so.order_line.mapped('x_fc_coating_config_id') \
if 'x_fc_coating_config_id' in so.order_line._fields else False
points = Point._find_matching(
trigger='so_confirmed', partner=partner,
)
for point in points:
# Filter by part / coating intersection if the point cares.
if point.part_catalog_ids and parts and \
not (point.part_catalog_ids & parts):
continue
if point.coating_config_ids and coatings and \
not (point.coating_config_ids & coatings):
continue
point._spawn_check_for(source=so, partner=partner)
return result
# ================================================================ JOB ===
class FpJobPointHook(models.Model):
_inherit = 'fp.job'
def action_confirm(self):
result = super().action_confirm()
Point = self.env['fp.quality.point']
for job in self:
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
points = Point._find_matching(
trigger='job_confirmed', partner=partner,
part=part or None, coating=coating or None,
)
for point in points:
point._spawn_check_for(
source=job, partner=partner, job=job,
)
return result
def button_mark_done(self):
result = super().button_mark_done()
Point = self.env['fp.quality.point']
for job in self:
if job.state != 'done':
continue
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
points = Point._find_matching(
trigger='job_done', partner=partner,
part=part or None, coating=coating or None,
)
for point in points:
point._spawn_check_for(
source=job, partner=partner, job=job,
)
return result
# =========================================================== JOB STEP ===
class FpJobStepPointHook(models.Model):
_inherit = 'fp.job.step'
def button_finish(self):
result = super().button_finish()
Point = self.env['fp.quality.point']
for step in self:
if step.state != 'done':
continue
job = step.job_id
partner = job.partner_id if job else False
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
points = Point._find_matching(
trigger='job_step_done', partner=partner,
part=part or None, coating=coating or None, step=step,
)
for point in points:
point._spawn_check_for(
source=step, partner=partner, job=job, step=step,
)
return result

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality reason (root-cause classification library).
from odoo import fields, models
class FpQualityReason(models.Model):
_name = 'fp.quality.reason'
_description = 'Fusion Plating — Quality Reason'
_order = 'category, name'
name = fields.Char(required=True, translate=True)
description = fields.Text(translate=True)
category = fields.Selection(
[
('process', 'Process'),
('supplier', 'Supplier / Material Inbound'),
('equipment', 'Equipment / Calibration'),
('human', 'Human Error / Training'),
('material', 'Material Defect'),
('other', 'Other'),
],
string='Category',
default='process',
required=True,
)
active = fields.Boolean(default=True)
_sql_constraints = [
('name_category_uniq', 'unique(name, category)',
'A reason with that name + category combination already exists.'),
]

View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — smart-button counts on fp.job, sale.order, res.partner.
#
# Each parent record gets badge counts for: Holds, Checks, NCRs, CAPAs,
# RMAs. Counts always render (zero is acceptable). Action methods open
# the relevant kanban filtered to that record.
from odoo import _, api, fields, models
# ============================================================ FP.JOB ===
class FpJobQualitySmart(models.Model):
_inherit = 'fp.job'
fp_qc_hold_count = fields.Integer(
compute='_compute_fp_quality_counts', string='Holds',
)
fp_qc_check_count = fields.Integer(
compute='_compute_fp_quality_counts', string='Checks',
)
fp_qc_ncr_count = fields.Integer(
compute='_compute_fp_quality_counts', string='NCRs',
)
fp_qc_capa_count = fields.Integer(
compute='_compute_fp_quality_counts', string='CAPAs',
)
fp_qc_rma_count = fields.Integer(
compute='_compute_fp_quality_counts', string='RMAs',
)
def _compute_fp_quality_counts(self):
Hold = self.env['fusion.plating.quality.hold']
Check = self.env['fusion.plating.quality.check']
Ncr = self.env['fusion.plating.ncr']
Capa = self.env['fusion.plating.capa']
Rma = self.env['fusion.plating.rma']
for job in self:
job.fp_qc_hold_count = Hold.search_count(
[('job_id', '=', job.id)])
job.fp_qc_check_count = Check.search_count(
[('job_id', '=', job.id)])
ncr_ids = []
capa_ids = []
rma_ids = []
if job.sale_order_id:
rma_ids = Rma.search(
[('sale_order_id', '=', job.sale_order_id.id)]).ids
if rma_ids:
ncr_ids = Ncr.search([('rma_id', 'in', rma_ids)]).ids
if job.partner_id:
ncr_ids = list(set(ncr_ids + Ncr.search([
('customer_partner_id', '=', job.partner_id.id),
]).ids))
if ncr_ids:
capa_ids = Capa.search([('ncr_id', 'in', ncr_ids)]).ids
job.fp_qc_ncr_count = len(ncr_ids)
job.fp_qc_capa_count = len(capa_ids)
job.fp_qc_rma_count = len(rma_ids)
def action_view_fp_holds(self):
self.ensure_one()
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('job_id', '=', self.id)],
'context': {'default_job_id': self.id},
}
def action_view_fp_checks(self):
self.ensure_one()
return {
'name': _('Quality Checks'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.check',
'view_mode': 'list,form',
'domain': [('job_id', '=', self.id)],
'context': {'default_job_id': self.id},
}
def action_view_fp_ncrs(self):
self.ensure_one()
domain = [('customer_partner_id', '=', self.partner_id.id)]
return {
'name': _('NCRs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.ncr',
'view_mode': 'kanban,list,form',
'domain': domain,
'context': {'default_customer_partner_id': self.partner_id.id},
}
def action_view_fp_capas(self):
self.ensure_one()
Ncr = self.env['fusion.plating.ncr']
ncr_ids = Ncr.search([
('customer_partner_id', '=', self.partner_id.id),
]).ids
return {
'name': _('CAPAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'list,form',
'domain': [('ncr_id', 'in', ncr_ids)],
}
def action_view_fp_rmas(self):
self.ensure_one()
domain = [('partner_id', '=', self.partner_id.id)]
if self.sale_order_id:
domain = ['|', ('sale_order_id', '=', self.sale_order_id.id),
('partner_id', '=', self.partner_id.id)]
return {
'name': _('RMAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.rma',
'view_mode': 'kanban,list,form',
'domain': domain,
'context': {'default_partner_id': self.partner_id.id},
}
# ============================================================== SO ===
class SaleOrderQualitySmart(models.Model):
_inherit = 'sale.order'
fp_qc_hold_count = fields.Integer(
compute='_compute_fp_qc_counts', string='Holds',
)
fp_qc_check_count = fields.Integer(
compute='_compute_fp_qc_counts', string='Checks',
)
fp_qc_ncr_count_so = fields.Integer(
compute='_compute_fp_qc_counts', string='NCRs',
)
fp_qc_capa_count = fields.Integer(
compute='_compute_fp_qc_counts', string='CAPAs',
)
fp_qc_rma_count = fields.Integer(
compute='_compute_fp_qc_counts', string='RMAs',
)
def _compute_fp_qc_counts(self):
Hold = self.env['fusion.plating.quality.hold']
Check = self.env['fusion.plating.quality.check']
Ncr = self.env['fusion.plating.ncr']
Capa = self.env['fusion.plating.capa']
Rma = self.env['fusion.plating.rma']
Job = self.env['fp.job']
for so in self:
job_ids = Job.search([('sale_order_id', '=', so.id)]).ids
so.fp_qc_hold_count = Hold.search_count(
[('job_id', 'in', job_ids)]) if job_ids else 0
so.fp_qc_check_count = Check.search_count(
[('job_id', 'in', job_ids)]) if job_ids else 0
rma_ids = Rma.search([('sale_order_id', '=', so.id)]).ids
so.fp_qc_rma_count = len(rma_ids)
ncr_ids = []
if rma_ids:
ncr_ids = Ncr.search([('rma_id', 'in', rma_ids)]).ids
if so.partner_id:
ncr_ids = list(set(ncr_ids + Ncr.search([
('customer_partner_id', '=', so.partner_id.id),
]).ids))
so.fp_qc_ncr_count_so = len(ncr_ids)
so.fp_qc_capa_count = Capa.search_count(
[('ncr_id', 'in', ncr_ids)]) if ncr_ids else 0
def action_view_fp_holds(self):
self.ensure_one()
Job = self.env['fp.job']
job_ids = Job.search([('sale_order_id', '=', self.id)]).ids
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('job_id', 'in', job_ids)],
}
def action_view_fp_checks(self):
self.ensure_one()
Job = self.env['fp.job']
job_ids = Job.search([('sale_order_id', '=', self.id)]).ids
return {
'name': _('Quality Checks'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.check',
'view_mode': 'list,form',
'domain': [('job_id', 'in', job_ids)],
}
def action_view_fp_ncrs_so(self):
self.ensure_one()
return {
'name': _('NCRs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.ncr',
'view_mode': 'kanban,list,form',
'domain': [('customer_partner_id', '=', self.partner_id.id)],
}
def action_view_fp_capas(self):
self.ensure_one()
Ncr = self.env['fusion.plating.ncr']
ncr_ids = Ncr.search([
('customer_partner_id', '=', self.partner_id.id),
]).ids
return {
'name': _('CAPAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'list,form',
'domain': [('ncr_id', 'in', ncr_ids)],
}
def action_view_fp_rmas(self):
self.ensure_one()
return {
'name': _('RMAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.rma',
'view_mode': 'kanban,list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {
'default_partner_id': self.partner_id.id,
'default_sale_order_id': self.id,
},
}
# ====================================================== RES.PARTNER ===
class ResPartnerQualitySmart(models.Model):
_inherit = 'res.partner'
fp_qc_quality_history_count = fields.Integer(
compute='_compute_fp_qc_history_count', string='Quality History',
)
def _compute_fp_qc_history_count(self):
Ncr = self.env['fusion.plating.ncr']
Rma = self.env['fusion.plating.rma']
for partner in self:
partner.fp_qc_quality_history_count = (
Ncr.search_count([('customer_partner_id', '=', partner.id)])
+ Rma.search_count([('partner_id', '=', partner.id)])
)
def action_view_fp_quality_history(self):
self.ensure_one()
return {
'name': _('Quality History — %s') % self.display_name,
'type': 'ir.actions.client',
'tag': 'fp_quality_dashboard',
'context': {'default_partner_id': self.id},
}

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality tag.
#
# Cross-cutting tag library reused by NCR, CAPA, Hold, Check, RMA.
from odoo import fields, models
class FpQualityTag(models.Model):
_name = 'fp.quality.tag'
_description = 'Fusion Plating — Quality Tag'
_order = 'name'
name = fields.Char(required=True, translate=True)
color = fields.Integer(string='Colour Index', default=0)
description = fields.Char(string='Description', translate=True)
active = fields.Boolean(default=True)
_sql_constraints = [
('name_uniq', 'unique(name)', 'A tag with that name already exists.'),
]

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality team.
#
# Dedicated team model rather than reusing res.groups, per Sub 12 locked
# decision: teams need their own kanban grouping + per-team escalation
# chains.
from odoo import fields, models
class FpQualityTeam(models.Model):
_name = 'fp.quality.team'
_description = 'Fusion Plating — Quality Team'
_order = 'sequence, name'
_inherit = ['mail.thread']
name = fields.Char(required=True, tracking=True, translate=True)
sequence = fields.Integer(default=10)
color = fields.Integer(string='Colour Index', default=0)
description = fields.Text(translate=True)
lead_user_id = fields.Many2one(
'res.users', string='Team Lead',
tracking=True,
help='Owns escalations and weekly review of open NCRs/RMAs.',
)
member_ids = fields.Many2many(
'res.users', 'fp_quality_team_user_rel', 'team_id', 'user_id',
string='Members',
)
escalation_user_id = fields.Many2one(
'res.users', string='Escalation Manager',
tracking=True,
help='Notified when team owns a record that misses its deadline.',
)
active = fields.Boolean(default=True)
_sql_constraints = [
('name_uniq', 'unique(name)', 'A team with that name already exists.'),
]

View File

@@ -0,0 +1,775 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# fp.rma — Return Material Authorisation.
#
# Sub 12 Phase A. Internal-only RMA workflow that ties customer returns to
# the existing NCR / CAPA / Hold stack. Portal submission is deferred to a
# future sub-project; for now an internal user opens the RMA on behalf of
# the customer.
#
# Lifecycle:
# draft -> authorised -> shipped_to_us -> received -> triaged ->
# resolving -> resolved -> closed
# \
# -> cancelled (manager only, any state)
#
# Auto-spawn rules at the `received` transition (driven by fp.receiving):
# - if auto_spawn_ncr (default True) -> create fusion.plating.ncr
# - if auto_spawn_hold (default True) -> create fusion.plating.quality.hold
# A manager can flip either toggle off before saving the RMA.
import base64
import io
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpRma(models.Model):
_name = 'fusion.plating.rma'
_description = 'Fusion Plating — Return Material Authorisation'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self._default_name(),
tracking=True,
)
state = fields.Selection(
[
('draft', 'Draft'),
('authorised', 'Authorised'),
('shipped_to_us', 'Customer Shipped'),
('received', 'Received at Shop'),
('triaged', 'Triaged'),
('resolving', 'Resolving'),
('resolved', 'Resolved'),
('closed', 'Closed'),
('cancelled', 'Cancelled'),
],
string='Status',
default='draft',
required=True,
tracking=True,
index=True,
)
# ------------------------------------------------------------------
# Customer + originating order
# ------------------------------------------------------------------
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, tracking=True,
domain=[('customer_rank', '>', 0)],
)
sale_order_id = fields.Many2one(
'sale.order', string='Original Sale Order',
required=True, tracking=True,
domain="[('partner_id', '=', partner_id)]",
help='The order being returned. Required so cert/part/coating '
'context follows the return through triage and resolution.',
)
sale_order_line_ids = fields.Many2many(
'sale.order.line', 'fp_rma_sol_rel', 'rma_id', 'sol_id',
string='Returned Lines',
domain="[('order_id', '=', sale_order_id)]",
help='Subset of the original SO lines that the customer is '
'returning. Used to pull part/cert context.',
)
original_job_ids = fields.Many2many(
'fp.job', string='Original Jobs',
compute='_compute_original_job_ids', store=False,
help='Jobs derived from the SO. Navigation-only.',
)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
readonly=True,
)
# ------------------------------------------------------------------
# Why and how bad
# ------------------------------------------------------------------
trigger_source = fields.Selection(
[
('customer_complaint', 'Customer Complaint'),
('qc_fail_post_ship', 'Post-Shipment QC Failure'),
('inspection_post_delivery', 'Customer Inspection Post-Delivery'),
('other', 'Other'),
],
string='Trigger',
default='customer_complaint',
required=True,
tracking=True,
)
severity = fields.Selection(
[
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
('critical', 'Critical'),
],
string='Severity',
default='medium',
required=True,
tracking=True,
)
complaint_description = fields.Html(
string='Customer Complaint',
help='What the customer reported.',
)
triage_findings = fields.Html(
string='Triage Findings',
help='What we found on inspection after receiving the parts.',
)
# ------------------------------------------------------------------
# Resolution
# ------------------------------------------------------------------
resolution_type = fields.Selection(
[
('replace', 'Replace'),
('rework', 'Rework'),
('refund', 'Refund'),
('scrap', 'Scrap'),
],
string='Resolution',
tracking=True,
)
resolution_notes = fields.Html(string='Resolution Notes')
replacement_job_id = fields.Many2one(
'fp.job', string='Replacement Job',
ondelete='set null',
help='New plating job created for replace/rework resolutions.',
)
refund_invoice_id = fields.Many2one(
'account.move', string='Refund / Credit Note',
ondelete='set null',
domain="[('move_type', '=', 'out_refund')]",
)
# ------------------------------------------------------------------
# Inbound logistics
# ------------------------------------------------------------------
inbound_receiving_id = fields.Many2one(
'fp.receiving', string='Inbound Receiving',
ondelete='set null',
help='Receiving record auto-created when the carrier delivers '
'the returned parts.',
)
inbound_picking_id = fields.Many2one(
'stock.picking', string='Inbound Picking',
ondelete='set null',
)
qty_returned = fields.Integer(
string='Qty Returned', tracking=True,
help='Total units the customer is returning per the authorisation.',
)
qty_received = fields.Integer(
string='Qty Received', tracking=True,
help='Counted on receipt at our dock.',
)
customer_tracking = fields.Char(
string='Customer Tracking #',
help='Outbound tracking from the customer back to us.',
)
our_tracking = fields.Char(
string='Our Tracking #',
help='Tracking number for the replacement / return shipment '
'from our shop.',
)
carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
# ------------------------------------------------------------------
# QR + auto-spawn toggles
# ------------------------------------------------------------------
qr_code = fields.Binary(
string='QR Code', compute='_compute_qr_code', store=False,
help='Encodes /fp/rma/<id> for the customer authorisation PDF.',
)
auto_spawn_ncr = fields.Boolean(
string='Auto-create NCR on Receipt',
default=True, tracking=True,
help='When the carrier delivers the returned parts and an '
'fp.receiving is created against this RMA, an NCR is '
'spawned automatically. Manager can toggle off — the '
'change is tracked on the chatter for audit.',
)
auto_spawn_hold = fields.Boolean(
string='Auto-place Hold on Receipt',
default=True, tracking=True,
help='Same trigger as auto_spawn_ncr but creates an '
'fusion.plating.quality.hold for the returned qty.',
)
# ------------------------------------------------------------------
# Linked records (cross-domain)
# ------------------------------------------------------------------
linked_ncr_ids = fields.One2many(
'fusion.plating.ncr', 'rma_id', string='NCRs',
)
linked_hold_ids = fields.One2many(
'fusion.plating.quality.hold', 'rma_id', string='Holds',
)
linked_capa_ids = fields.Many2many(
'fusion.plating.capa', string='CAPAs',
compute='_compute_linked_capa_ids', store=False,
)
ncr_count = fields.Integer(compute='_compute_link_counts')
hold_count = fields.Integer(compute='_compute_link_counts')
capa_count = fields.Integer(compute='_compute_link_counts')
active = fields.Boolean(default=True)
# ------------------------------------------------------------------
# Phase B placeholders (categorisation) — added now so views won't
# break when Phase B lands. Kept as M2O/M2M to models added later.
# ------------------------------------------------------------------
# tag_ids, reason_id, team_id, stage_id are added in Phase B.
# ------------------------------------------------------------------
# Defaults / create / name
# ------------------------------------------------------------------
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma')
return seq or '/'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
# ------------------------------------------------------------------
# Computes
# ------------------------------------------------------------------
@api.depends('sale_order_id', 'sale_order_line_ids')
def _compute_original_job_ids(self):
Job = self.env['fp.job']
for rec in self:
if not rec.sale_order_id:
rec.original_job_ids = False
continue
rec.original_job_ids = Job.search([
('sale_order_id', '=', rec.sale_order_id.id),
])
@api.depends('linked_ncr_ids.capa_ids')
def _compute_linked_capa_ids(self):
for rec in self:
rec.linked_capa_ids = rec.linked_ncr_ids.mapped('capa_ids')
@api.depends(
'linked_ncr_ids', 'linked_hold_ids', 'linked_capa_ids',
)
def _compute_link_counts(self):
for rec in self:
rec.ncr_count = len(rec.linked_ncr_ids)
rec.hold_count = len(rec.linked_hold_ids)
rec.capa_count = len(rec.linked_capa_ids)
@api.depends('name')
def _compute_qr_code(self):
try:
import qrcode
except ImportError:
for rec in self:
rec.qr_code = False
return
base = self.env['ir.config_parameter'].sudo().get_param(
'web.base.url', '',
)
for rec in self:
if not rec.id:
rec.qr_code = False
continue
url = f'{base}/fp/rma/{rec.id}'
buf = io.BytesIO()
img = qrcode.make(url)
img.save(buf, format='PNG')
rec.qr_code = base64.b64encode(buf.getvalue())
# ------------------------------------------------------------------
# Lifecycle actions
# ------------------------------------------------------------------
def action_authorise(self):
for rec in self:
if not rec.sale_order_line_ids:
raise UserError(_(
'Select at least one returned line on RMA %s before '
'authorising.'
) % rec.display_name)
if rec.qty_returned <= 0:
raise UserError(_(
'RMA %s needs a returned quantity > 0 before '
'authorising.'
) % rec.display_name)
rec.state = 'authorised'
rec._post_state_message('Authorised')
rec._fire_rma_notification('rma_authorised')
def action_mark_shipped_to_us(self):
for rec in self:
if rec.state != 'authorised':
raise UserError(_(
'RMA %s must be Authorised before marking it as '
'shipped by the customer.'
) % rec.display_name)
rec.state = 'shipped_to_us'
rec._post_state_message('Customer Shipped')
def action_mark_received(self):
"""Manual fallback when an inbound fp.receiving was not auto-linked."""
for rec in self:
if rec.state not in ('authorised', 'shipped_to_us'):
raise UserError(_(
'RMA %s must be Authorised or Shipped before being '
'marked Received.'
) % rec.display_name)
rec._enter_received_state(receiving=False)
def _enter_received_state(self, receiving=None):
"""Common receive-side hook. Called either:
- from action_mark_received (manual)
- from fp.receiving.create override when rma_id was set
Flips state to `received` and (optionally) spawns NCR + Hold per
the auto_spawn_* toggles. Idempotent — re-entry on an already-
received RMA is a no-op (no double-spawn on ORM retry / split
deliveries).
"""
for rec in self:
if rec.state == 'received':
continue
rec.state = 'received'
spawned = []
if rec.auto_spawn_ncr:
ncr = rec._spawn_ncr()
if ncr:
spawned.append(_('NCR %s') % ncr.name)
if rec.auto_spawn_hold:
hold = rec._spawn_hold()
if hold:
spawned.append(_('Hold %s') % hold.name)
label = 'Received'
if spawned:
label += ' — auto-spawned ' + ', '.join(spawned)
rec._post_state_message(label)
# Customer notification: parts arrived at the shop.
rec._fire_rma_notification('rma_received')
def _spawn_ncr(self):
self.ensure_one()
Ncr = self.env['fusion.plating.ncr']
# Idempotency: if an NCR for this RMA already exists, return it.
existing = Ncr.search([('rma_id', '=', self.id)], limit=1)
if existing:
return existing
partner = self.partner_id
# Pull a facility — prefer the partner's company facility, fall
# back to the first active facility.
Facility = self.env['fusion.plating.facility']
facility = (
Facility.search([('company_id', '=', self.company_id.id)], limit=1)
or Facility.search([], limit=1)
)
if not facility:
_logger.warning(
'RMA %s: no fusion.plating.facility found, NCR spawn '
'skipped', self.name,
)
return False
part_ref = ', '.join(
self.sale_order_line_ids.mapped('product_id.default_code') or []
) or self.sale_order_line_ids[:1].product_id.display_name or '/'
complaint = self.complaint_description or ''
body = (
Markup('<p><strong>RMA %s — auto-created from customer return.</strong></p>') % self.name
+ Markup(complaint or '<p>(no description)</p>')
)
ncr = Ncr.create({
'facility_id': facility.id,
'source': 'customer',
'severity': self.severity or 'medium',
'part_ref': part_ref[:64],
'quantity_affected': self.qty_received or self.qty_returned or 0,
'description': body,
'customer_partner_id': partner.id,
'rma_id': self.id,
})
return ncr
def _spawn_hold(self):
self.ensure_one()
Hold = self.env['fusion.plating.quality.hold']
# Idempotency: one auto-Hold per RMA.
existing = Hold.search([('rma_id', '=', self.id)], limit=1)
if existing:
return existing
Facility = self.env['fusion.plating.facility']
facility = (
Facility.search([('company_id', '=', self.company_id.id)], limit=1)
or Facility.search([], limit=1)
)
part_ref = (
self.sale_order_line_ids[:1].product_id.default_code
or self.sale_order_line_ids[:1].product_id.display_name
or self.name
)
hold = Hold.create({
'part_ref': part_ref[:64],
'qty_on_hold': self.qty_received or self.qty_returned or 0,
'qty_original': self.qty_returned or 0,
'hold_reason': 'customer_complaint',
'description': (
f'Auto-created from RMA {self.name}. '
f'Returned parts on hold pending triage.'
),
'facility_id': facility.id if facility else False,
'rma_id': self.id,
})
return hold
def action_triage_complete(self):
for rec in self:
if rec.state != 'received':
raise UserError(_(
'RMA %s must be Received before triage can be '
'completed.'
) % rec.display_name)
if not rec.resolution_type:
raise UserError(_(
'Set a Resolution (replace / rework / refund / scrap) '
'on RMA %s before completing triage.'
) % rec.display_name)
rec.state = 'triaged'
rec._post_state_message('Triaged')
def action_start_resolving(self):
for rec in self:
if rec.state != 'triaged':
raise UserError(_(
'RMA %s must be Triaged before resolution work can '
'start.'
) % rec.display_name)
rec.state = 'resolving'
rec._post_state_message('Resolving')
def action_resolve(self):
"""Trigger resolution-specific side-effects then flip to resolved.
For replace/rework/scrap: spawn the side-effect, flip state.
For refund: open the credit-note wizard. State stays at
`resolving` until the wizard runs and the accountant links the
credit note via action_link_refund (or the AccountMove write
hook auto-links by invoice_origin).
"""
for rec in self:
if rec.state not in ('triaged', 'resolving'):
raise UserError(_(
'RMA %s must be Triaged or Resolving before being '
'marked Resolved.'
) % rec.display_name)
# Refund path needs a wizard return — handle separately.
refund_recs = self.filtered(lambda r: r.resolution_type == 'refund')
if len(refund_recs) > 1:
raise UserError(_(
'Resolve refund RMAs one at a time so the credit-note '
'wizard can be filled in.'
))
if refund_recs:
return refund_recs._resolve_refund()
# Non-refund paths: fire side-effect then flip state.
for rec in self:
handler = {
'replace': rec._resolve_replace,
'rework': rec._resolve_rework,
'scrap': rec._resolve_scrap,
}.get(rec.resolution_type)
if not handler:
raise UserError(_(
'No handler for resolution type "%s" on RMA %s.'
) % (rec.resolution_type, rec.display_name))
handler()
rec.state = 'resolved'
rec._post_state_message(
f'Resolved ({rec.resolution_type})',
)
rec._fire_rma_notification('rma_resolved')
def _resolve_replace(self):
return self._spawn_replacement_job(reason='replace')
def _resolve_rework(self):
return self._spawn_replacement_job(reason='rework')
def _spawn_replacement_job(self, reason='replace'):
self.ensure_one()
Job = self.env['fp.job']
if self.replacement_job_id:
return self.replacement_job_id
first = self.original_job_ids[:1]
if not first:
_logger.info(
'RMA %s: no originating fp.job to clone; creating bare '
'replacement job.', self.name,
)
new_job = Job.create({
'partner_id': self.partner_id.id,
'sale_order_id': self.sale_order_id.id,
'origin': self.sale_order_id.name or self.name,
'qty': self.qty_returned or 1,
})
else:
new_job = first.copy({
'origin': f'{self.name} (RMA {reason})',
'qty': self.qty_returned or first.qty,
'state': 'draft',
})
# Drop cloned-from-source steps and regenerate from the
# recipe so the rework starts fresh (every step pending,
# no inherited timelogs / actuals / completion flags).
if hasattr(new_job, 'step_ids') and new_job.step_ids:
new_job.step_ids.unlink()
if hasattr(new_job, '_generate_steps_from_recipe') \
and new_job.recipe_id:
new_job._generate_steps_from_recipe()
self.replacement_job_id = new_job.id
# Auto-confirm so the portal mirror, racking inspection and
# 'job_confirmed' notification all fire — same as a normal job.
if hasattr(new_job, 'action_confirm') and new_job.state == 'draft':
try:
new_job.action_confirm()
except Exception as e:
_logger.warning(
'RMA %s: replacement job %s auto-confirm failed (%s); '
'leaving in draft.', self.name, new_job.name, e,
)
return new_job
def _resolve_refund(self):
self.ensure_one()
if self.refund_invoice_id:
return self.refund_invoice_id
# Open the standard refund wizard pre-filled to the original SO.
# We don't auto-confirm — accountant verifies amounts first.
invoices = self.env['account.move'].search([
('invoice_origin', '=', self.sale_order_id.name),
('move_type', '=', 'out_invoice'),
], limit=1)
if not invoices:
raise UserError(_(
'RMA %s: no posted invoice found for SO %s — cannot '
'create a credit note automatically. Issue refund '
'manually.'
) % (self.display_name, self.sale_order_id.name))
return {
'name': _('Credit Note'),
'type': 'ir.actions.act_window',
'res_model': 'account.move.reversal',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_ids': invoices.ids,
'default_reason': f'RMA {self.name}',
'default_journal_id': invoices.journal_id.id,
},
}
def _resolve_scrap(self):
# NB: spec calls for an fp.job.consumption row with source='rma_scrap'
# but fp.job.consumption requires product_id and there's no curated
# "scrap" product yet. Phase E will surface scrap via the Monthly
# Quality Summary report instead. For now, just narrate.
self.ensure_one()
qty = self.qty_received or self.qty_returned or 0
self.message_post(
body=Markup(
'Resolution: <b>scrap</b>. %s units written off via RMA %s.'
) % (qty, self.name),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
for ncr in self.linked_ncr_ids:
ncr.message_post(
body=Markup('Resolution: <b>scrap</b> via RMA %s.') % self.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
def action_close(self):
for rec in self:
if rec.state != 'resolved':
raise UserError(_(
'RMA %s must be Resolved before it can be closed.'
) % rec.display_name)
open_ncrs = rec.linked_ncr_ids.filtered(
lambda n: n.state != 'closed'
)
if open_ncrs:
raise UserError(_(
'RMA %s has open NCRs (%s). Close the NCRs first.'
) % (
rec.display_name,
', '.join(open_ncrs.mapped('name')),
))
open_holds = rec.linked_hold_ids.filtered(
lambda h: h.state in ('on_hold', 'under_review')
)
if open_holds:
raise UserError(_(
'RMA %s still has active Holds (%s). Release, scrap, '
'or send to rework before closing the RMA.'
) % (
rec.display_name,
', '.join(open_holds.mapped('name')),
))
rec.state = 'closed'
rec._post_state_message('Closed')
def _fire_rma_notification(self, event):
"""Best-effort notification dispatch via fp.notification.template.
Silently skips if fusion_plating_notifications is absent or no
template is configured for this trigger event. Failures never
block the RMA state machine.
"""
if 'fp.notification.template' not in self.env:
return
Tpl = self.env['fp.notification.template'].sudo()
for rec in self:
partner = rec.partner_id
if not partner:
continue
try:
Tpl._dispatch(
event, rec, partner, sale_order=rec.sale_order_id,
)
except Exception as e:
_logger.warning(
'RMA %s: notification %s failed: %s',
rec.name, event, e,
)
def action_cancel(self):
is_manager = self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
if not is_manager:
raise UserError(_(
'Only Plating Managers can cancel an RMA.'
))
for rec in self:
if rec.state == 'closed':
raise UserError(_(
'RMA %s is already closed and cannot be cancelled.'
) % rec.display_name)
rec.state = 'cancelled'
rec._post_state_message('Cancelled')
# ------------------------------------------------------------------
# Smart-button actions
# ------------------------------------------------------------------
def action_view_ncrs(self):
self.ensure_one()
return {
'name': _('NCRs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.ncr',
'view_mode': 'list,form',
'domain': [('rma_id', '=', self.id)],
'context': {
'default_rma_id': self.id,
'default_customer_partner_id': self.partner_id.id,
'default_source': 'customer',
},
}
def action_view_holds(self):
self.ensure_one()
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('rma_id', '=', self.id)],
'context': {'default_rma_id': self.id},
}
def action_view_capas(self):
self.ensure_one()
return {
'name': _('CAPAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'list,form',
'domain': [('id', 'in', self.linked_capa_ids.ids)],
}
def action_view_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.sale_order_id.id,
}
def action_view_replacement_job(self):
self.ensure_one()
if not self.replacement_job_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job',
'view_mode': 'form',
'res_id': self.replacement_job_id.id,
}
def action_view_refund(self):
self.ensure_one()
if not self.refund_invoice_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'form',
'res_id': self.refund_invoice_id.id,
}
def action_view_inbound_receiving(self):
self.ensure_one()
if not self.inbound_receiving_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.receiving',
'view_mode': 'form',
'res_id': self.inbound_receiving_id.id,
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _post_state_message(self, label):
for rec in self:
rec.message_post(
body=Markup('RMA status changed to <b>%s</b>.') % label,
message_type='comment',
subtype_xmlid='mail.mt_note',
)

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase A. Inverse Many2one fields on NCR, Hold and fp.receiving so
# RMA can hang One2many counterparts off them. Plus a tiny override on
# fp.receiving.create to flip a linked RMA into the `received` state and
# trigger the auto-spawn rules.
import logging
from markupsafe import Markup
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FpNcrRmaLink(models.Model):
_inherit = 'fusion.plating.ncr'
rma_id = fields.Many2one(
'fusion.plating.rma', string='RMA',
ondelete='set null', index=True,
help='Return that triggered this NCR (auto-set by RMA receive).',
)
class FpQualityHoldRmaLink(models.Model):
_inherit = 'fusion.plating.quality.hold'
rma_id = fields.Many2one(
'fusion.plating.rma', string='RMA',
ondelete='set null', index=True,
help='Return that placed these parts on hold.',
)
class FpReceivingRmaLink(models.Model):
_inherit = 'fp.receiving'
rma_id = fields.Many2one(
'fusion.plating.rma', string='Linked RMA',
ondelete='set null', index=True,
help='If set, this receiving is the inbound for a customer return. '
'When created, it transitions the RMA to `received` and may '
'auto-spawn an NCR + Hold per the RMA toggles.',
)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
# Walk new records, mirror back to RMA, walk the receiving's own
# state machine (draft → counted → staged → closed) so the linked
# SO's x_fc_receiving_status updates, then fire the RMA receive
# hook. Without this the receiving sat at draft and the SO read
# 'not_received' even though the parts were physically at the shop.
for rec in records:
if not rec.rma_id:
continue
rma = rec.rma_id.sudo()
# Mirror inbound link both ways.
if not rma.inbound_receiving_id:
rma.inbound_receiving_id = rec.id
if rma.state in ('authorised', 'shipped_to_us'):
# Use received_qty as qty_received fallback if not set.
if not rma.qty_received and rec.received_qty:
rma.qty_received = rec.received_qty
# Walk the receiving's lifecycle to closed so SO status
# updates. RMA receipts don't have a multi-day racking
# delay (parts are already plated and being inspected for
# the complaint, not racked for fresh plating), so we
# fast-forward all three transitions in one shot.
rec.sudo()._fp_rma_fast_close()
rma._enter_received_state(receiving=rec)
else:
_logger.info(
'RMA %s linked to fp.receiving %s but state %s does '
'not trigger auto-receive hook.',
rma.name, rec.name, rma.state,
)
return records
def _fp_rma_fast_close(self):
"""Walk an RMA-bound receiving from draft to closed in one call.
For RMA returns, the receiving's box-count → racking → close walk
is purely administrative — the parts are already plated and the
operator opens them on triage, not on intake. Fast-forwarding
here keeps the SO's x_fc_receiving_status accurate without
forcing the receiver to click three buttons in sequence.
"""
for rec in self:
if not rec.box_count_in:
# Best-effort default: 1 box if unknown. Real qty lives on
# the RMA's qty_returned / qty_received.
rec.box_count_in = 1
if rec.state == 'draft':
rec.action_mark_counted()
if rec.state == 'counted':
rec.action_mark_staged()
if rec.state == 'staged':
rec.action_close()
class AccountMoveRmaLink(models.Model):
"""Auto-link a credit note back to its RMA when the accountant
confirms the reversal wizard. Looks up by invoice_origin matching
an RMA's sale_order_id.name, scoped to RMAs in `resolving` state
with resolution_type='refund' and no refund_invoice_id yet.
Also flips the RMA from `resolving` to `resolved` once the credit
note is linked — mirrors the auto-progression for replace/rework
paths so the RMA doesn't get stuck after a refund.
"""
_inherit = 'account.move'
@api.model_create_multi
def create(self, vals_list):
moves = super().create(vals_list)
moves._fp_link_to_rma()
return moves
def write(self, vals):
result = super().write(vals)
if 'state' in vals and vals.get('state') == 'posted':
self._fp_link_to_rma()
return result
def _fp_link_to_rma(self):
Rma = self.env['fusion.plating.rma'].sudo()
for move in self:
if move.move_type != 'out_refund':
continue
if not move.invoice_origin:
continue
candidate = Rma.search([
('sale_order_id.name', '=', move.invoice_origin),
('resolution_type', '=', 'refund'),
('refund_invoice_id', '=', False),
('state', 'in', ('resolving', 'triaged')),
], limit=1)
if not candidate:
continue
candidate.refund_invoice_id = move.id
candidate.state = 'resolved'
candidate.message_post(
body=Markup(
'Refund credit note <b>%s</b> linked back to this RMA. '
'Marked Resolved.'
) % move.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
candidate._fire_rma_notification('rma_resolved')

View File

@@ -0,0 +1,210 @@
# Battle test — real shop failure modes.
#
# This is the "what if my operator is sloppy / forgetful / lazy" suite.
# We document what the system does TODAY, then identify what's missing.
#
# Persona shorthand:
# Carlos = operator
# Mike = second operator
# Bob = supervisor / manager (admin in this DB)
import time
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
def make_job(po_suffix):
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': f'PO-BT-{po_suffix}',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# ====================================================================== 1
print('='*72)
print('SCENARIO 1 — Carlos forgot to click Start. Realizes 2 hours later.')
print('='*72)
job = make_job('S1-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
print(f' Setup: {job.name}, step "{step.name}" state={step.state}')
print(f' Reality: Carlos started masking 2h ago but forgot to click.')
print(f' Now he clicks Start, then immediately Finish.')
step.button_start()
step.button_finish()
print(f' Result: state={step.state}, duration_actual={step.duration_actual:.4f} min')
print(f' → Lost 2h of clock time. NO way to back-date date_started without admin SQL.')
print(f' → date_started field is readonly=True on the form.')
print(f' GAP: No "Adjust Time" affordance for forgetful operators.')
# ====================================================================== 2
print()
print('='*72)
print('SCENARIO 2 — Carlos finished step physically. Forgot Finish. Went home.')
print('='*72)
job = make_job('S2-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos starts {step.name} at {step.date_started}')
print(f' ... 12 hours later Mike notices the step is still in_progress ...')
# Simulate the time gap by setting started 12h ago
step.write({'date_started': fields.Datetime.now() - timedelta(hours=12)})
# Mike taps Finish now
step.button_finish()
print(f' Mike clicks Finish: duration_actual = {step.duration_actual:.1f} min')
print(f' Reality was probably 30 min. System recorded {step.duration_actual:.0f} min.')
print(f' Cost rollup is wildly wrong: cost_total = ${step.cost_total or 0:.2f}')
print(f' GAP: No way to retroactively correct the timelog interval.')
# ====================================================================== 3
print()
print('='*72)
print('SCENARIO 3 — Two operators tap Start on the same step.')
print('='*72)
job = make_job('S3-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos clicks Start → state={step.state}, '
f'open logs={len(step.time_log_ids.filtered(lambda l: not l.date_finished))}')
try:
# Mike "logs in as himself" then taps Start on the same step
step.button_start()
open_logs = step.time_log_ids.filtered(lambda l: not l.date_finished)
print(f' Mike clicks Start → state={step.state}, open logs={len(open_logs)}')
if len(open_logs) >= 2:
print(f' ❌ TWO open timelogs created. duration_actual will double-count.')
except Exception as e:
print(f' ✓ Blocked: {e}')
# ====================================================================== 4
print()
print('='*72)
print('SCENARIO 4 — Operator finishes step #6 before #5 is started.')
print('='*72)
job = make_job('S4-' + fields.Datetime.now().strftime('%H%M%S'))
steps = job.step_ids.sorted('sequence')
step5 = steps[4]
step6 = steps[5]
print(f' Step #5: {step5.name} state={step5.state}')
print(f' Step #6: {step6.name} state={step6.state}')
try:
step6.button_start()
print(f' ❌ Allowed start of step #6 while step #5 still ready')
step6.button_finish()
print(f' Step #6 done. Step #5 still: {step5.state}')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
print(f' GAP: No predecessor enforcement. Steps are independent.')
print(f' REALITY: This may be intentional (parallel work in different tanks).')
print(f' But there\'s no "force serial" flag for steps that MUST be in order.')
# ====================================================================== 5
print()
print('='*72)
print('SCENARIO 5 — Job stuck mid-process. Manager wants to take over.')
print('='*72)
job = make_job('S5-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.write({'assigned_user_id': env.user.id})
step.button_start()
print(f' Step assigned to Carlos, in progress.')
print(f' Carlos is on vacation. Bob needs to reassign + finish.')
print(f' Bob views step → assigned_user_id={step.assigned_user_id.name}')
# Can Bob reassign?
try:
step.write({'assigned_user_id': env.user.id})
print(f' ✓ Bob reassigned step (write to assigned_user_id allowed)')
except Exception as e:
print(f' ❌ Reassign blocked: {e}')
# Bob finishes
step.button_finish()
print(f' Bob finishes: state={step.state}, finished_by={step.finished_by_user_id.name}')
# ====================================================================== 6
print()
print('='*72)
print('SCENARIO 6 — Bake window expired (operator at lunch). Override?')
print('='*72)
BW = env['fusion.plating.bake.window']
Bath = env['fusion.plating.bath']
bath = Bath.search([], limit=1)
expired = BW.create({
'bath_id': bath.id,
'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
'window_hours': 4.0,
'part_ref': 'BT-EXPIRED',
'quantity': 5,
})
# Cron updates state if past required_by
BW._cron_update_states()
expired.invalidate_recordset()
print(f' Bake window {expired.name}: state={expired.state}, '
f'required_by={expired.bake_required_by} (10h ago)')
# Try to start_bake on a missed_window
try:
expired.action_start_bake()
print(f' ⚠️ action_start_bake worked even on missed_window: state={expired.state}')
print(f' GAP: No guard against starting bake after missing window. Should require manager override.')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
# ====================================================================== 7
print()
print('='*72)
print('SCENARIO 7 — Operator clocks 6 hours on a step expected to take 30 min.')
print('='*72)
job = make_job('S7-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.duration_expected = 30 # 30 min
step.button_start()
# Simulate 6h elapsed
step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
step.button_finish()
ratio = (step.duration_actual / step.duration_expected) if step.duration_expected else 0
print(f' duration_expected={step.duration_expected} min, duration_actual={step.duration_actual:.0f} min')
print(f' Ratio: {ratio:.1f}x expected')
print(f' GAP: System silently accepted 12x overrun. No alert, no chatter post.')
# ====================================================================== 8
print()
print('='*72)
print('SCENARIO 8 — Operator did 4 of 5 parts. 1 contaminated. Qty drift.')
print('='*72)
job = make_job('S8-' + fields.Datetime.now().strftime('%H%M%S'))
print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
# Operator finishes all steps
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
# Try to mark done — qty_done is still 0
try:
job.button_mark_done()
print(f' Job done: qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
print(f' ⚠️ System lets job close with qty_done=0 even though qty=5')
print(f' GAP: No reconciliation between qty + qty_done + qty_scrapped at close.')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
env.cr.commit()
print()
print('== Battle test complete ==')

View File

@@ -0,0 +1,150 @@
# Battle test v2 — re-verify after fixes for: bake-window override,
# duration overrun chatter, qty reconciliation, recompute-duration.
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
def make_job(po_suffix):
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': f'PO-BT2-{po_suffix}',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# ====================================================================== Fix 1
print('='*72)
print('FIX 1 — Bake-window: missed_window blocks, manager override allowed + audited')
print('='*72)
BW = env['fusion.plating.bake.window']
Bath = env['fusion.plating.bath']
expired = BW.create({
'bath_id': Bath.search([], limit=1).id,
'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
'window_hours': 4.0,
'part_ref': 'BT2-EXPIRED',
'quantity': 5,
})
BW._cron_update_states()
expired.invalidate_recordset()
print(f' Window {expired.name} state: {expired.state}')
# Naive operator (no override) — should fail
try:
expired.action_start_bake()
print(f' ❌ start_bake worked without override')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:120]}')
# Manager override
try:
expired.action_force_start_missed()
print(f' ✓ Manager override succeeded: state={expired.state}')
# Check chatter
msgs = expired.message_ids.filtered(lambda m: 'OVERRIDE' in (m.body or ''))
print(f' ✓ Chatter audit: {len(msgs)} OVERRIDE message logged')
except Exception as e:
print(f' ❌ Override failed: {e}')
# ====================================================================== Fix 2
print()
print('='*72)
print('FIX 2 — Duration overrun: > 1.5x expected posts chatter warning')
print('='*72)
job = make_job('F2-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.duration_expected = 30 # 30 min expected
step.button_start()
# Force a 6h elapsed via timelog backdate
step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
# Update the open timelog to start 6h ago too
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
step.button_finish()
print(f' duration_expected={step.duration_expected:.0f} min, '
f'duration_actual={step.duration_actual:.0f} min, '
f'ratio={step.duration_actual/step.duration_expected:.1f}x')
overrun_msgs = job.message_ids.filtered(lambda m: 'expected' in (m.body or ''))
print(f' Chatter overrun warnings on job: {len(overrun_msgs)}')
if overrun_msgs:
print(f' ✓ Posted: {overrun_msgs[0].body[:100]}...')
# ====================================================================== Fix 3
print()
print('='*72)
print('FIX 3 — Qty reconciliation: job mark-done blocks if qty mismatch')
print('='*72)
job = make_job('F3-' + fields.Datetime.now().strftime('%H%M%S'))
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
# Try mark done with qty_done = 0
try:
job.button_mark_done()
print(f' ❌ Job closed with qty_done=0!')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:160]}')
# Set qty_done = 4, qty_scrapped = 1, retry
job.qty_done = 4
job.qty_scrapped = 1
print(f' Update: qty_done=4, qty_scrapped=1 (sums to qty=5)')
try:
job.button_mark_done()
print(f' ✓ Closed with reconciled qty: state={job.state}')
except Exception as e:
print(f' ❌ Still blocked: {e}')
# ====================================================================== Fix 4
print()
print('='*72)
print('FIX 4 — Supervisor edits timelog → Recompute Duration action picks it up')
print('='*72)
job = make_job('F4-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
import time as _t
_t.sleep(1)
step.button_finish()
print(f' Initial: duration_actual={step.duration_actual:.4f} min, '
f'logs={len(step.time_log_ids)}')
# Bob backdates the timelog (operator forgot to start; was actually 30 min)
log = step.time_log_ids[0]
real_start = log.date_finished - timedelta(minutes=30)
log.write({'date_started': real_start})
print(f' Bob backdates log: started 30 min before finish')
print(f' log.duration_minutes (auto): {log.duration_minutes:.2f} min')
print(f' step.duration_actual STILL stale: {step.duration_actual:.2f} min')
# Apply recompute
step.action_recompute_duration_from_timelogs()
print(f' After Recompute: duration_actual={step.duration_actual:.2f} min')
recompute_msgs = job.message_ids.filtered(lambda m: 'recomputed' in (m.body or '').lower())
print(f' Chatter audit: {len(recompute_msgs)} recompute entry logged')
env.cr.commit()
print()
print('== Battle test v2 complete ==')

View File

@@ -0,0 +1,82 @@
# Scenario 10 — Carlos paused for lunch. Got pulled to another job. Step
# is now sitting in 'paused' state for 3 days. No alert. Costing is wrong
# (the open timelog row was already closed at pause, but the step shows
# zero progress).
#
# Real shop pattern: this happens daily — interruptions, shift change,
# operator pulled to rush job.
#
# What we want:
# 1. A way to find ALL steps stuck in 'paused' beyond a threshold
# 2. An automatic activity / chatter nudge to the supervisor
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S10-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[0]
# Carlos starts → pauses → walks away
step.button_start()
step.button_pause()
print(f'[Carlos] Started + paused step "{step.name}" (state={step.state})')
# Simulate 3 days passing — backdate the pause by setting date_started
step.date_started = fields.Datetime.now() - timedelta(days=3)
print(f' Pretending it has been paused 3 days')
# Today: how would a manager find this?
print()
print('=== Manager finds stale paused steps ===')
Step = env['fp.job.step']
all_paused = Step.search([('state', '=', 'paused')])
print(f' Total paused steps in DB: {len(all_paused)}')
print(f' Stale-paused (date_started > 1 day ago, state=paused):')
cutoff = fields.Datetime.now() - timedelta(days=1)
stale = Step.search([
('state', '=', 'paused'),
('date_started', '<', cutoff),
])
print(f' found {len(stale)} via search_count')
for s in stale[:5]:
age = (fields.Datetime.now() - s.date_started).days
print(f' - {s.job_id.name} step "{s.name}": paused {age}d, '
f'assigned={s.assigned_user_id.name or "(no one)"}')
# Is there a cron / activity nudge?
crons = env['ir.cron'].search([('name', 'ilike', 'pause')])
print()
print(f' Crons matching "pause": {len(crons)}')
activities = env['mail.activity'].search([
('res_model', '=', 'fp.job.step'),
('summary', 'ilike', 'paused'),
])
print(f' Activities about paused steps: {len(activities)}')
print()
if not activities and not crons:
print(' ❌ GAP: stale-paused steps live forever silently. No nudge.')
env.cr.commit()

View File

@@ -0,0 +1,110 @@
# Scenario 11 — Carlos plating step #4 in tank 3. 8 minutes in, the
# rectifier dies. Parts come out half-plated. Carlos needs to:
# 1. Abort the current step (parts not finished — but partial work
# already happened)
# 2. Switch to backup tank 5
# 3. Restart the step there
#
# What does the system support today?
#
# Fields on fp.job.step that exist:
# - state machine: pending/ready/in_progress/paused/done/skipped/cancelled
# - bath_id, tank_id (the tank picked at start)
# - time_log_ids
#
# Operator's options today:
# A) button_cancel → state=cancelled, but then step shows as cancelled
# and won't be replayed. Not what we want — we WANT a retry.
# B) button_finish + open NCR manually + create a new step manually?
# Way too much paperwork.
# C) button_pause + change tank_id + button_start → preserves history
# but doesn't capture WHY (equipment failure)
#
# Real shop need:
# - "Abort + restart" action that:
# 1. Closes the current timelog (capturing the partial time)
# 2. Resets state to ready
# 3. Lets operator pick a new tank/bath
# 4. Posts chatter on the JOB explaining (equipment failure → tank)
# 5. Optionally fires an NCR / Maintenance request
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S11-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Pick the plating step
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not plating:
plating = job.step_ids.sorted('sequence')[3:4]
# Walk earlier steps to done
for s in job.step_ids.sorted('sequence'):
if s == plating:
break
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' [Carlos] About to start: {plating.name}')
Tank = env['fusion.plating.tank']
Bath = env['fusion.plating.bath']
tanks = Tank.search([], limit=2)
if len(tanks) < 2:
print(f' ⚠️ Need 2+ tanks for the test, only have {len(tanks)}')
else:
tank3, tank5 = tanks[0], tanks[1]
plating.write({'tank_id': tank3.id})
print(f' Initial tank: {plating.tank_id.name}')
plating.button_start()
print(f' Started → state={plating.state}, started_by={plating.started_by_user_id.name}')
print(f' Open timelog rows: {len(plating.time_log_ids)}')
print()
print(f' ⚡ 8 MINUTES LATER: Rectifier dies on tank {plating.tank_id.name}')
print(f' Carlos needs to abort and restart on backup tank.')
print()
# Today's options:
print(f' Today\'s options the operator has:')
print(f' A) button_cancel → step becomes cancelled (job stuck — no replay)')
print(f' B) button_pause + write tank_id + button_start (no failure record)')
print(f' C) ???')
print()
# Try option B (the workaround)
print(f' Trying option B (pause → change tank → resume):')
plating.button_pause()
print(f' Paused: state={plating.state}, logs={len(plating.time_log_ids)}')
if len(tanks) >= 2:
plating.write({'tank_id': tanks[1].id})
print(f' Changed tank to: {plating.tank_id.name}')
plating.button_start()
print(f' Resumed: state={plating.state}, logs={len(plating.time_log_ids)}')
print()
print(f' ❌ GAP: NO RECORD of WHY the tank change happened.')
print(f' ❌ GAP: Workaround works but loses the equipment-failure event.')
print(f' ❌ GAP: No automatic Maintenance Request / NCR creation for the failed equipment.')
env.cr.commit()

View File

@@ -0,0 +1,82 @@
# Verify action_abort_for_retry on a fresh job.
import time
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
Tank = env['fusion.plating.tank']
tanks = Tank.search([], limit=2)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S11V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[3] # plating
step.tank_id = tanks[0].id
step.button_start()
print(f' [Carlos] Started {step.name} on tank {step.tank_id.name}')
time.sleep(2)
# Equipment fails
print(f' ⚡ Rectifier dies on tank {step.tank_id.name}')
print()
before_msgs = len(job.message_ids)
step.action_abort_for_retry(
reason='Rectifier #3 tripped breaker; sparking on bus bar',
new_tank_id=tanks[1].id if len(tanks) > 1 else False,
)
print(f' After abort:')
print(f' state={step.state}')
print(f' tank_id={step.tank_id.name}')
print(f' duration_actual (partial work)={step.duration_actual:.4f} min')
print(f' timelogs={len(step.time_log_ids)}, all closed: '
f'{all(l.date_finished for l in step.time_log_ids)}')
print()
after_msgs = len(job.message_ids)
print(f' Job chatter: {before_msgs}{after_msgs} (delta {after_msgs - before_msgs})')
abort_msg = job.message_ids[0]
print(f' Latest message:')
print(f' {abort_msg.body[:300]}...')
# Operator restarts on the new tank
print()
print(f' [Carlos] Restarts the step on the new tank')
step.button_start()
time.sleep(2)
step.button_finish()
print(f' Final state={step.state}, total duration_actual={step.duration_actual:.4f} min')
print(f' Total timelogs={len(step.time_log_ids)} (1 from abort + 1 from retry)')
# Failure case: try to abort a step in 'ready' state
print()
print(f' Failure test: try abort on a ready (not in_progress) step')
ready_step = job.step_ids.filtered(lambda s: s.state == 'ready')[:1]
if ready_step:
try:
ready_step.action_abort_for_retry(reason='test')
print(f' ❌ Allowed abort on ready step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:100]}')
env.cr.commit()

View File

@@ -0,0 +1,76 @@
# Scenario 12 — Sarah enters SO qty=5. Job spawns with qty=5. Carlos
# starts step 1. Customer calls — they want 8 instead of 5. Sarah edits
# the SO line from 5 to 8.
#
# Question: does the job pick up the change?
# Reality: a stale qty on the job means Carlos plates 5 (per his router)
# but invoice goes for 8 (per the SO).
#
# OR Sarah can't edit a confirmed-SO line (Odoo standard locks it),
# in which case Sarah cancels + reorders, and we have ANOTHER problem.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Build SO with qty=5
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S12-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
sol = so.order_line[:1]
print(f' Initial: SO line qty={sol.product_uom_qty}, job qty={job.qty}')
# Carlos starts the first step
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos started step "{step.name}" (state={step.state})')
print()
# Customer calls — wants 8 not 5
print(f' 📞 Customer: "Make it 8 instead of 5"')
print(f' Sarah edits SO line qty from 5 to 8...')
try:
sol.product_uom_qty = 8
print(f' Edit succeeded: SO line qty={sol.product_uom_qty}')
except Exception as e:
print(f' ❌ Edit blocked: {e}')
# Did the job qty propagate?
job.invalidate_recordset()
print(f' Job qty AFTER SO edit: {job.qty}')
print()
if job.qty != sol.product_uom_qty:
print(f' ❌ GAP: Job qty stale ({job.qty}) vs SO line qty ({sol.product_uom_qty}).')
print(f' Carlos will plate {job.qty} parts. Invoice ships for {sol.product_uom_qty}.')
print(f' No automatic resync, no warning.')
else:
print(f' ✓ Job qty auto-updated.')
# Try the reverse — what if Sarah tries to LOWER the qty?
print()
print(f' Customer changes mind: now wants 3 instead of 8')
try:
sol.product_uom_qty = 3
job.invalidate_recordset()
print(f' SO line qty={sol.product_uom_qty}, job qty={job.qty}')
except Exception as e:
print(f' ❌ Blocked: {e}')
env.cr.commit()

View File

@@ -0,0 +1,59 @@
# Verify mid-job qty change posts chatter + sync action works.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S12V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
sol = so.order_line[:1]
job.step_ids.sorted('sequence')[0].button_start()
print(f' Initial: SO={sol.product_uom_qty}, job.qty={job.qty}')
before_msgs = len(job.message_ids)
print()
print(f' Sarah edits SO line qty 5 → 8 mid-job')
sol.product_uom_qty = 8
job.invalidate_recordset()
after_msgs = len(job.message_ids)
print(f' Job chatter: {before_msgs}{after_msgs} (delta {after_msgs - before_msgs})')
warn = job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or ''))
print(f' Warning messages on job: {len(warn)}')
if warn:
print(f' ✓ Chatter warning posted')
print(f' Job.qty still: {job.qty} (unchanged — supervisor must explicitly sync)')
print()
print(f' Bob clicks "Sync qty from SO" on the job')
job.action_sync_qty_from_so()
print(f' Job.qty after sync: {job.qty} (expect 8)')
sync_msgs = job.message_ids.filtered(lambda m: 'synced from SO' in (m.body or ''))
print(f' Sync chatter messages: {len(sync_msgs)}')
print()
# Now what about LOWER qty
print(f' Customer reduces to 3...')
sol.product_uom_qty = 3
job.invalidate_recordset()
warn2 = len(job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or '')))
print(f' Warnings now: {warn2}')
job.action_sync_qty_from_so()
print(f' After sync: job.qty={job.qty}')
env.cr.commit()

View File

@@ -0,0 +1,93 @@
# Verify shopfloor scan + tablet_overview now expose step instructions.
from odoo.tests.common import HOST
from odoo import fields
# Build a job with instructions on a step
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S13-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Add detailed instructions to the plating step
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not plating:
plating = job.step_ids.sorted('sequence')[3:4]
plating.instructions = (
'<p><b>Plating bath checklist:</b></p><ul>'
'<li>Verify nickel concentration is 4.05.5 g/L (Fischerscope reading)</li>'
'<li>pH must be 4.44.8 — adjust with ammonium hydroxide if needed</li>'
'<li>Bath temp 8893°C, agitation ON</li>'
'<li>Dwell 45 minutes for 25 µm coating; longer for thicker</li>'
'<li>Rinse for 60s before next station</li></ul>'
)
plating.thickness_target = 25.0
plating.thickness_uom = 'um'
plating.dwell_time_minutes = 45.0
plating.bake_setpoint_temp = 0 # not a bake step
print(f' Step "{plating.name}":')
print(f' instructions length: {len(plating.instructions or "")} chars')
print(f' thickness_target: {plating.thickness_target} {plating.thickness_uom}')
print()
# Now simulate scan endpoint via the controller
from odoo.addons.fusion_plating_shopfloor.controllers import shopfloor_controller as sc
print(f' Tablet operator scans the step QR code (simulating /fp/shopfloor/scan)')
# Build a fake request env
from odoo.http import request as _req
# Call the underlying logic directly
# Find code prefix used
print(f' Step code: {plating.id}, name: {plating.name}')
# Direct call to the scan response builder (no http) — easier approach:
# The scan endpoint builds the dict inline. Verify by replicating its code path.
step = plating
payload = {
'ok': True, 'model': 'fp.job.step',
'id': step.id, 'name': step.name, 'state': step.state,
'duration_actual': step.duration_actual,
'duration_expected': step.duration_expected,
'job_name': step.job_id.name or '',
'product_name': step.job_id.product_id.display_name or '',
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
}
print(f' Scan payload now includes:')
print(f' instructions: {len(payload["instructions"])} chars')
print(f' thickness_target: {payload["thickness_target"]} {payload["thickness_uom"]}')
print(f' dwell_time_minutes: {payload["dwell_time_minutes"]}')
print(f' duration_expected: {payload["duration_expected"]}')
# Tablet overview check via JSONRPC
# We'll just check the controller method directly
print()
print(f' Tablet overview payload (simulate /fp/shopfloor/tablet_overview):')
# Just verify the field is in _step_payload by introspection
import inspect
src = inspect.getsource(sc.FpShopfloorController)
print(f' _step_payload includes "instructions"? {"instructions" in src and "step.instructions" in src}')
print(f' _step_payload includes "thickness_target"? {"step.thickness_target" in src}')
print(f' _step_payload includes "dwell_time_minutes"? {"step.dwell_time_minutes" in src}')
env.cr.commit()

View File

@@ -0,0 +1,63 @@
# Scenario 14 — Recipe author wants step "Plating" to be hard-blocked
# until step "Acid Etch" finishes. (Real reason: passivation layer
# starts forming on bare metal in seconds; if Plating starts before
# acid etch is done, adhesion fails.)
#
# Today the system allows ANY step to start any time. Out-of-order is
# allowed for parallel work — but for SERIAL-MUST steps, there's no
# enforcement. We need an opt-in flag the recipe author can set per
# step: requires_predecessor_done.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
steps = job.step_ids.sorted('sequence')
print(f' Job {job.name} steps:')
for i, s in enumerate(steps[:6]):
print(f' #{i+1} ({s.sequence}): {s.name} state={s.state}')
# Today: skip step #1, #2, #3, jump to step #4 (plating).
print()
print(' [Operator] Tries to skip earlier steps and start plating directly:')
plating = steps.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if plating:
try:
plating.button_start()
print(f' state={plating.state}')
print(f' ❌ NO PREDECESSOR CHECK. Plating started while Masking/Racking still ready.')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
# Check if requires_predecessor_done field exists
rec_step = plating.recipe_node_id if plating else False
fields_on_node = list(env['fusion.plating.process.node']._fields.keys())
print()
print(f' Looking for requires_predecessor_done field on fp.process.node:')
print(f' Found: {"requires_predecessor_done" in fields_on_node}')
print(f' Looking for requires_predecessor_done field on fp.job.step:')
print(f' Found: {"requires_predecessor_done" in env["fp.job.step"]._fields}')
print()
print(f' ❌ GAP: No way for the recipe author to mark a step as serial-required.')
env.cr.commit()

View File

@@ -0,0 +1,91 @@
# Verify predecessor enforcement
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Find plating step + flag its recipe node as serial-required
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if plating and plating.recipe_node_id:
plating.recipe_node_id.requires_predecessor_done = True
print(f' Recipe author flagged "{plating.name}" requires_predecessor_done')
plating.invalidate_recordset()
print(f' Step picks it up via related: {plating.requires_predecessor_done}')
# Try to start plating with earlier steps still ready
print()
print(f' [Operator] Tries to start plating WITHOUT finishing earlier steps:')
try:
plating.button_start()
print(f' ❌ Allowed early start! state={plating.state}')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:200]}')
# Walk earlier steps to done
print()
print(f' [Operator] Walks earlier steps to done:')
for s in job.step_ids.sorted('sequence'):
if s == plating:
break
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' Earlier steps now: {set(job.step_ids.filtered(lambda x: x.sequence < plating.sequence).mapped("state"))}')
# Try plating again
print()
print(f' [Operator] Tries plating again after earlier steps done:')
try:
plating.button_start()
print(f' ✓ Allowed: state={plating.state}')
except Exception as e:
print(f' ❌ Still blocked: {e}')
# Test manager bypass via context
print()
print(f' Test manager bypass on a fresh job:')
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14B-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
Line.create({
'wizard_id': w2.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r2 = w2.action_create_order()
so2 = env['sale.order'].browse(r2['res_id'])
so2.action_confirm()
job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
plating2 = job2.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
# (the recipe_node already has requires_predecessor_done=True from earlier write)
print(f' Plating step requires_predecessor_done: {plating2.requires_predecessor_done}')
try:
plating2.with_context(fp_skip_predecessor_check=True).button_start()
print(f' ✓ Manager bypass: state={plating2.state}')
except Exception as e:
print(f' ❌ Bypass failed: {e}')
env.cr.commit()

View File

@@ -0,0 +1,72 @@
# Scenario 15 — Job has a coating that requires hydrogen embrittlement
# bake. Operator finishes plating step → bake.window auto-spawns
# (state=awaiting_bake). Operator finishes the rest of the steps and
# clicks Mark Done on the job — but never started the bake.
#
# Today: job closes done. Customer ships parts. Field failure 3 weeks
# later. AS9100 auditor: "Show me the bake record for lot X." There's
# no bake record. NCR + customer credit hit.
#
# Want: button_mark_done blocks if any linked bake.window is in state
# awaiting_bake or bake_in_progress. Manager bypass for one-off
# deviations.
import time
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
part = Part.create({
'partner_id': target.id,
'part_number': 'S15-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'substrate_material': 'steel',
'x_fc_default_coating_config_id': coating.id,
})
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S15-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Walk all steps to done — the plating step will spawn a bake.window
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.qty_done = 5 # satisfy reconciliation gate
print(f' Job {job.name}: all steps done')
BW = env['fusion.plating.bake.window']
bws = BW.search([('part_ref', '=', job.name)])
print(f' Bake windows linked to job: {len(bws)}')
for bw in bws:
print(f' {bw.name}: state={bw.state}, required_by={bw.bake_required_by}')
print()
print(f' [Operator — careless] Clicks Mark Done WITHOUT starting bake')
try:
job.button_mark_done()
print(f' ❌ Job closed with bake awaiting! state={job.state}')
print(f' COMPLIANCE BOMB — no bake record but parts ship.')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:200]}')
env.cr.commit()

View File

@@ -0,0 +1,67 @@
# Scenario 16 — Carlos clicked Start on a step. Got pulled to a rush
# job. Forgot to come back. The original step is still in_progress 8
# hours later. The open timelog row is accumulating phantom time. Cost
# rollup is wrong. Manager has no nudge.
#
# Mirror of S10 (stale-paused) but for in_progress.
from datetime import timedelta
from odoo import fields
# Find existing stale in_progress steps in DB to test against
Step = env['fp.job.step']
cutoff = fields.Datetime.now() - timedelta(hours=8)
stale = Step.search([
('state', '=', 'in_progress'),
('date_started', '<', cutoff),
('date_started', '!=', False),
])
print(f' Total in_progress steps started > 8h ago: {len(stale)}')
for s in stale[:5]:
age = (fields.Datetime.now() - s.date_started).total_seconds() / 3600.0
print(f' {s.job_id.name} step "{s.name}": in_progress {age:.1f}h, '
f'started_by={s.started_by_user_id.name or "(none)"}')
if not stale:
print(f' Building one synthetic case...')
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S16-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
s = job.step_ids.sorted('sequence')[0]
s.button_start()
s.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
open_log = s.time_log_ids.filtered(lambda l: not l.date_finished)
if open_log:
open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
print(f' Created stale: {s.job_id.name} step "{s.name}"')
# Look for cron / activity
crons = env['ir.cron'].search([
('name', 'ilike', 'in_progress'), ('name', 'ilike', 'stale'),
])
print()
print(f' Crons matching stale-in_progress: {len(crons)}')
acts = env['mail.activity'].search([('summary', 'like', 'Stale in-progress%')])
print(f' Activities about stale in_progress: {len(acts)}')
if not crons and not acts:
print(f' ❌ GAP: no nudge for phantom in_progress steps either.')
env.cr.commit()

View File

@@ -0,0 +1,60 @@
# Scenario 17 — Mid-job Carlos drops 2 parts (out of 5). Sets
# qty_scrapped from 0 → 2. With my qty-reconciliation gate, he MUST
# update this for the job to close — but there's no NCR / hold record
# explaining WHY 2 parts went away.
#
# Real shop: every scrap event is investigated. Material cost lost,
# customer not told (because the qty_done went down, not the order
# qty), and the AS9100 audit asks "where's the disposition record for
# scrapped parts?"
#
# Want: when qty_scrapped increases on fp.job, auto-create a
# fusion.plating.quality.hold + post chatter for the operator to
# document the cause.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S17-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Carlos starts working
job.step_ids.sorted('sequence')[0].button_start()
# Count holds linked before
Hold = env['fusion.plating.quality.hold']
holds_before = Hold.search_count([('part_ref', '=', part.part_number)])
print(f' Holds for {part.part_number} before scrap: {holds_before}')
# Drop 2 parts
print(f' [Carlos] Drops 2 parts. Updates qty_scrapped 0 → 2')
job.qty_scrapped = 2
holds_after = Hold.search_count([('part_ref', '=', part.part_number)])
print(f' Holds for {part.part_number} after scrap: {holds_after}')
if holds_after > holds_before:
print(f' ✓ Auto-Hold spawned')
else:
print(f' ❌ GAP: qty_scrapped went up but NO hold/NCR auto-created.')
print(f' No record of what happened. AS9100 auditor unhappy.')
env.cr.commit()

View File

@@ -0,0 +1,121 @@
# Scenario 18 — Certificate flow simulation.
# Persona: Sarah (CSR) → Carlos (operator) → Tom (shipper)
# Goal: complete cert issuance from SO entry to customer email.
# Track every gap.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
coating = Coating.search([('spec_reference', '!=', False)], limit=1) \
or Coating.search([], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
print(f' Coating: {coating.name}, spec_reference={coating.spec_reference}')
# Build the SO + walk the full flow
import base64
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-CERT-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 25.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f' Job {job.name} confirmed, qty=5')
# Walk all steps to done
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
# Set qty_done so reconciliation gate passes
job.qty_done = 5
# Bake-window if any
BW = env['fusion.plating.bake.window']
bws = BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')])
for bw in bws:
bw.action_start_bake()
bw.action_end_bake()
# Mark done
print(f' [Carlos] Mark Done')
job.button_mark_done()
print(f' job.state = {job.state}')
# CHECK: was a certificate auto-spawned?
Cert = env['fp.certificate']
certs = Cert.search([('sale_order_id', '=', so.id)])
if not certs:
# try x_fc_job_id link
certs = Cert.search([]) # last resort
print()
print(f' Certificates for this SO: {len(certs)}')
for c in certs[:3]:
print(f' {c.name}: state={c.state}, type={c.certificate_type}')
print(f' partner_id: {c.partner_id.name}')
print(f' spec_reference: {c.spec_reference!r}')
print(f' part_number: {c.part_number!r}')
print(f' quantity_shipped: {c.quantity_shipped}')
print(f' po_number: {c.po_number!r}')
print(f' attachment_id: {c.attachment_id.name if c.attachment_id else None}')
if not certs:
print(f' ❌ GAP: no cert auto-created!')
raise SystemExit
cert = certs[0]
# DISCOVERABILITY — would Tom find the cert from the job form?
print()
print(f' [Tom] Looking at the job form, smart-button row:')
print(f' job.certificate_count = {getattr(job, "certificate_count", "no field")}')
print(f' Smart button visible? (depends on certificate_count > 0)')
# Try to issue
print()
print(f' [Tom] Clicks Issue on the certificate:')
try:
cert.action_issue()
print(f' ✓ Issued: state={cert.state}')
except Exception as e:
print(f' ❌ Blocked: {str(e)[:200]}')
# If blocked due to spec_reference, fix and retry
if cert.state == 'draft' and not cert.spec_reference:
print()
print(f' [Tom] Manually fills spec_reference (workflow gap — should auto-fill from coating)')
cert.spec_reference = coating.spec_reference or 'AMS 2404'
try:
cert.action_issue()
print(f' ✓ Issued after manual fix: state={cert.state}')
except Exception as e:
print(f' ❌ Still blocked: {str(e)[:200]}')
# Try Send to Customer
print()
print(f' [Tom] Clicks Send to Customer:')
print(f' cert.attachment_id = {cert.attachment_id.name if cert.attachment_id else "(none — PDF not generated!)"}')
try:
act = cert.action_send_to_customer()
print(f' Composer opens. Default attachments: '
f'{act.get("context", {}).get("default_attachment_ids", "(none)")}')
except Exception as e:
print(f'{e}')
env.cr.commit()

View File

@@ -0,0 +1,155 @@
# Scenario 19 — Fischerscope thickness report PDF appended to CoC.
#
# Goal: when QC has a thickness_report_pdf_id uploaded by the operator
# on the tablet, action_issue should produce a multi-page CoC with the
# Fischerscope PDF as page 2+.
import base64
from odoo import fields
# Build a fresh job that requires QC + Fischerscope PDF
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Tpl = env['fp.qc.checklist.template']
TplLine = env['fp.qc.checklist.template.line']
QC = env['fusion.plating.quality.check']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
target.x_fc_requires_qc = True
coating = Coating.search([('spec_reference', '!=', False)], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Make sure default QC template requires Fischerscope PDF
default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
if default_tpl:
default_tpl.require_thickness_report_pdf = True
print(f' Using QC template: {default_tpl.name} (requires PDF)')
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-FISCHER-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Find the auto-spawned QC
qc = QC.search([('job_id', '=', job.id)], limit=1)
if not qc:
qc = QC.create_for_job(job)
print(f' QC: {qc.name}, lines={len(qc.line_ids)}')
# Operator uploads a fake "Fischerscope" PDF to the QC
# Use a real minimal PDF so the merge actually parses
minimal_pdf = (
b'%PDF-1.4\n'
b'1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
b'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
b'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R'
b'/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n'
b'4 0 obj<</Length 88>>stream\n'
b'BT /F1 14 Tf 100 700 Td (FISCHERSCOPE THICKNESS REPORT) Tj '
b'0 -30 Td (Mean: 25.3 um Std: 1.2) Tj ET\n'
b'endstream endobj\n'
b'xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n'
b'0000000054 00000 n \n0000000097 00000 n \n0000000189 00000 n \n'
b'trailer<</Size 5/Root 1 0 R>>\nstartxref\n330\n%%EOF\n'
)
att = env['ir.attachment'].create({
'name': 'fischer_test.pdf',
'datas': base64.b64encode(minimal_pdf),
'mimetype': 'application/pdf',
'type': 'binary',
})
qc.thickness_report_pdf_id = att.id
print(f' Uploaded Fischerscope PDF: {qc.thickness_report_pdf_id.name} '
f'({len(minimal_pdf)} bytes)')
# Walk QC lines + pass
for ln in qc.line_ids:
ln.result = 'pass'
qc.action_pass()
print(f' QC state: {qc.state}')
# Walk job to done
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.qty_done = 5
# Bake window
BW = env['fusion.plating.bake.window']
for bw in BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')]):
bw.action_start_bake()
bw.action_end_bake()
job.button_mark_done()
print(f' Job done')
# Fetch the auto-spawned cert
Cert = env['fp.certificate']
cert = Cert.search([('x_fc_job_id', '=', job.id)], limit=1)
print()
print(f' Cert: {cert.name}, state={cert.state}')
# v19.0.6.20.0 — new UI visibility fields (S19 Phase 2). Assert the
# operator would see "Will Append on Issue" badge BEFORE clicking Issue.
print(f' x_fc_thickness_status (pre-Issue): {cert.x_fc_thickness_status!r}')
print(f' x_fc_thickness_qc_id: {cert.x_fc_thickness_qc_id.name if cert.x_fc_thickness_qc_id else "(none)"}')
print(f' x_fc_thickness_pdf_id: {cert.x_fc_thickness_pdf_id.name if cert.x_fc_thickness_pdf_id else "(none)"}')
if cert.x_fc_thickness_status == 'pending':
print(f' ✓ UI banner WILL show "Fischerscope thickness PDF is on file"')
elif cert.x_fc_thickness_status == 'none':
print(f' ❌ UI says no PDF — merge would not run on Issue')
else:
print(f' ⚠️ unexpected status: {cert.x_fc_thickness_status}')
# Issue the cert — should render CoC + merge Fischerscope as page 2
cert.action_issue()
cert.invalidate_recordset(['x_fc_thickness_status', 'x_fc_thickness_qc_id', 'x_fc_thickness_pdf_id'])
print(f' After Issue: state={cert.state}')
print(f' x_fc_thickness_status (post-Issue): {cert.x_fc_thickness_status!r}')
if cert.x_fc_thickness_status == 'merged':
print(f' ✓ UI banner shows "Fischerscope thickness report merged"')
else:
print(f' ❌ UI status not flipping to merged: {cert.x_fc_thickness_status}')
print(f' attachment_id: {cert.attachment_id.name if cert.attachment_id else "(none)"}')
if cert.attachment_id:
pdf_bytes = base64.b64decode(cert.attachment_id.datas)
print(f' Total PDF size: {len(pdf_bytes)} bytes')
# Quick page count via pypdf
import io
try:
from pypdf import PdfReader
except ImportError:
from PyPDF2 import PdfReader
try:
reader = PdfReader(io.BytesIO(pdf_bytes))
print(f' Page count: {len(reader.pages)}')
if len(reader.pages) >= 2:
print(f' ✓ CoC + Fischerscope merged (multi-page)')
else:
print(f' ❌ Only 1 page — merge did not run')
except Exception as e:
print(f' ⚠️ couldn\'t parse output PDF: {e}')
# Look for chatter audit
msgs = cert.message_ids.filtered(lambda m: 'fischerscope' in (m.body or '').lower())
print(f' Chatter mentions Fischerscope: {len(msgs)}')
for m in msgs[:2]:
print(f' - {m.body[:120]}')
target.x_fc_requires_qc = False
env.cr.commit()

View File

@@ -0,0 +1,65 @@
# Scenario 9 — Carlos starts step, Bob (supervisor) reassigns to Mike.
# Verify chatter audit trail.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S9-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[0]
# Pretend Carlos owns this step.
step.assigned_user_id = env.user.id
step.button_start()
print(f'[Carlos] Started step "{step.name}" (state={step.state})')
print(f' assigned_user_id: {step.assigned_user_id.name}')
# Count chatter messages on the JOB before Bob reassigns.
before_count = len(job.message_ids)
print(f' Job chatter messages before reassign: {before_count}')
# Bob reassigns to "another user" — for the test we just write to ourselves
# to simulate, but the field write IS the operation.
print()
print('[Bob] Reassigning step to a different operator...')
# Use a different user if available.
other = env['res.users'].search([('id', '!=', env.user.id), ('share', '=', False)], limit=1)
if not other:
other = env.user # fallback — at least the write fires
step.assigned_user_id = other.id
step.invalidate_recordset()
job.invalidate_recordset()
after_count = len(job.message_ids)
print(f' After reassign: assigned_user_id={step.assigned_user_id.name}')
print(f' Job chatter messages: {after_count} (delta: {after_count - before_count})')
reassign_msgs = job.message_ids.filtered(
lambda m: 'reassign' in (m.body or '').lower()
)
print(f' Reassign-flagged chatter posts: {len(reassign_msgs)}')
if reassign_msgs:
print(f' ✓ Audit trail captured')
else:
print(f' ❌ GAP: silent reassignment, no chatter trail')
env.cr.commit()

View File

@@ -0,0 +1,395 @@
# -*- coding: utf-8 -*-
# E2E persona walk — order entry from start to finish.
#
# Personas:
# Sarah — Customer Service Rep
# Mike — Receiver
# Carlos — Plating Operator
# Lisa — QC Inspector
# Tom — Shipper
# Jane — Accounting
#
# This script fills every visible-to-operator field per step, walks the
# workflow with no shortcuts, asserts the data is sane after each phase,
# and prints what's actually visible in each form view.
import logging
import traceback
from datetime import date, timedelta
_log = logging.getLogger(__name__)
def section(title):
print(f'\n{"="*72}\n{title}\n{"="*72}')
def step(persona, msg):
print(f' [{persona:>7}] {msg}')
def fail(persona, msg):
print(f' [{persona:>7}] ❌ {msg}')
def find(persona, msg):
print(f' [{persona:>7}] 🔍 GAP: {msg}')
def e2e(env):
findings = []
# ----- pick a real partner with a recipe-able product -----
section('SETUP — pick a customer + a part already in the catalog')
Partner = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
partner = Partner.search([
('customer_rank', '>', 0),
('x_fc_account_hold', '=', False),
], limit=1)
if not partner:
partner = Partner.search([('customer_rank', '>', 0)], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1) \
or Part.search([], limit=1)
coating = part.x_fc_default_coating_config_id \
if part.x_fc_default_coating_config_id \
else Coating.search([], limit=1)
step('Sarah', f'Customer: {partner.display_name} (id={partner.id})')
step('Sarah', f'Part: {part.part_number or part.name} rev {part.revision or "?"} (id={part.id})')
step('Sarah', f'Coating: {coating.display_name if coating else "NONE"} (id={coating.id if coating else 0})')
if not coating:
findings.append('No fp.coating.config found in DB — cannot create realistic SO')
return findings
# ----- Sarah builds a sale order -----
section('PHASE 1 — Sarah (CSR) creates the sale order')
SO = env['sale.order']
SOL = env['sale.order.line']
so_vals = {
'partner_id': partner.id,
'x_fc_po_number': f'PO-E2E-{date.today():%y%m%d}',
'x_fc_customer_job_number': 'CUSTJOB-001',
'x_fc_contact_phone': '+1-555-0100',
'x_fc_ship_via': 'Customer pickup',
'x_fc_planned_start_date': date.today() + timedelta(days=2),
'x_fc_internal_deadline': date.today() + timedelta(days=10),
'commitment_date': date.today() + timedelta(days=14),
'x_fc_invoice_strategy': 'net_terms',
'x_fc_delivery_method': 'shipping_partner',
'x_fc_rush_order': False,
'x_fc_is_blanket_order': False,
'x_fc_internal_note': 'E2E test SO — full persona walk.',
'x_fc_external_note': 'Standard plating per spec.',
}
so = SO.create(so_vals)
step('Sarah', f'Created SO {so.name} (id={so.id})')
# add a line — fill the part / coating / treatment fields
product = env['product.product'].search([('sale_ok', '=', True)], limit=1)
if not product:
findings.append('No saleable product available for SO line')
return findings
line_vals = {
'order_id': so.id,
'product_id': product.id,
'product_uom_qty': 25,
'name': f'{part.part_number or part.name} — Plating per coating spec',
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': coating.id,
'x_fc_internal_description': 'Process via standard recipe; bake ASAP.',
'x_fc_job_number': 'INTJOB-001',
}
line = SOL.create(line_vals)
step('Sarah', f'Added line: {line.product_uom_qty} × {line.name[:40]}')
# confirm — does account hold block?
if partner.x_fc_account_hold:
find('Sarah', 'Customer is on account hold; SO confirm should block (or warn)')
try:
so.action_confirm()
step('Sarah', f'SO confirmed → state={so.state}')
except Exception as e:
fail('Sarah', f'SO confirm raised: {e}')
findings.append(f'SO confirm failure: {e}')
return findings
# ----- side effects: fp.job created? receiving created? -----
Job = env['fp.job']
Receiving = env['fp.receiving']
PortalJob = env['fusion.plating.portal.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
receivings = Receiving.search([('sale_order_id', '=', so.id)])
portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
step('Sarah', f'After confirm: {len(jobs)} fp.job, {len(receivings)} fp.receiving, {len(portal_jobs)} portal.job')
if not jobs:
find('Sarah', 'NO fp.job auto-created on SO confirm! Operator has nothing to work.')
findings.append('SO confirm did not auto-spawn fp.job')
if not receivings:
find('Sarah', 'NO fp.receiving auto-created on SO confirm! Receiver has nothing to track.')
findings.append('SO confirm did not auto-spawn fp.receiving')
if jobs and not portal_jobs:
find('Sarah', 'fp.job exists but no portal.job mirror — customer can\'t track on portal.')
findings.append('Portal job mirror missing post-confirm')
# smart-button visibility check
so._compute_smart_button_visibility()
so._compute_fp_qc_counts()
step('Sarah', f'SO smart buttons: BOM Items visible? {so.x_fc_distinct_part_count >= 2} (count={so.x_fc_distinct_part_count}); '
f'By Job Group visible? {so.x_fc_has_wo_group_tag}; '
f'NCRs visible? {so.fp_qc_ncr_count_so > 0} (count={so.fp_qc_ncr_count_so})')
# ----- Mike receives parts -----
section('PHASE 2 — Mike (Receiver) processes inbound parts')
receiving = receivings[:1]
if not receiving:
receiving = Receiving.create({
'sale_order_id': so.id,
'expected_qty': 25,
})
step('Mike', f'Manually created receiving {receiving.name} (auto-create did not fire)')
find('Mike', 'Had to manually create receiving — auto-create from SO confirm is missing')
findings.append('Auto-receiving on SO confirm not wired')
else:
step('Mike', f'Found auto-created receiving {receiving.name} (state={receiving.state})')
# operator fills carrier + box count
receiving.write({
'carrier_name': 'Purolator Ground',
'carrier_tracking': 'PUR-1Z9999E2E',
'box_count_in': 3,
'received_qty': 25,
})
step('Mike', f'Set box_count_in={receiving.box_count_in}, carrier={receiving.carrier_name}')
# walk the state machine: draft → counted → staged → closed
try:
receiving.action_mark_counted()
step('Mike', f'Marked Counted → state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert receiving.state == 'counted'
assert so.x_fc_receiving_status == 'partial', f'Expected partial after Counted, got {so.x_fc_receiving_status}'
except AssertionError as e:
fail('Mike', str(e))
findings.append(f'Receiving status mismatch after Counted: {e}')
except Exception as e:
fail('Mike', f'action_mark_counted failed: {e}')
findings.append(f'action_mark_counted: {e}')
try:
receiving.action_mark_staged()
step('Mike', f'Marked Staged → state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert receiving.state == 'staged'
assert so.x_fc_receiving_status == 'partial'
except Exception as e:
fail('Mike', f'action_mark_staged failed: {e}')
findings.append(f'action_mark_staged: {e}')
try:
receiving.action_close()
step('Mike', f'Closed receiving → state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert receiving.state == 'closed'
assert so.x_fc_receiving_status == 'received'
except Exception as e:
fail('Mike', f'action_close failed: {e}')
findings.append(f'receiving action_close: {e}')
# racking inspection should exist
if 'fp.racking.inspection' in env:
Inspection = env['fp.racking.inspection']
racks = Inspection.search([('sale_order_id', '=', so.id)])
step('Mike', f'Racking inspections for this SO: {len(racks)}')
if not racks:
find('Mike', 'Racking inspection NOT auto-created — racking crew has nothing to walk.')
findings.append('No racking inspection auto-created post-confirm')
# ----- Carlos works the plating job -----
section('PHASE 3 — Carlos (Operator) walks the plating job')
if not jobs:
fail('Carlos', 'No job to work — SO confirm did not spawn one. Skipping phase.')
else:
job = jobs[0]
step('Carlos', f'Job {job.name}: state={job.state}, qty={job.qty}, deadline={job.date_deadline}')
step('Carlos', f'Steps: {len(job.step_ids)} — recipe={job.recipe_id.name or "(none)"}')
if not job.step_ids:
find('Carlos', f'Job has zero steps! Recipe not assigned or not generated. Recipe field: {job.recipe_id}')
findings.append('Job confirmed with zero steps')
if job.step_ids:
first_step = job.step_ids.sorted('sequence')[0]
step('Carlos', f'Starting step {first_step.sequence}: {first_step.name}')
try:
first_step.button_start()
step('Carlos', f'After start: state={first_step.state}, started_by={first_step.started_by_user_id.name if first_step.started_by_user_id else "(none)"}')
except Exception as e:
fail('Carlos', f'button_start failed: {e}')
findings.append(f'step button_start: {e}')
try:
first_step.button_finish()
step('Carlos', f'After finish: state={first_step.state}, duration_actual={first_step.duration_actual}')
except Exception as e:
fail('Carlos', f'button_finish failed: {e}')
findings.append(f'step button_finish: {e}')
# walk the rest at warp speed
for s in job.step_ids.sorted('sequence')[1:]:
try:
if s.state == 'pending':
s.button_start()
if s.state == 'in_progress':
s.button_finish()
except Exception as e:
fail('Carlos', f'step {s.name} walk: {e}')
findings.append(f'step walk {s.name}: {e}')
done_count = len(job.step_ids.filtered(lambda st: st.state == 'done'))
step('Carlos', f'Walked {done_count}/{len(job.step_ids)} steps to done')
# try to mark job done — should hit QC gate if customer requires QC
wants_qc = 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc
step('Carlos', f'Customer requires QC? {wants_qc}')
try:
job.button_mark_done()
step('Carlos', f'Job done → state={job.state}, finished={job.date_finished}')
except Exception as e:
if wants_qc:
step('Carlos', f'(Expected) QC gate fired: {str(e)[:120]}')
else:
fail('Carlos', f'button_mark_done unexpectedly failed: {e}')
findings.append(f'button_mark_done: {e}')
# ----- Lisa runs QC -----
section('PHASE 4 — Lisa (QC) walks the checklist (if any)')
QC = env['fusion.plating.quality.check']
qcs = QC.search([('job_id', 'in', jobs.ids)]) if jobs else QC.browse()
step('Lisa', f'QC checks for this job: {len(qcs)}')
if jobs and 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc and not qcs:
find('Lisa', 'Customer requires QC but no QC check auto-spawned!')
findings.append('QC gate fired but no check spawned')
for qc in qcs:
step('Lisa', f'QC {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
# try to pass it
for ln in qc.line_ids:
try:
ln.write({'result': 'pass'})
except Exception:
pass
try:
qc.action_pass()
step('Lisa', f'After action_pass: state={qc.state}')
except Exception as e:
fail('Lisa', f'action_pass failed: {e}')
findings.append(f'qc action_pass: {e}')
# retry job done if blocked
if jobs:
job = jobs[0]
if job.state != 'done':
try:
job.button_mark_done()
step('Lisa', f'Job marked done after QC pass → state={job.state}')
except Exception as e:
fail('Lisa', f'Job still blocked: {e}')
findings.append(f'Job blocked post-QC: {e}')
# ----- Tom ships -----
section('PHASE 5 — Tom (Shipper) prepares the delivery')
Delivery = env['fusion.plating.delivery']
deliveries = Delivery.search([
'|', ('job_ref', 'in', jobs.mapped('name') if jobs else []),
('x_fc_job_id', 'in', jobs.ids) if jobs else (False, False, False),
]) if jobs else Delivery.browse()
step('Tom', f'Deliveries linked to this job: {len(deliveries)}')
if jobs and jobs[0].state == 'done' and not deliveries:
find('Tom', 'Job is done but NO delivery auto-created!')
findings.append('Delivery auto-create on job done missing')
for delivery in deliveries:
method = (
getattr(delivery, 'x_fc_delivery_method', None)
or getattr(delivery, 'delivery_method', None)
or '(no method field)'
)
step('Tom', f'Delivery {delivery.name}: state={delivery.state}, method={method}')
try:
if hasattr(delivery, 'action_schedule') and delivery.state == 'draft':
delivery.action_schedule()
step('Tom', f'Scheduled → state={delivery.state}')
except Exception as e:
fail('Tom', f'schedule: {e}')
# certificates
Cert = env['fp.certificate']
certs = Cert.search([('x_fc_job_id', 'in', jobs.ids)]) if jobs else Cert.browse()
step('Tom', f'Certificates for this job: {len(certs)}')
if jobs and jobs[0].state == 'done' and not certs:
find('Tom', 'Job done but NO certificate auto-generated.')
findings.append('Certificate auto-create missing')
# ----- Jane invoices -----
section('PHASE 6 — Jane (Accounting) creates and posts invoice')
invoices_before = env['account.move'].search_count([
('invoice_origin', '=', so.name),
])
try:
if so.invoice_status == 'to invoice':
inv_action = so._create_invoices()
step('Jane', f'Invoiced — {invoices_before}{env["account.move"].search_count([("invoice_origin","=",so.name)])} moves')
else:
step('Jane', f'invoice_status={so.invoice_status} (nothing to invoice)')
except Exception as e:
fail('Jane', f'_create_invoices failed: {e}')
findings.append(f'invoice creation: {e}')
# ----- common-sense edge case sweeps -----
section('PHASE 7 — common-sense edge case sweeps')
# smart-button results: do they actually return non-empty data?
section_name = ' smart-button result probes'
print(section_name)
if jobs:
job = jobs[0]
for action in ('action_view_fp_holds', 'action_view_fp_checks',
'action_view_fp_ncrs', 'action_view_fp_capas',
'action_view_fp_rmas'):
try:
act = getattr(job, action)()
domain = act.get('domain') or []
model = act.get('res_model')
count = env[model].search_count(domain) if model else 0
step('audit', f'{action}: model={model}, domain count={count}')
except Exception as e:
fail('audit', f'{action}: {e}')
findings.append(f'{action}: {e}')
# SO smart-buttons
for action in ('action_view_fp_holds', 'action_view_fp_checks',
'action_view_fp_ncrs_so', 'action_view_fp_capas',
'action_view_fp_rmas'):
try:
act = getattr(so, action)()
domain = act.get('domain') or []
model = act.get('res_model')
count = env[model].search_count(domain) if model else 0
step('audit', f'SO {action}: model={model}, domain count={count}')
except Exception as e:
fail('audit', f'SO {action}: {e}')
findings.append(f'SO {action}: {e}')
# final summary
section('SUMMARY')
if findings:
print(f'{len(findings)} finding(s):')
for i, f in enumerate(findings, 1):
print(f' {i}. {f}')
else:
print(' ✅ No findings — workflow is clean end-to-end.')
env.cr.commit()
return findings
# entry-point: env injected by odoo-shell
try:
findings = e2e(env) # noqa
except Exception as e:
print('FATAL:', e)
traceback.print_exc()

View File

@@ -0,0 +1,60 @@
# Step 1 verification — Direct Order wizard onchange + hold guard fixes.
W = env['fp.direct.order.wizard']
ISD = env['fp.invoice.strategy.default']
P = env['res.partner']
target = P.browse(2529) # 2CM INNOVATIVE
print('Test 1 — customer with NO invoice strategy default:')
ISD.search([('partner_id', '=', target.id)]).unlink()
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy}, payment_term={w.payment_term_id.name if w.payment_term_id else None}')
print('\nTest 2 — customer WITH strategy default but NO payment_term:')
isd = ISD.create({'partner_id': target.id, 'default_strategy': 'net_terms'})
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy} (expect: net_terms)')
print(f' payment_term={w.payment_term_id.name if w.payment_term_id else None}')
isd.unlink()
print('\nTest 3 — customer with strategy + deposit + payment_term:')
pt = env['account.payment.term'].search([], limit=1)
isd = ISD.create({
'partner_id': target.id, 'default_strategy': 'deposit',
'default_deposit_percent': 50.0, 'payment_term_id': pt.id,
})
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy} (expect: deposit)')
print(f' deposit_percent={w.deposit_percent} (expect: 50.0)')
print(f' payment_term={w.payment_term_id.name} (expect: {pt.name})')
isd.unlink()
print('\nTest 4 — account-hold warning fires on partner change:')
target.x_fc_account_hold = True
w = W.new({'partner_id': target.id})
result = w._onchange_partner_id()
warning = (result or {}).get('warning')
print(f' warning title: {warning.get("title") if warning else None}')
print(f' warning msg: {(warning.get("message") or "")[:100] if warning else None}')
print('\nTest 5 — account-hold blocks action_create_order:')
w = W.create({'partner_id': target.id, 'po_pending': True})
# add one line so the line check passes
part = env['fp.part.catalog'].search([], limit=1)
coating = env['fp.coating.config'].search([], limit=1)
env['fp.direct.order.line'].create({
'wizard_id': w.id,
'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 1,
'unit_price': 10.0,
})
try:
w.action_create_order()
print(' ❌ HELD CUSTOMER CREATED ORDER — guard failed')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
target.x_fc_account_hold = False
env.cr.commit()

View File

@@ -0,0 +1,37 @@
# Step 2 verification — picking a part on the wizard line pre-fills coating + treatments.
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
P = env['res.partner']
target = P.browse(2529)
# Pick a part that has a default coating + treatments configured.
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
if not part:
# Build a synthetic one for the test.
coating = Coating.search([], limit=1)
treats = Treat.search([], limit=2)
part = Part.search([], limit=1)
part.x_fc_default_coating_config_id = coating.id
part.x_fc_default_treatment_ids = [(6, 0, treats.ids)]
print(f'Set up part {part.part_number}: default coating={coating.name}, treatments={treats.mapped("name")}')
print(f'Part: {part.part_number} rev {part.revision}')
print(f' default coating: {part.x_fc_default_coating_config_id.name if part.x_fc_default_coating_config_id else None}')
print(f' default treatments: {part.x_fc_default_treatment_ids.mapped("name") if part.x_fc_default_treatment_ids else None}')
# Build a wizard, add an empty line, simulate Sarah picking the part.
w = W.create({'partner_id': target.id})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
print()
print(f'After picking part on line:')
print(f' coating_config_id: {ln.coating_config_id.name if ln.coating_config_id else None}')
print(f' treatment_ids: {ln.treatment_ids.mapped("name") if ln.treatment_ids else None}')
print(f' Pre-fill worked? {bool(ln.coating_config_id) and bool(ln.treatment_ids)}')
env.cr.commit()

View File

@@ -0,0 +1,107 @@
# Step 3 — Sarah hits "Create Order" in wizard, then confirms SO.
# Watch every side-effect: SO state, fp.job auto-spawn, fp.receiving
# auto-spawn, fp.racking.inspection, portal.job mirror, QC check.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529) # 2CM INNOVATIVE
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Build the wizard exactly the way Sarah would after Step 1+2 fixes.
w = W.create({
'partner_id': target.id,
'po_number': 'PO-STEP3-001',
'po_pending': False,
'customer_job_number': 'CUSTJOB-STEP3',
'planned_start_date': fields.Date.today(),
'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
'invoice_strategy': 'net_terms',
'delivery_method': 'shipping_partner',
'po_attachment_file': b'fake-pdf-bytes',
'po_attachment_filename': 'po.pdf',
})
w._onchange_partner_id()
print(f'[Sarah] Created wizard {w.name} for {target.display_name}')
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
ln_vals = ln._convert_to_write({n: ln[n] for n in ln._fields})
ln_vals.update({
'wizard_id': w.id,
'quantity': 25,
'unit_price': 12.50,
'line_description': 'EN plating per part default coating',
'internal_description': 'Standard recipe; bake within 4h.',
})
real_line = Line.create(ln_vals)
print(f'[Sarah] Added line: part={real_line.part_catalog_id.part_number}, '
f'coating={real_line.coating_config_id.name}, qty={real_line.quantity}')
# Hit Create Order.
print('[Sarah] Clicking "Create Order"...')
result = w.action_create_order()
so_id = (result or {}).get('res_id')
SO = env['sale.order']
so = SO.browse(so_id) if so_id else SO.search(
[('x_fc_po_number', '=', 'PO-STEP3-001')], order='id desc', limit=1,
)
print(f' -> SO created: {so.name} (state={so.state})')
# Now confirm the SO (Sarah does this from the SO form, not the wizard).
print('[Sarah] Confirming SO...')
try:
so.action_confirm()
print(f' -> SO state={so.state}, x_fc_receiving_status={so.x_fc_receiving_status}')
except Exception as e:
print(f' ❌ confirm failed: {e}')
env.cr.rollback()
raise SystemExit
# Verify side-effects.
print()
print('=== Side effects of SO confirm ===')
Job = env['fp.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
print(f' fp.job auto-spawn: {len(jobs)} job(s)')
for j in jobs:
print(f' {j.name}: state={j.state}, qty={j.qty}, recipe={j.recipe_id.name or "(no recipe)"}, steps={len(j.step_ids)}')
Receiving = env['fp.receiving']
receivings = Receiving.search([('sale_order_id', '=', so.id)])
print(f' fp.receiving auto-spawn: {len(receivings)} record(s)')
for r in receivings:
print(f' {r.name}: state={r.state}, expected_qty={r.expected_qty}')
if 'fp.racking.inspection' in env:
Inspection = env['fp.racking.inspection']
racks = Inspection.search([('sale_order_id', '=', so.id)])
print(f' fp.racking.inspection auto-spawn: {len(racks)} record(s)')
for ri in racks:
print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
PortalJob = env['fusion.plating.portal.job']
portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
print(f' portal.job mirror: {len(portal_jobs)} record(s)')
for pj in portal_jobs:
print(f' {pj.name}: state={pj.state}')
QC = env['fusion.plating.quality.check']
qcs = QC.search([('job_id', 'in', jobs.ids)])
print(f' QC checks: {len(qcs)} (customer x_fc_requires_qc={getattr(target, "x_fc_requires_qc", "NOFIELD")})')
for qc in qcs:
print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
# x_fc_receiving_status check
print()
print(f' SO x_fc_receiving_status (post-confirm, no receipt yet): {so.x_fc_receiving_status}')
print(f' Expected: not_received (parts haven\'t arrived)')
env.cr.commit()
print()
print(f'== Step 3 complete. SO ID for next steps: {so.id} ==')

View File

@@ -0,0 +1,91 @@
# Step 4 — Mike receives parts. Walk the receiving form, fill every
# visible field, walk the state machine, verify SO status updates at
# every transition.
so = env['sale.order'].browse(423)
recv = env['fp.receiving'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Mike] Looking at receiving {recv.name}: state={recv.state}, expected_qty={recv.expected_qty}')
# Mike sees the form. What's required vs optional?
print()
print('Visible fields on the receiving form (per fp_receiving_views.xml):')
print(f' sale_order_id: {recv.sale_order_id.name} (readonly via related)')
print(f' partner_id: {recv.partner_id.name} (related)')
print(f' po_number: {recv.po_number}')
print(f' box_count_in: {recv.box_count_in} <-- Mike must set this')
print(f' expected_qty: {recv.expected_qty}')
print(f' received_qty: {recv.received_qty} <-- defaults to expected_qty per Sub 8')
print(f' qty_match: {recv.qty_match}')
print(f' carrier_name: {recv.carrier_name} <-- Mike fills this')
print(f' carrier_tracking: {recv.carrier_tracking} <-- Mike fills this')
# Mike fills the carrier + tracking + counts the boxes.
print()
print('[Mike] Filling carrier + tracking + box count...')
recv.write({
'carrier_name': 'Purolator Ground',
'carrier_tracking': 'PUR-1Z9999E2E',
'box_count_in': 3,
'received_qty': 25, # all 25 arrived
'notes': '<p>Truck arrived 10am. Boxes look clean.</p>',
})
print(f' recv.qty_match = {recv.qty_match} (expected vs received)')
print(f' SO status BEFORE marking counted: {so.x_fc_receiving_status}')
# Click "Mark Counted"
print()
print('[Mike] Clicks "Mark Counted"')
try:
recv.action_mark_counted()
print(f' recv.state = {recv.state}')
print(f' recv.received_by_id = {recv.received_by_id.name}')
print(f' SO status AFTER mark counted: {so.x_fc_receiving_status}')
print(f' Expected: partial (boxes on dock, racking pending)')
assert so.x_fc_receiving_status == 'partial', 'SO status should be partial!'
print(' ✓ SO status correctly flipped to partial')
except Exception as e:
print(f'{e}')
# Click "Mark Staged"
print()
print('[Mike] Clicks "Mark Staged"')
try:
recv.action_mark_staged()
print(f' recv.state = {recv.state}')
print(f' SO status: {so.x_fc_receiving_status} (should still be partial)')
assert so.x_fc_receiving_status == 'partial'
print(' ✓ Still partial — racking not done yet')
except Exception as e:
print(f'{e}')
# Mike clicks the new "Racking Inspections" smart button (Round 2 fix)
print()
print('[Mike] Clicks the "Racking Inspections" smart button')
try:
act = recv.action_view_racking_inspections()
Inspection = env['fp.racking.inspection']
racks = Inspection.search(act.get('domain') or [])
print(f' Smart-button opens model={act.get("res_model")}, finds {len(racks)} inspection(s)')
for ri in racks:
print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
except Exception as e:
print(f'{e}')
# At this point Mike's done — racking crew takes over.
# But the receiving stays at "staged" until racking crew finishes
# inspection and someone clicks "Close" on the receiving.
# Let's pretend racking is done and close the receiving.
print()
print('[Mike] (or shop manager) Clicks "Close Receiving" once racking is done')
try:
recv.action_close()
print(f' recv.state = {recv.state}')
print(f' SO status AFTER close: {so.x_fc_receiving_status}')
assert so.x_fc_receiving_status == 'received'
print(' ✓ SO status correctly flipped to received')
except Exception as e:
print(f'{e}')
print()
print(f'== Step 4 complete. SO {so.name} status={so.x_fc_receiving_status}, recv {recv.name} state={recv.state} ==')
env.cr.commit()

View File

@@ -0,0 +1,99 @@
# Step 5 — Carlos walks the plating job. Test BOTH paths:
# A) Try to mark_done with steps still ready → must be blocked
# B) Walk every step → mark_done succeeds
# Build a fresh SO + job (don't reuse 423 — its job is already done).
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP5-001',
'planned_start_date': fields.Date.today(),
'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
'invoice_strategy': 'net_terms',
'delivery_method': 'shipping_partner',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': ln.coating_config_id.id,
'quantity': 10, 'unit_price': 15.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Carlos] Fresh job {job.name} for SO {so.name}')
print(f' Steps: {len(job.step_ids)}, all in state: {set(job.step_ids.mapped("state"))}')
# Path A: try mark_done without walking steps.
print()
print('[Carlos] Try Mark Done WITHOUT walking any step (compliance test):')
try:
job.button_mark_done()
print(' ❌ JOB CLOSED WITH ZERO STEPS WALKED — guard failed')
except Exception as e:
print(f' ✓ blocked: {str(e)[:200]}')
# Path B: walk every step then mark_done.
print()
print('[Carlos] Walk every step, then Mark Done:')
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
print(f' walked {done_count}/{len(job.step_ids)} to done')
try:
job.button_mark_done()
print(f' ✓ Job marked done — state={job.state}, finished={job.date_finished}')
except Exception as e:
print(f' ❌ Mark Done failed AFTER walking: {e}')
# Verify side effects on this job too.
Delivery = env['fusion.plating.delivery']
deliveries = Delivery.search([('x_fc_job_id', '=', job.id)])
Cert = env['fp.certificate']
certs = Cert.search([('x_fc_job_id', '=', job.id)])
print(f' Side effects: {len(deliveries)} delivery, {len(certs)} certificate')
# Path C: manager bypass (admin is a manager).
print()
print('[Mgr] Test manager bypass via context fp_skip_step_gate=True')
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP5-002',
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
Line.create({
'wizard_id': w2.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 15.0,
})
r2 = w2.action_create_order()
so2 = env['sale.order'].browse(r2['res_id'])
so2.action_confirm()
job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
print(f' Created fresh job {job2.name} with {len(job2.step_ids)} unworked steps')
try:
job2.with_context(fp_skip_step_gate=True).button_mark_done()
print(f' ✓ Manager bypass worked — job state={job2.state}')
except Exception as e:
print(f' ❌ Bypass failed: {e}')
env.cr.commit()
print()
print('== Step 5 complete ==')

View File

@@ -0,0 +1,103 @@
# Step 6 — Lisa walks the QC checklist for a customer that requires QC.
# Test:
# A) Customer requires QC but no template configured → spawn fails gracefully?
# B) Customer requires QC + template configured → check auto-spawns on confirm
# C) Lisa walks the checklist, marks lines, action_pass
# D) Job mark_done now lets through
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Tpl = env['fp.qc.checklist.template']
TplLine = env['fp.qc.checklist.template.line']
QC = env['fusion.plating.quality.check']
# Find or create a QC template (default, no partner_id) for the test.
default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
if not default_tpl:
default_tpl = Tpl.create({
'name': 'Default QC Template (E2E)',
'active': True,
})
TplLine.create({'template_id': default_tpl.id, 'sequence': 10, 'name': 'Visual inspection — appearance'})
TplLine.create({'template_id': default_tpl.id, 'sequence': 20, 'name': 'Thickness measurement (Fischerscope)'})
TplLine.create({'template_id': default_tpl.id, 'sequence': 30, 'name': 'Tape adhesion test'})
print(f'[setup] Created default QC template: {default_tpl.name} ({len(default_tpl.line_ids)} lines)')
else:
print(f'[setup] Using existing default QC template: {default_tpl.name}')
# Mark our test customer as requires_qc.
target = P.browse(2529)
target.x_fc_requires_qc = True
print(f'[setup] Set {target.display_name}.x_fc_requires_qc = True')
# Build a fresh SO + job for QC test.
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP6-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Sarah] Confirmed SO {so.name} → job {job.name}')
# Did QC auto-spawn?
qcs = QC.search([('job_id', '=', job.id)])
print(f'[Lisa] QC checks auto-spawned: {len(qcs)}')
for qc in qcs:
print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}, partner_id={qc.partner_id.name}')
if not qcs:
print(' ❌ Customer requires QC but no check spawned!')
raise SystemExit
qc = qcs[0]
# Lisa walks every checklist line.
print()
print('[Lisa] Walks the checklist:')
for ln in qc.line_ids.sorted('sequence'):
print(f' - {ln.name}: result before={ln.result}')
ln.result = 'pass'
ln.notes = 'OK on inspection'
# Try to action_pass.
print()
print('[Lisa] Clicks "Pass":')
try:
qc.action_pass()
print(f' ✓ QC state={qc.state}, overall_result={qc.overall_result}')
except Exception as e:
print(f'{e}')
# Now job mark_done should work (steps need to be walked first).
print()
print('[Carlos+Lisa] Walking steps then marking job done:')
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
try:
job.button_mark_done()
print(f' ✓ Job done — state={job.state}')
except Exception as e:
print(f' ❌ Job mark_done blocked: {e}')
# Reset partner flag for test independence.
target.x_fc_requires_qc = False
env.cr.commit()
print()
print('== Step 6 complete ==')

View File

@@ -0,0 +1,93 @@
# Step 7 — Tom (Shipper) walks the delivery from draft to delivered.
# Test:
# A) Delivery exists post-job-done — what fields visible? what state?
# B) Try action_start_route without driver → must block
# C) Assign driver + vehicle + box count, schedule
# D) Try action_mark_delivered without POD → must block
# E) Capture POD, mark delivered, verify cert + chain of custody
so = env['sale.order'].browse(423) # Step 3's SO
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
Delivery = env['fusion.plating.delivery']
delivery = Delivery.search([('x_fc_job_id', '=', job.id)], limit=1)
print(f'[Tom] Looking at delivery {delivery.name}')
print()
print('Visible header on delivery form:')
print(f' partner_id: {delivery.partner_id.name}')
print(f' delivery_address_id: {delivery.delivery_address_id.name if delivery.delivery_address_id else None}')
print(f' contact_name: {delivery.contact_name}')
print(f' contact_phone: {delivery.contact_phone}')
print(f' job_ref: {delivery.job_ref}')
print(f' state: {delivery.state}')
print(f' scheduled_date: {delivery.scheduled_date}')
print(f' assigned_driver_id: {delivery.assigned_driver_id.name if delivery.assigned_driver_id else None}')
print(f' vehicle_id: {delivery.vehicle_id.name if delivery.vehicle_id else None}')
print(f' source_facility_id: {delivery.source_facility_id.name if delivery.source_facility_id else None}')
print(f' tdg_required: {delivery.tdg_required}')
print(f' pod_id: {delivery.pod_id.name if delivery.pod_id else None}')
# Tom schedules.
print()
print('[Tom] Clicks "Schedule"')
delivery.action_schedule()
print(f' state={delivery.state}')
# Tom tries to start route WITHOUT assigning a driver.
print()
print('[Tom] Tries Start Route without driver:')
try:
delivery.action_start_route()
print(' ❌ Got past driver gate without assignment!')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
# Assign a driver (any user).
driver = env.user
delivery.assigned_driver_id = driver.id
delivery.x_fc_box_count_out = 3
print()
print(f'[Tom] Assigned driver: {driver.name}, box_count_out=3')
# Now start route.
print()
print('[Tom] Clicks Start Route:')
try:
delivery.action_start_route()
print(f' state={delivery.state}')
print(f' custody events: {delivery.custody_event_count}')
except Exception as e:
print(f'{e}')
# Tom tries to mark delivered without POD.
print()
print('[Tom] Tries Mark Delivered without POD:')
try:
delivery.action_mark_delivered()
print(' ❌ Got past POD gate without capture!')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
# Tom captures POD.
POD = env['fusion.plating.proof.of.delivery']
pod = POD.create({
'delivery_id': delivery.id,
'recipient_name': 'Mark at receiving',
})
delivery.pod_id = pod.id
print()
print(f'[Tom] Captured POD: {pod.name}, recipient="{pod.recipient_name}"')
# Mark delivered.
print()
print('[Tom] Clicks Mark Delivered:')
try:
delivery.action_mark_delivered()
print(f' state={delivery.state}, delivered_at={delivery.delivered_at}')
print(f' custody events: {delivery.custody_event_count}')
except Exception as e:
print(f'{e}')
env.cr.commit()
print()
print('== Step 7 complete ==')

View File

@@ -0,0 +1,58 @@
# Step 8 re-verify — fresh SO with net_terms strategy should now get
# Net-30 payment term auto-filled, and the invoice should post.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
P = env['res.partner']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP8RV-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
w._onchange_invoice_strategy() # also fires _apply_strategy_payment_term
print(f'[Sarah] After invoice_strategy=net_terms, payment_term_id={w.payment_term_id.name if w.payment_term_id else None}')
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 4, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
print(f'[Sarah] SO {so.name} created, payment_term_id={so.payment_term_id.name if so.payment_term_id else None}')
so.action_confirm()
print(f'[Sarah] Confirmed → state={so.state}')
# Walk job to done so it's invoiceable.
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.button_mark_done()
print(f'[Carlos] Job {job.name} done')
# Jane invoices.
print()
print('[Jane] Creating + posting invoice:')
result = so._create_invoices()
inv = env['account.move'].search([('invoice_origin', '=', so.name)], order='id desc', limit=1)
print(f' Invoice {inv.name or "(unnamed draft)"}: state={inv.state}, payment_term={inv.invoice_payment_term_id.name if inv.invoice_payment_term_id else None}')
try:
inv.action_post()
print(f' ✓ Posted: state={inv.state}, payment_state={inv.payment_state}')
print(f' ✓ Invoice name: {inv.name}, due date: {inv.invoice_date_due}')
except Exception as e:
print(f'{e}')
env.cr.commit()
print()
print('== Step 8 re-verify complete ==')

View File

@@ -0,0 +1,76 @@
# Step 8 — Jane creates the invoice for the completed SO and posts it.
# Test:
# A) SO has invoice_status = 'to invoice' after delivery
# B) Jane creates the invoice
# C) Invoice draft has correct lines, taxes, payment terms
# D) Jane posts → invoice posted, account moves balanced
# E) Notification fires (best-effort)
so = env['sale.order'].browse(423)
print(f'[Jane] Looking at SO {so.name}')
print(f' state: {so.state}')
print(f' invoice_status: {so.invoice_status}')
print(f' amount_total: {so.amount_total} {so.currency_id.symbol}')
print(f' payment_term_id: {so.payment_term_id.name if so.payment_term_id else None}')
print(f' x_fc_invoice_strategy: {so.x_fc_invoice_strategy}')
print(f' partner.account hold? {getattr(so.partner_id, "x_fc_account_hold", False)}')
# What's already invoiced?
existing = env['account.move'].search([
('invoice_origin', '=', so.name),
])
print(f' Existing invoices for this SO: {len(existing)}')
for inv in existing:
print(f' {inv.name}: state={inv.state}, type={inv.move_type}, amount={inv.amount_total}')
# Path A: create invoices.
print()
print('[Jane] Clicks "Create Invoice"')
if so.invoice_status == 'to invoice':
try:
result = so._create_invoices()
new_invs = env['account.move'].search([
('invoice_origin', '=', so.name), ('id', 'not in', existing.ids),
])
print(f' Created {len(new_invs)} new invoice(s)')
for inv in new_invs:
print(f' {inv.name}: state={inv.state}, lines={len(inv.invoice_line_ids)}')
for ln in inv.invoice_line_ids:
print(f' - {ln.name[:50]}: qty={ln.quantity}, price={ln.price_unit}, subtotal={ln.price_subtotal}')
except Exception as e:
print(f'{e}')
else:
print(f' Skipped — invoice_status={so.invoice_status} (nothing to invoice)')
new_invs = env['account.move'].browse()
# Path B: post.
if new_invs:
inv = new_invs[0]
print()
print(f'[Jane] Posting invoice {inv.name}:')
try:
inv.action_post()
print(f' ✓ state={inv.state}')
print(f' payment_state={inv.payment_state}')
except Exception as e:
print(f'{e}')
# Verify the SO progress: invoice_status should now show 'invoiced'
print()
print(f'[Jane] After posting:')
print(f' SO invoice_status: {so.invoice_status}')
print(f' Outstanding receivables on partner: {sum(env["account.move"].search([("partner_id", "=", so.partner_id.id), ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "in", ("not_paid", "partial"))]).mapped("amount_residual"))}')
# Notification check.
print()
print(f'[Jane] Notification logs for this SO/invoice:')
NotifLog = env['fp.notification.log'] if 'fp.notification.log' in env else None
if NotifLog and new_invs:
logs = NotifLog.search([('source_record_id', 'in', new_invs.ids)])
print(f' {len(logs)} notification log(s)')
for lg in logs:
print(f' {lg.trigger_event}{lg.partner_id.name if lg.partner_id else "(no partner)"} sent_at={lg.sent_at if "sent_at" in lg._fields else "?"}')
env.cr.commit()
print()
print('== Step 8 complete ==')

View File

@@ -0,0 +1,138 @@
# Verify the auto-push-to-defaults behaviour.
#
# Four scenarios:
# A) Brand-new part (no defaults) → push_to_defaults auto-ticks +
# warning popup fires
# B) Existing part WITH defaults → push_to_defaults stays False (no
# surprise overwrite)
# C) Brand-new part flagged is_one_off → push_to_defaults stays False
# D) End-to-end: enter order with new part → confirm → second order
# with same part auto-pre-fills coating + treatments
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
P = env['res.partner']
target = P.browse(2529)
coating = Coating.search([], limit=1)
treats = Treat.search([], limit=2)
# ====================================================================== A
print('='*72)
print('Scenario A — Brand-new part (no defaults)')
print('='*72)
fresh = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-A-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Fresh part for auto-default test',
})
w = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = fresh
result = ln._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln.push_to_defaults} (expect True)')
print(f' is_one_off: {ln.is_one_off}')
print(f' warning fired? {bool(result and result.get("warning"))}')
if result and result.get('warning'):
w_msg = result['warning']
print(f' title: {w_msg["title"]}')
print(f' message: {w_msg["message"][:90]}...')
# ====================================================================== B
print()
print('='*72)
print('Scenario B — Existing part WITH defaults already set')
print('='*72)
existing = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
print(f' Using part: {existing.display_name} (default coating={existing.x_fc_default_coating_config_id.name})')
w2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w2._onchange_partner_id()
ln2 = Line.new({'wizard_id': w2.id})
ln2.part_catalog_id = existing
result2 = ln2._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln2.push_to_defaults} (expect False — defaults already exist)')
print(f' pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
print(f' warning fired? {bool(result2 and result2.get("warning"))} (expect False)')
# ====================================================================== C
print()
print('='*72)
print('Scenario C — Brand-new part flagged is_one_off (don\'t persist)')
print('='*72)
fresh3 = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-C-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
})
w3 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w3._onchange_partner_id()
ln3 = Line.new({'wizard_id': w3.id, 'is_one_off': True})
ln3.part_catalog_id = fresh3
result3 = ln3._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln3.push_to_defaults} (expect False — is_one_off blocks)')
print(f' warning fired? {bool(result3 and result3.get("warning"))} (expect False)')
# ====================================================================== D
print()
print('='*72)
print('Scenario D — End-to-end: order #1 saves defaults, order #2 pre-fills')
print('='*72)
fresh_d = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-D-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
})
print(f' Created fresh part: {fresh_d.part_number}')
# ORDER #1
w_d = W.create({'partner_id': target.id, 'po_pending': True, 'po_number': 'PO-AUTO-D-1', 'invoice_strategy': 'net_terms'})
w_d._onchange_partner_id()
ln_d = Line.new({'wizard_id': w_d.id})
ln_d.part_catalog_id = fresh_d
ln_d._onchange_part_clears_variant()
print(f' Order #1 line — auto-ticked push_to_defaults: {ln_d.push_to_defaults}')
# Sarah picks the coating + treatments she wants
saved = Line.create({
'wizard_id': w_d.id, 'part_catalog_id': fresh_d.id,
'coating_config_id': coating.id,
'treatment_ids': [(6, 0, treats.ids)],
'push_to_defaults': True,
'quantity': 5, 'unit_price': 12.0,
})
print(f' Sarah picked coating={coating.name}, treatments={treats.mapped("name")}')
# Confirm
result = w_d.action_create_order()
print(f' Order created: {env["sale.order"].browse(result["res_id"]).name}')
# Re-fetch the part
fresh_d.invalidate_recordset()
print(f' Part defaults after order #1:')
print(f' x_fc_default_coating_config_id: {fresh_d.x_fc_default_coating_config_id.name if fresh_d.x_fc_default_coating_config_id else "(none)"}')
print(f' x_fc_default_treatment_ids: {fresh_d.x_fc_default_treatment_ids.mapped("name") if fresh_d.x_fc_default_treatment_ids else "(none)"}')
# ORDER #2 — Sarah picks the same part again
print()
print(' Order #2 — Sarah picks the same part:')
w_d2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w_d2._onchange_partner_id()
ln_d2 = Line.new({'wizard_id': w_d2.id})
ln_d2.part_catalog_id = fresh_d
ln_d2._onchange_part_clears_variant()
print(f' Pre-filled coating: {ln_d2.coating_config_id.name if ln_d2.coating_config_id else "(none)"}')
print(f' Pre-filled treatments: {ln_d2.treatment_ids.mapped("name") if ln_d2.treatment_ids else "(none)"}')
print(f' push_to_defaults: {ln_d2.push_to_defaults} (expect False — defaults exist)')
if ln_d2.coating_config_id == coating:
print(f' ✓ Order #2 correctly auto-filled from order #1\'s saved defaults')
else:
print(f' ❌ Order #2 did NOT pre-fill from order #1\'s defaults')
env.cr.commit()
print()
print('== Auto-default test complete ==')

View File

@@ -0,0 +1,171 @@
# Comprehensive internal-process walk.
#
# Phases:
# A) Pause / resume — multiple intervals merge into duration_actual
# B) Skip an opt-in step
# C) Skipped steps don't block job mark-done
# D) Wet plating step finish auto-spawns bake.window with right window_hours
# E) Bake-window state evolves (awaiting_bake → bake_in_progress → baked)
# F) Failure: try to start a step already done
import time
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
# Find or build a coating that requires bake relief.
coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
if not coating:
coating = Coating.search([], limit=1)
coating.requires_bake_relief = True
coating.bake_window_hours = 4.0
coating.bake_temperature = 375.0
coating.bake_temperature_uom = 'F'
coating.bake_duration_hours = 4.0
print(f'[setup] Configured {coating.name} to require bake relief (4h window @ 375°F for 4h)')
else:
print(f'[setup] Using existing bake-required coating: {coating.name} ({coating.bake_window_hours}h window)')
# Build a part using this coating as default.
part = Part.create({
'partner_id': target.id,
'part_number': 'INT-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Internal-process test bracket',
'substrate_material': 'steel',
'x_fc_default_coating_config_id': coating.id,
})
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-INT-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 22.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[setup] Job {job.name} with {len(job.step_ids)} steps')
# ====================================================================== A
print()
print('='*72)
print('A — Pause + resume on a step. Multiple intervals must merge.')
print('='*72)
masking = job.step_ids.sorted('sequence')[0]
masking.button_start()
print(f' start → state={masking.state}, open logs={len(masking.time_log_ids)}')
time.sleep(2)
masking.button_pause()
print(f' pause → state={masking.state}, logs={len(masking.time_log_ids)}, '
f'log[0]={masking.time_log_ids[0].duration_minutes:.3f} min')
time.sleep(2)
masking.button_start() # resume
print(f' resume → state={masking.state}, logs={len(masking.time_log_ids)}')
time.sleep(2)
masking.button_finish()
print(f' finish → state={masking.state}, logs={len(masking.time_log_ids)}')
total = sum(masking.time_log_ids.mapped('duration_minutes'))
print(f' duration_actual={masking.duration_actual:.3f} min (sum of logs={total:.3f} min)')
if abs(masking.duration_actual - total) < 0.001:
print(f' ✓ Pause/resume merged correctly')
else:
print(f' ❌ Mismatch')
# ====================================================================== B
print()
print('='*72)
print('B — Skip an opt-in step')
print('='*72)
racking = job.step_ids.sorted('sequence')[1]
print(f' Step: {racking.name} state={racking.state}')
racking.button_skip()
print(f' After Skip: state={racking.state}')
if racking.state == 'skipped':
print(f' ✓ Skip works')
# ====================================================================== C — walk rest, then mark-done
print()
print('='*72)
print('C — Walk remaining steps (some will spawn bake-window). Mark job done.')
print('='*72)
spawn_count_before = env['fusion.plating.bake.window'].search_count([])
for s in job.step_ids.sorted('sequence'):
if s.state in ('done', 'skipped', 'cancelled'):
continue
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
spawn_count_after = env['fusion.plating.bake.window'].search_count([])
created_bw = spawn_count_after - spawn_count_before
print(f' Walked all remaining steps to done')
print(f' Bake windows spawned during walk: {created_bw}')
bws = env['fusion.plating.bake.window'].search([('part_ref', '=', job.name)])
for bw in bws:
print(f' {bw.name}: state={bw.state}, plate_exit={bw.plate_exit_time}, required_by={bw.bake_required_by}, time_remaining={bw.time_remaining_display}')
# ====================================================================== D — try to mark job done
print()
print('='*72)
print('D — Mark job done (skipped+done steps both count as terminal)')
print('='*72)
try:
job.button_mark_done()
print(f' ✓ Job done — state={job.state}')
except Exception as e:
print(f'{e}')
# ====================================================================== E — bake-window lifecycle
if bws:
bw = bws[0]
print()
print('='*72)
print('E — Bake-window lifecycle: start → end')
print('='*72)
print(f' Before start: state={bw.state}, color={bw.status_color}')
bw.action_start_bake()
print(f' After start_bake: state={bw.state}, bake_start={bw.bake_start_time}, color={bw.status_color}')
time.sleep(1)
bw.action_end_bake()
print(f' After end_bake: state={bw.state}, bake_end={bw.bake_end_time}, duration_h={bw.bake_duration_hours:.4f}')
# ====================================================================== F — failure: start a done step
print()
print('='*72)
print('F — Failure paths')
print('='*72)
done_step = job.step_ids.filtered(lambda s: s.state == 'done')[:1]
if done_step:
try:
done_step.button_start()
print(f' ❌ Allowed re-start of a done step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
# Try to skip an already-done step
try:
done_step.button_skip()
print(f' ❌ Allowed skip of done step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
env.cr.commit()
print()
print('== Internal-process walk complete ==')

View File

@@ -0,0 +1,146 @@
# Internal-process walk — test time tracking, pause, skip, bake-window
# auto-spawn, duration overrun. Persona: Carlos (operator) walking the
# tablet station for a real plating job.
#
# Goals:
# 1) Time tracking captures every start/stop interval correctly
# 2) Multiple intervals (start/finish/start/finish) sum to duration_actual
# 3) Pause / resume flow works (currently NOT implemented — gap to fix)
# 4) Skip flow works for opt-in steps (currently NOT implemented)
# 5) Wet plating step finishing auto-spawns a bake.window when the
# coating requires hydrogen embrittlement relief
# 6) Bake-window state machine reflects elapsed time
import time
from datetime import timedelta
from odoo import fields
# Set up a fresh job to walk.
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-INTERNAL-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 18.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[setup] Fresh job {job.name} with {len(job.step_ids)} steps')
# ====================================================================== STEP 1
print()
print('='*72)
print('STEP 1 — Carlos opens the first step on the tablet, clicks Start')
print('='*72)
first = job.step_ids.sorted('sequence')[0]
print(f' Step: {first.name} (kind={first.kind}, state={first.state})')
print(f' duration_expected: {first.duration_expected} min')
before = fields.Datetime.now()
first.button_start()
print(f' After Start: state={first.state}, date_started={first.date_started}, started_by={first.started_by_user_id.name}')
print(f' Open time-log rows: {len(first.time_log_ids.filtered(lambda l: not l.date_finished))}')
# ====================================================================== STEP 2
print()
print('='*72)
print('STEP 2 — Carlos works for 6 seconds, then clicks Finish')
print('='*72)
time.sleep(6)
first.button_finish()
print(f' After Finish: state={first.state}, date_finished={first.date_finished}')
print(f' Time-log rows: {len(first.time_log_ids)}')
for log in first.time_log_ids:
print(f' - {log.user_id.name} {log.date_started}{log.date_finished or "OPEN"} = {log.duration_minutes:.3f} min')
print(f' duration_actual: {first.duration_actual:.3f} min')
print(f' ✓ Single interval captured cleanly')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Test pause/resume on the next step (currently NotImplementedError)')
print('='*72)
second = job.step_ids.sorted('sequence')[1]
second.button_start()
print(f' Started step: {second.name} (state={second.state})')
print(f' Carlos now needs a smoke break — clicks Pause')
try:
second.button_pause()
print(f' ✓ Paused: state={second.state}, open timelog={len(second.time_log_ids.filtered(lambda l: not l.date_finished))}')
except NotImplementedError as e:
print(f' ❌ button_pause not implemented: {e}')
except Exception as e:
print(f'{type(e).__name__}: {e}')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Test skip (currently NotImplementedError)')
print('='*72)
third = job.step_ids.sorted('sequence')[2]
print(f' Step: {third.name}, state={third.state}')
print(f' Planner wants to skip this opt-in step')
try:
third.button_skip()
print(f' ✓ Skipped: state={third.state}')
except NotImplementedError as e:
print(f' ❌ button_skip not implemented: {e}')
except Exception as e:
print(f'{type(e).__name__}: {e}')
# ====================================================================== STEP 5
print()
print('='*72)
print('STEP 5 — Wet plating step finishes, does a bake.window auto-spawn?')
print('='*72)
# Find a step with kind='wet' (or use step #4 as plating analog)
wet_step = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not wet_step:
wet_step = job.step_ids.sorted('sequence')[3:4]
print(f' Using as plating step: {wet_step.name} (kind={wet_step.kind})')
coating = job.coating_config_id
print(f' Coating: {coating.name}')
print(f' coating.requires_bake_relief: {coating.requires_bake_relief}')
print(f' coating.bake_window_hours: {coating.bake_window_hours}')
# Count bake.window before
BW = env['fusion.plating.bake.window']
bw_before = BW.search_count([('part_ref', '=', job.name)])
print(f' Bake windows for this job BEFORE finish: {bw_before}')
# Skip if currently in_progress (it is — paused step #2 still open)
if wet_step.state in ('pending', 'ready'):
wet_step.button_start()
if wet_step.state == 'in_progress':
wet_step.button_finish()
print(f' After Finish: state={wet_step.state}')
bw_after = BW.search_count([('part_ref', '=', job.name)])
print(f' Bake windows for this job AFTER finish: {bw_after}')
if coating.requires_bake_relief and bw_after == bw_before:
print(f' ❌ Coating requires bake relief BUT no bake.window was auto-created!')
elif not coating.requires_bake_relief:
print(f' (coating doesn\'t require bake relief — auto-spawn would skip anyway)')
else:
print(f' ✓ Bake window spawned')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,158 @@
# Walk: Sarah opens Direct Order, creates a brand-new part inline, attaches a process.
#
# Personas:
# Sarah (CSR) — driving the wizard
#
# What we're testing:
# 1) Wizard now allows creating a new part (no_quick_create lets the
# "Create and edit..." popup through)
# 2) Sarah enters a brand-new part number for the customer
# 3) Sarah picks coating + treatments
# 4) Variant dropdown is empty for the brand-new part (no variants yet)
# 5) On confirm, the part is saved to catalog + the SO line links to it
# 6) The job uses the coating's recipe as fallback (no variant means
# coating.recipe_id wins)
# 7) Sarah can THEN go to the part form, hit Compose, attach 1+ variants,
# and the next order can pick one
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
P = env['res.partner']
target = P.browse(2529) # Cyclone Manufacturing
default_coating = Coating.search([], limit=1)
print(f'[Sarah] Customer: {target.display_name}')
print(f'[Sarah] Picking coating: {default_coating.name}')
print()
# ====================================================================== STEP 2
print('='*72)
print('STEP 2 — Sarah opens wizard, hits "Create and edit..." on Part field')
print('='*72)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-NEWPART-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
print(f'[Sarah] Wizard {w.name} created.')
# In the UI, Sarah types a new part number → dropdown shows nothing →
# clicks "Create and edit..." → popup opens with partner pre-filled →
# fills part_number + name + revision (default A) → saves.
# Programmatic equivalent: just create the part directly.
new_part = Part.create({
'partner_id': target.id,
'part_number': 'NEW-INLINE-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Inline-created bracket',
'substrate_material': 'aluminium',
})
print(f'[Sarah] Filled popup → created part: {new_part.display_name}')
print(f' partner_id correctly set: {new_part.partner_id.name}')
print(f' part_number: {new_part.part_number}')
print(f' revision: {new_part.revision}')
print(f' default_process_id: {new_part.default_process_id.name if new_part.default_process_id else "(none — no variants composed yet)"}')
print(f' process_variant_count: {new_part.process_variant_count}')
# Now Sarah adds a line with the new part.
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = new_part
ln._onchange_part_clears_variant()
print()
print(f'[Sarah] Adds line with new part:')
print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none — new part has no defaults)"}')
print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
# Sarah picks coating manually (since new part has no defaults).
print(f'[Sarah] Manually picks coating: {default_coating.name}')
# Save the line.
real_line = Line.create({
'wizard_id': w.id,
'part_catalog_id': new_part.id,
'coating_config_id': default_coating.id,
'quantity': 8,
'unit_price': 18.0,
})
# Check variant dropdown availability
print()
print(f'[Sarah] Variant dropdown for new part:')
print(f' Available variants: {len(new_part.process_variant_ids)} (expect 0 — none composed yet)')
print(f' → Sarah leaves variant blank; coating.recipe_id will drive job')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Sarah confirms order, verify part landed in catalog + job uses coating recipe')
print('='*72)
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
print(f'[Sarah] SO {so.name} created.')
# Confirm
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Sarah] Confirmed → job {job.name}')
print(f' job.partner_id: {job.partner_id.name}')
print(f' job.part_catalog_id: {job.part_catalog_id.display_name}')
print(f' job.coating_config_id: {job.coating_config_id.name}')
print(f' job.recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
print(f' → Coating recipe used as fallback (correct, no variant picked)')
# Verify part is in catalog
print()
fetched = Part.search([('part_number', '=', new_part.part_number)], limit=1)
print(f' Part survives in catalog: {fetched.display_name} (id={fetched.id})')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Bob attaches a variant to the new part (compose flow)')
print('='*72)
from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
import _clone_subtree
Node = env['fusion.plating.process.node']
template = Node.search([
('node_type', '=', 'recipe'),
('parent_id', '=', False),
('part_catalog_id', '=', False),
], limit=1)
v1 = _clone_subtree(env, template, new_part, parent=False)
v1.variant_label = 'Standard'
v1.is_default_variant = True
new_part.default_process_id = v1.id
print(f'[Bob] Composed 1 variant: "{v1.variant_label}" (root id={v1.id})')
new_part.invalidate_recordset()
print(f' process_variant_count now: {new_part.process_variant_count}')
print(f' default_process_id: {new_part.default_process_id.name}')
# Now Sarah enters a SECOND order — this time variant dropdown should show "Standard"
print()
print('='*72)
print('STEP 5 — Sarah enters a follow-up order; variant dropdown should now show "Standard"')
print('='*72)
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-NEWPART-2-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
ln2 = Line.new({'wizard_id': w2.id})
ln2.part_catalog_id = new_part
ln2._onchange_part_clears_variant()
print(f'[Sarah] Picked the same part again. Variant dropdown:')
for v in new_part.process_variant_ids:
flag = '' if v.is_default_variant else ' '
print(f' {flag} {v.variant_label or v.name}')
print(f' Pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,190 @@
# Walk part creation + 4 process variants step by step.
# Personas:
# Bob (Estimator) — owns the part catalog, designs process variants
# Sarah (CSR) — picks a variant on order entry
#
# Goal: prove that
# 1) Bob can create a part
# 2) Bob can attach 4 distinct process variants via the Composer flow
# 3) One is flagged default; switching default works
# 4) Sarah opens a Direct Order, picks the part — variant dropdown lists ALL FOUR
# 5) Sarah picks a non-default variant; the SO + job actually use it
from odoo import fields
from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
import _list_variants, _clone_subtree
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
Node = env['fusion.plating.process.node']
Tpl = Node # template recipes are also fp.process.node records
# ====================================================================== STEP 2
print('='*72)
print('STEP 2 — Bob creates a brand-new part')
print('='*72)
target_partner = P.browse(2529) # 2CM INNOVATIVE
default_coating = Coating.search([], limit=1)
default_treats = Treat.search([], limit=2)
part = Part.create({
'partner_id': target_partner.id,
'part_number': 'E2E-VAR-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'E2E variant test bracket',
'substrate_material': 'aluminium',
'surface_area': 12.5,
'surface_area_uom': 'sq_in',
'weight': 0.45,
'complexity': 'simple',
'masking_zones': 1,
'x_fc_default_coating_config_id': default_coating.id,
'x_fc_default_treatment_ids': [(6, 0, default_treats.ids)],
})
print(f'[Bob] Created part: {part.display_name} (id={part.id})')
print(f' default coating: {part.x_fc_default_coating_config_id.name}')
print(f' default treatments: {default_treats.mapped("name")}')
print(f' process_variant_count (BEFORE adding any): {part.process_variant_count}')
# Find a shared template recipe to clone from. Templates = fp.process.node
# records with node_type='recipe', parent_id=False, part_catalog_id=False.
template = Node.search([
('node_type', '=', 'recipe'),
('parent_id', '=', False),
('part_catalog_id', '=', False),
], limit=1)
if not template:
print(' ❌ No shared template recipes available — cannot continue!')
raise SystemExit
print(f'[Bob] Will clone from shared template: {template.name} ({len(template.child_ids)} root children)')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Bob adds variant #1: Standard Production')
print('='*72)
v1 = _clone_subtree(env, template, part, parent=False)
v1.variant_label = 'Standard Production'
v1.is_default_variant = True
part.default_process_id = v1.id
print(f'[Bob] Created variant: {v1.variant_label} (root node id={v1.id}, name="{v1.name}")')
print(f' is_default: {v1.is_default_variant}')
print(f' child nodes cloned: {len(v1.child_ids)}')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Bob adds variant #2: Aerospace Cert (AS9100)')
print('='*72)
v2 = _clone_subtree(env, template, part, parent=False)
v2.variant_label = 'Aerospace Cert (AS9100)'
print(f'[Bob] Created variant: {v2.variant_label} (root id={v2.id})')
print(f' is_default: {v2.is_default_variant} (correct — first one stays default)')
# ====================================================================== STEP 5
print()
print('='*72)
print('STEP 5 — Bob adds variant #3: Quick-turn (no bake)')
print('='*72)
v3 = _clone_subtree(env, template, part, parent=False)
v3.variant_label = 'Quick-turn (no bake)'
print(f'[Bob] Created variant: {v3.variant_label} (root id={v3.id})')
# ====================================================================== STEP 6
print()
print('='*72)
print('STEP 6 — Bob adds variant #4: Heavy build (wear)')
print('='*72)
v4 = _clone_subtree(env, template, part, parent=False)
v4.variant_label = 'Heavy build (wear)'
print(f'[Bob] Created variant: {v4.variant_label} (root id={v4.id})')
# Refresh the part and inspect what the form would show.
part.invalidate_recordset()
print()
print(f'[Bob] After 4 adds — part {part.display_name}:')
print(f' process_variant_count: {part.process_variant_count}')
print(f' default_process_id: {part.default_process_id.name if part.default_process_id else None}')
print(f' Variants list (per Composer endpoint /fp/part/composer/state):')
for entry in _list_variants(part):
flag = '★ default' if entry['is_default'] else ' '
print(f' {flag} id={entry["id"]:>5} "{entry["label"]}"{entry["node_count"]} nodes')
# ====================================================================== STEP 7
print()
print('='*72)
print('STEP 7 — Sarah enters a Direct Order, picks the part, picks a variant')
print('='*72)
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
w = W.create({
'partner_id': target_partner.id, 'po_pending': True,
'po_number': 'PO-VARTEST-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
# Sarah adds a line, picks the part. Onchange should pre-fill default coating.
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
print(f'[Sarah] Picked part {part.part_number}.')
print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none)"}')
print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
# What variants would the dropdown show? Inspect process_variant_id field domain.
print()
print(f'[Sarah] Looking at the Variant dropdown on the line:')
# Domain on x_fc_process_variant_id (defined on sale.order.line) is part-scoped.
# For the wizard line it's process_variant_id with the same domain.
visible_variants = part.process_variant_ids
print(f' Domain: part_scoped (id, child_of, ...). Visible variants: {len(visible_variants)}')
for v in visible_variants:
flag = '' if v.is_default_variant else ' '
print(f' {flag} {v.variant_label or v.name} (id={v.id})')
# Sarah picks variant #3 (Quick-turn).
ln.process_variant_id = v3
print()
print(f'[Sarah] Picked variant: {ln.process_variant_id.variant_label}')
# Persist via Line.create with the chosen variant.
new_line = Line.create({
'wizard_id': w.id,
'part_catalog_id': part.id,
'coating_config_id': default_coating.id,
'process_variant_id': v3.id,
'quantity': 5,
'unit_price': 25.0,
})
print(f' Saved line: process_variant_id={new_line.process_variant_id.variant_label}')
# ====================================================================== STEP 8
print()
print('='*72)
print('STEP 8 — Confirm SO; verify the JOB uses variant #3, not the default')
print('='*72)
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
print(f'[Sarah] SO created: {so.name}')
# Inspect the SO line's variant.
sol = so.order_line[:1]
print(f' SO line process_variant_id: {sol.x_fc_process_variant_id.variant_label if sol.x_fc_process_variant_id else "(none)"}')
# Confirm the SO.
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f' Job created: {job.name}')
print(f' Job recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
print(f' EXPECTED: recipe_id should match variant #3 root (id={v3.id}, name="{v3.name}")')
print(f' ACTUAL: recipe_id={job.recipe_id.id} (name="{job.recipe_id.name}")')
if job.recipe_id.id == v3.id:
print(f' ✓ Job correctly inherited the picked variant')
else:
print(f' ❌ Job did NOT use the picked variant! Recipe is {job.recipe_id.name}, expected {v3.name}')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
# End-to-end order walkthrough — simulates each role on the shop floor.
#
# Run via odoo-shell:
# echo 'exec(open("/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py").read())' \
# | /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
#
# Each step prints what the employee would see / type. Failures and
# missing affordances are printed with [GAP] tags.
import logging
_log = logging.getLogger(__name__)
GAPS = []
def gap(role, where, msg):
GAPS.append((role, where, msg))
print(f' [GAP] {role} @ {where}: {msg}')
def walk():
e = env # noqa -- env injected by odoo-shell
print('====================== E2E ORDER WALKTHROUGH ======================')
# ------------------------------------------------------------------
# ROLE: Sales / Estimator — open Plating > Sales > Quotations
# ------------------------------------------------------------------
print('\n[ROLE: Estimator] Plating > Sales > Quotations > New Quote')
# 1. Pick or create a customer
Partner = e['res.partner']
customer = Partner.search([('customer_rank', '>', 0)], limit=1)
if not customer:
gap('Estimator', 'res.partner', 'No customers in DB at all')
return
print(f' Customer chosen: {customer.display_name} (id={customer.id})')
# 2. Pick a part from the catalog (or create on the fly)
Part = e.get('fp.part.catalog') or (
e['fp.part.catalog'] if 'fp.part.catalog' in e else None
)
if Part is None or 'fp.part.catalog' not in e:
gap('Estimator', 'fp.part.catalog', 'Part catalog model missing')
return
Part = e['fp.part.catalog']
part = Part.search([], limit=1)
if not part:
gap('Estimator', 'fp.part.catalog',
'No parts in catalog — estimator has nothing to quote against')
return
print(f' Part chosen: {part.display_name} '
f'(part#={getattr(part, "part_number", "?")} '
f'rev={getattr(part, "revision", "?")})')
# 2a. Required-field walk (Sub 2 made part_number + revision required)
for f in ('part_number', 'revision', 'name'):
if f not in part._fields:
gap('Estimator', f'fp.part.catalog.{f}', 'field missing')
elif not part[f]:
gap('Estimator', f'fp.part.catalog.{f}',
f'value blank on existing record')
# 3. Pick a coating config
if 'fp.coating.config' not in e:
gap('Estimator', 'fp.coating.config', 'coating config model missing')
return
coating = e['fp.coating.config'].search([], limit=1)
if not coating:
gap('Estimator', 'fp.coating.config',
'No coating configs defined — estimator cannot configure quote')
else:
print(f' Coating chosen: {coating.display_name}')
# 4. Try to create a quote configurator session (the "New Quote" wizard)
if 'fp.quote.configurator' not in e:
gap('Estimator', 'fp.quote.configurator', 'configurator model missing')
return
Configurator = e['fp.quote.configurator']
cfg_vals = {
'partner_id': customer.id,
}
if 'part_catalog_id' in Configurator._fields and part:
cfg_vals['part_catalog_id'] = part.id
if 'coating_config_id' in Configurator._fields and coating:
cfg_vals['coating_config_id'] = coating.id
try:
cfg = Configurator.create(cfg_vals)
print(f' ✓ Configurator session created: {cfg.display_name}')
except Exception as ex:
gap('Estimator', 'fp.quote.configurator.create', str(ex))
return
# 4a. Try the "Create Quotation" path — what action confirms the SO?
so = False
for meth in ('action_create_quotation', 'action_promote_to_direct_order',
'action_create_sale_order', 'action_generate_quote'):
if hasattr(cfg, meth):
try:
result = getattr(cfg, meth)()
so = (
e['sale.order'].browse(result.get('res_id'))
if isinstance(result, dict) and result.get('res_id')
else (cfg.x_fc_sale_order_id if 'x_fc_sale_order_id' in cfg._fields else False)
)
print(f' ✓ Quote created via {meth}: '
f'{so.name if so else "(no SO returned)"}')
break
except Exception as ex:
gap('Estimator', f'configurator.{meth}', str(ex))
if not so:
# Fall back: create SO directly and see if the configurator workflow is wired.
gap('Estimator', 'configurator',
'No working "create quote" action found on the configurator '
'— estimator has no button to make a quote')
# Manual SO creation for the rest of the walkthrough
SO = e['sale.order']
try:
so = SO.create({
'partner_id': customer.id,
'order_line': [(0, 0, {
'product_id': (
e['product.product'].search(
[('sale_ok', '=', True)], limit=1).id
),
'product_uom_qty': 10,
})],
})
print(f' Fallback: hand-created SO {so.name}')
except Exception as ex:
gap('Estimator', 'sale.order.create (fallback)', str(ex))
return
# 5. Customer-facing fields on the SO line
if so.order_line:
line = so.order_line[0]
for f in ('x_fc_internal_description', 'x_fc_part_catalog_id',
'x_fc_coating_config_id', 'x_fc_thickness_id',
'x_fc_serial_id', 'x_fc_job_number'):
if f not in line._fields:
gap('Estimator', f'sale.order.line.{f}',
'expected field missing')
print(f' SO header fields: po={so.x_fc_po_number or "(blank)"}, '
f'invoice_strategy={so.x_fc_invoice_strategy}, '
f'rush={so.x_fc_rush_order}')
# ------------------------------------------------------------------
# ROLE: Estimator confirms the quote → SO
# ------------------------------------------------------------------
print('\n[ROLE: Estimator] Click Confirm on the quote')
# Estimator types in the customer PO# (real flow: paste from email)
if 'x_fc_po_number' in so._fields and not so.x_fc_po_number:
so.x_fc_po_number = 'TEST-PO-E2E-001'
print(f' Set x_fc_po_number=TEST-PO-E2E-001 on {so.name}')
if so.state == 'draft':
try:
so.action_confirm()
print(f' ✓ SO confirmed — state={so.state}')
except Exception as ex:
gap('Estimator', 'sale.order.action_confirm', str(ex))
return
else:
print(f' SO already in state {so.state}')
# 5a. Confirm side-effects fired
Job = e['fp.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
if not jobs:
gap('Planner', 'fp.job auto-create',
'No fp.job auto-created on SO confirm — planner has nothing '
'to plan against')
else:
print(f'{len(jobs)} fp.job(s) created: '
f'{", ".join(jobs.mapped("name"))}')
# 5b. Receiving record auto-created?
Recv = e['fp.receiving']
receivings = Recv.search([('sale_order_id', '=', so.id)])
if not receivings:
gap('Receiver', 'fp.receiving auto-create',
'No fp.receiving auto-created on SO confirm — receiver has '
'nothing to count against')
else:
print(f' ✓ Receiving record(s): {", ".join(receivings.mapped("name"))}')
# 5c. Racking inspection auto-created on job confirm?
Insp = e['fp.racking.inspection']
insps = Insp.search([('sale_order_id', '=', so.id)])
if not insps and jobs:
gap('Racker', 'fp.racking.inspection auto-create',
'jobs exist but no racking inspection — racker walks empty')
elif insps:
print(f' ✓ Racking inspection(s): '
f'{", ".join(insps.mapped("name"))}')
# 5d. Portal job mirror auto-created?
PJ = e['fusion.plating.portal.job']
pjs = PJ.search([('partner_id', '=', customer.id)],
order='id desc', limit=2)
if pjs:
print(f' ✓ Portal job(s) for customer: '
f'{", ".join(pjs.mapped("name"))}')
else:
gap('Portal', 'portal job auto-create',
'No portal.job mirror — customer sees nothing on portal')
# ------------------------------------------------------------------
# ROLE: Receiver — Plating > Receiving > All Receiving
# ------------------------------------------------------------------
print('\n[ROLE: Receiver] Open the receiving record, count boxes')
if receivings:
r = receivings[0]
if 'box_count_in' not in r._fields:
gap('Receiver', 'fp.receiving.box_count_in', 'field missing')
else:
r.box_count_in = 3
print(f' Set box_count_in=3 on {r.name}')
if hasattr(r, 'action_mark_counted'):
try:
r.action_mark_counted()
print(f' ✓ Marked counted — state={r.state}')
except Exception as ex:
gap('Receiver', 'action_mark_counted', str(ex))
else:
gap('Receiver', 'fp.receiving',
'no action_mark_counted button')
if hasattr(r, 'action_mark_staged'):
try:
r.action_mark_staged()
print(f' ✓ Marked staged — state={r.state}')
except Exception as ex:
gap('Receiver', 'action_mark_staged', str(ex))
# Smart button to racking inspection?
if 'racking_inspection_count' in r._fields:
print(f' ✓ Receiving form shows '
f'{r.racking_inspection_count} racking inspection(s)')
else:
gap('Receiver', 'fp.receiving.racking_inspection_count',
'no smart button; receiver navigates manually')
# ------------------------------------------------------------------
# ROLE: Racking Crew — open the linked racking inspection
# ------------------------------------------------------------------
print('\n[ROLE: Racker] Open the racking inspection from receiving smart button')
if insps:
insp = insps[0]
# Real fields are line_count / ok_count / flagged_count (not "parts_*")
for f in ('line_count', 'ok_count', 'flagged_count', 'has_variance'):
if f not in insp._fields:
gap('Racker', f'fp.racking.inspection.{f}',
f'expected field missing')
# Real workflow: draft → inspecting (action_start) → done (action_complete)
if hasattr(insp, 'action_start'):
try:
insp.action_start()
print(f' ✓ Inspection started — state={insp.state}')
except Exception as ex:
gap('Racker', 'racking_inspection.action_start', str(ex))
if hasattr(insp, 'action_complete'):
try:
insp.action_complete()
print(f' ✓ Inspection completed — state={insp.state}')
except Exception as ex:
gap('Racker', 'racking_inspection.action_complete', str(ex))
# ------------------------------------------------------------------
# ROLE: Operator — runs the plating job step-by-step
# ------------------------------------------------------------------
print('\n[ROLE: Operator] Open the job, run each step')
if jobs:
job = jobs[0]
steps = job.step_ids.sorted('sequence')
if not steps:
gap('Operator', 'fp.job.step_ids',
'job has no steps — recipe not generated')
else:
print(f' Job {job.name} has {len(steps)} steps')
ran = 0
for step in steps[:3]: # walk the first 3
if step.state in ('ready', 'paused') and hasattr(step, 'button_start'):
try:
step.button_start()
step.button_finish()
ran += 1
except Exception as ex:
gap('Operator', f'step.{step.name}', str(ex))
else:
gap('Operator', f'step.{step.name}',
f"state={step.state} — operator can't start it")
print(f' ✓ Ran {ran} of 3 first steps')
# ------------------------------------------------------------------
# ROLE: Inspector — walk the QC checklist if customer requires QC
# ------------------------------------------------------------------
print('\n[ROLE: Inspector] Look for an open QC check on the job')
QC = e['fusion.plating.quality.check']
if jobs:
job = jobs[0]
# Customer might not be flagged x_fc_requires_qc — flip it for the test.
wants = ('x_fc_requires_qc' in customer._fields
and customer.x_fc_requires_qc)
print(f' Customer requires QC: {wants}')
if wants:
check = QC.search([('job_id', '=', job.id)], limit=1)
if not check:
gap('Inspector', 'QC.create_for_job',
'customer wants QC but no check was auto-spawned on confirm')
else:
print(f' ✓ QC check found: {check.name}')
# ------------------------------------------------------------------
# ROLE: Operator — try to mark job done (will hit QC gate if applicable)
# ------------------------------------------------------------------
print('\n[ROLE: Operator] Click Mark Done on the job')
if jobs:
job = jobs[0]
# Move all steps to done first so the job CAN be done
for step in job.step_ids:
if step.state in ('pending', 'in_progress'):
if step.state == 'pending' and hasattr(step, 'button_start'):
try:
step.button_start()
except Exception:
pass
if hasattr(step, 'button_finish'):
try:
step.button_finish()
except Exception:
pass
try:
job.with_context(fp_skip_qc_gate=True).button_mark_done()
print(f' ✓ Job marked done (with QC bypass) — state={job.state}')
except Exception as ex:
gap('Operator', 'fp.job.button_mark_done', str(ex))
# 5e. Delivery auto-created on done?
Del = e.get('fusion.plating.delivery') or (
e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
)
Del = e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
if Del is not None and jobs:
deliveries = Del.search([], order='id desc', limit=3)
print(f' Latest deliveries on system: '
f'{", ".join(deliveries.mapped("name") or ["(none)"])}')
# ------------------------------------------------------------------
# ROLE: Driver — picks up the delivery
# ------------------------------------------------------------------
print('\n[ROLE: Driver] Find the linked fusion.plating.delivery')
if Del is not None and jobs:
d = Del.search([('job_id', '=', jobs[0].id) if 'job_id' in Del._fields
else ('id', '=', 0)], limit=1)
if d:
print(f' ✓ Delivery {d.name} state={d.state}')
if hasattr(d, 'action_mark_delivered'):
try:
d.action_mark_delivered()
print(f' ✓ Marked delivered — state={d.state}')
except Exception as ex:
gap('Driver', 'delivery.action_mark_delivered', str(ex))
else:
print(' No delivery linked to job — checking by SO')
# ------------------------------------------------------------------
# ROLE: Accountant — invoice the SO
# ------------------------------------------------------------------
print('\n[ROLE: Accountant] Generate invoice')
print(f' invoice_status={so.invoice_status}')
if so and so.invoice_status == 'to invoice':
try:
so._create_invoices()
invs = e['account.move'].search(
[('invoice_origin', '=', so.name)])
print(f' ✓ Invoice(s) created: '
f'{", ".join(invs.mapped("name") or ["(none yet)"])}')
except Exception as ex:
gap('Accountant', 'sale.order._create_invoices', str(ex))
elif so.invoice_status == 'no':
# qty_delivered is 0 — service products invoice on ordered qty by
# default. If "no" persists, the SO has no invoiceable lines yet
# (e.g. delivered_qty=0 + invoice_policy='delivery').
print(f' Note: SO not yet invoiceable (qty_delivered=0). '
f'Set invoice_policy=order on plating service products to '
f'invoice immediately on confirm.')
# ------------------------------------------------------------------
# SUMMARY
# ------------------------------------------------------------------
print('\n=========================== SUMMARY ===========================')
if not GAPS:
print('NO GAPS FOUND — workflow walked end-to-end clean')
else:
print(f'{len(GAPS)} GAP(S) FOUND:')
for role, where, msg in GAPS:
print(f' - [{role}] {where} :: {msg}')
e.cr.commit()
walk()

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Sub 12 Phase F — end-to-end smoke test.
#
# Run via odoo-shell:
# /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin
# >>> exec(open('/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_smoke_test.py').read())
#
# Walks the full Sub 12 lifecycle and asserts at each step.
import logging
_log = logging.getLogger(__name__)
def _resolve_test_partner(env):
"""Pick a partner with at least one sale order so the RMA can bind."""
so = env['sale.order'].search([('state', 'in', ('sale', 'done'))], limit=1)
if not so:
raise RuntimeError('No confirmed sale.order found — seed one first.')
return so.partner_id, so
def smoke():
e = env # noqa -- env injected by odoo-shell
print('--- Sub 12 smoke test ---')
# Reset any stale half-completed test artefacts so the script is idempotent.
e['fusion.plating.rma'].search([('name', 'like', 'RMA/SUB12-SMOKE-%')]).unlink()
partner, so = _resolve_test_partner(e)
print(f' Using partner: {partner.display_name} (SO {so.name})')
# 1. Create RMA (draft)
rma = e['fusion.plating.rma'].create({
'partner_id': partner.id,
'sale_order_id': so.id,
'sale_order_line_ids': [(6, 0, so.order_line[:1].ids)] if so.order_line else False,
'trigger_source': 'customer_complaint',
'severity': 'high',
'qty_returned': 5,
'complaint_description': '<p>Smoke-test RMA. Auto-issued for Sub 12 verification.</p>',
})
print(f' ✓ Created RMA {rma.name} (state={rma.state})')
assert rma.state == 'draft', f'Expected draft, got {rma.state}'
# 2. Authorise
rma.action_authorise()
assert rma.state == 'authorised'
print(f' ✓ Authorised — state={rma.state}, qr_code present={bool(rma.qr_code)}')
# 3. Mark shipped
rma.action_mark_shipped_to_us()
assert rma.state == 'shipped_to_us'
print(f' ✓ Customer shipped — state={rma.state}')
# 4. Auto-receive via fp.receiving create. The RMA-link create-hook
# walks the receiving from draft → counted → staged → closed in one
# shot so the SO's x_fc_receiving_status flips to "received".
receiving = e['fp.receiving'].create({
'sale_order_id': so.id,
'rma_id': rma.id,
'box_count_in': 1,
'expected_qty': 5,
'received_qty': 5,
})
print(f' ✓ Created fp.receiving {receiving.name} → RMA state={rma.state}, recv state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert rma.state == 'received', f'Expected received, got {rma.state}'
assert receiving.state == 'closed', f'Expected receiving closed, got {receiving.state}'
assert so.x_fc_receiving_status == 'received', \
f'Expected SO status received, got {so.x_fc_receiving_status}'
# 5. Verify auto-spawn fired
assert rma.linked_ncr_ids, 'Auto-NCR was not spawned'
assert rma.linked_hold_ids, 'Auto-Hold was not spawned'
ncr = rma.linked_ncr_ids[0]
hold = rma.linked_hold_ids[0]
print(f' ✓ Auto-spawned NCR {ncr.name} + Hold {hold.name}')
# 6. Set resolution + triage
rma.resolution_type = 'rework'
rma.action_triage_complete()
assert rma.state == 'triaged'
print(f' ✓ Triage complete — state={rma.state}, resolution=rework')
# 7. Start resolving
rma.action_start_resolving()
assert rma.state == 'resolving'
print(f' ✓ Resolving — state={rma.state}')
# 8. NCR walk: open → containment → root cause → close
ncr.action_open()
ncr.action_containment()
ncr.containment = '<p>Smoke-test: parts segregated for re-rack.</p>'
ncr.action_disposition()
ncr.disposition = 'rework'
ncr.root_cause = '<p>Smoke-test: rack contact loss during transit.</p>'
# 9. Spawn CAPA from NCR (uses severity gate — high passes)
spawn_action = ncr.action_spawn_capa()
capa = e['fusion.plating.capa'].browse(spawn_action.get('res_id'))
print(f' ✓ Spawned CAPA {capa.name} from NCR')
assert capa.ncr_id == ncr, 'CAPA not linked to NCR'
# 10. Walk CAPA: analysis → implementation → verification → effective
capa.action_start_analysis()
capa.root_cause_analysis = '<p>Smoke-test: 5 Whys → packaging gap.</p>'
capa.action_start_implementation()
capa.action_plan = '<p>Smoke-test: revise packaging SOP.</p>'
capa.action_start_verification()
capa.effectiveness_notes = '<p>Smoke-test: 30 days no recurrence.</p>'
capa.action_mark_effective()
print(f' ✓ CAPA marked effective — state={capa.state}')
assert capa.state == 'effective'
# 11. Close NCR
ncr.action_close()
assert ncr.state == 'closed'
print(f' ✓ NCR closed — state={ncr.state}')
# 11b. Release the auto-spawned Hold (rework path) so the RMA close
# gate doesn't block. action_close on RMA refuses if any Hold is
# still on_hold or under_review.
hold.action_send_to_rework()
print(f' ✓ Hold sent to rework — state={hold.state}')
# 12. Resolve RMA (will spawn replacement job for rework)
rma.action_resolve()
print(f' ✓ RMA resolved — state={rma.state}, replacement_job={rma.replacement_job_id.name if rma.replacement_job_id else None}')
assert rma.state == 'resolved'
# 13. Close RMA
rma.action_close()
assert rma.state == 'closed'
print(f' ✓ RMA closed — state={rma.state}')
# 14. Stage_id sync sanity
print(f' ✓ NCR stage_id={ncr.stage_id.name if ncr.stage_id else "(none)"}')
print(f' ✓ RMA stage_id={rma.stage_id.name if rma.stage_id else "(none)"}')
# 15. Counts smoke (read directly — controller needs http context).
open_holds = e['fusion.plating.quality.hold'].search_count([
('state', 'in', ('on_hold', 'under_review')),
])
open_ncrs = e['fusion.plating.ncr'].search_count([
('state', 'in', ('open', 'containment', 'disposition')),
])
open_rmas = e['fusion.plating.rma'].search_count([
('state', 'not in', ('closed', 'cancelled')),
])
print(f' ✓ Dashboard counts (post-test): holds={open_holds}, ncrs={open_ncrs}, rmas={open_rmas}')
e.cr.commit()
print('--- Sub 12 smoke test PASSED ---')
smoke()

View File

@@ -44,3 +44,21 @@ access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_check
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_rma_operator,fusion.plating.rma.operator,model_fusion_plating_rma,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_rma_supervisor,fusion.plating.rma.supervisor,model_fusion_plating_rma,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_rma_manager,fusion.plating.rma.manager,model_fusion_plating_rma,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_tag_user,fp.quality.tag.user,model_fp_quality_tag,base.group_user,1,0,0,0
access_fp_quality_tag_supervisor,fp.quality.tag.supervisor,model_fp_quality_tag,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_tag_manager,fp.quality.tag.manager,model_fp_quality_tag,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_reason_user,fp.quality.reason.user,model_fp_quality_reason,base.group_user,1,0,0,0
access_fp_quality_reason_supervisor,fp.quality.reason.supervisor,model_fp_quality_reason,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_reason_manager,fp.quality.reason.manager,model_fp_quality_reason,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_team_user,fp.quality.team.user,model_fp_quality_team,base.group_user,1,0,0,0
access_fp_quality_team_supervisor,fp.quality.team.supervisor,model_fp_quality_team,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_team_manager,fp.quality.team.manager,model_fp_quality_team,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_alert_stage_user,fp.quality.alert.stage.user,model_fp_quality_alert_stage,base.group_user,1,0,0,0
access_fp_quality_alert_stage_supervisor,fp.quality.alert.stage.supervisor,model_fp_quality_alert_stage,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_alert_stage_manager,fp.quality.alert.stage.manager,model_fp_quality_alert_stage,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_point_user,fp.quality.point.user,model_fp_quality_point,base.group_user,1,0,0,0
access_fp_quality_point_supervisor,fp.quality.point.supervisor,model_fp_quality_point,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_point_manager,fp.quality.point.manager,model_fp_quality_point,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_qc_template_line_operator fp.qc.checklist.template.line.operator model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_operator 1 0 0 0
45 access_fp_qc_template_line_supervisor fp.qc.checklist.template.line.supervisor model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
46 access_fp_qc_template_line_manager fp.qc.checklist.template.line.manager model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_manager 1 1 1 1
47 access_fp_rma_operator fusion.plating.rma.operator model_fusion_plating_rma fusion_plating.group_fusion_plating_operator 1 0 0 0
48 access_fp_rma_supervisor fusion.plating.rma.supervisor model_fusion_plating_rma fusion_plating.group_fusion_plating_supervisor 1 1 1 0
49 access_fp_rma_manager fusion.plating.rma.manager model_fusion_plating_rma fusion_plating.group_fusion_plating_manager 1 1 1 1
50 access_fp_quality_tag_user fp.quality.tag.user model_fp_quality_tag base.group_user 1 0 0 0
51 access_fp_quality_tag_supervisor fp.quality.tag.supervisor model_fp_quality_tag fusion_plating.group_fusion_plating_supervisor 1 1 1 0
52 access_fp_quality_tag_manager fp.quality.tag.manager model_fp_quality_tag fusion_plating.group_fusion_plating_manager 1 1 1 1
53 access_fp_quality_reason_user fp.quality.reason.user model_fp_quality_reason base.group_user 1 0 0 0
54 access_fp_quality_reason_supervisor fp.quality.reason.supervisor model_fp_quality_reason fusion_plating.group_fusion_plating_supervisor 1 1 1 0
55 access_fp_quality_reason_manager fp.quality.reason.manager model_fp_quality_reason fusion_plating.group_fusion_plating_manager 1 1 1 1
56 access_fp_quality_team_user fp.quality.team.user model_fp_quality_team base.group_user 1 0 0 0
57 access_fp_quality_team_supervisor fp.quality.team.supervisor model_fp_quality_team fusion_plating.group_fusion_plating_supervisor 1 1 1 0
58 access_fp_quality_team_manager fp.quality.team.manager model_fp_quality_team fusion_plating.group_fusion_plating_manager 1 1 1 1
59 access_fp_quality_alert_stage_user fp.quality.alert.stage.user model_fp_quality_alert_stage base.group_user 1 0 0 0
60 access_fp_quality_alert_stage_supervisor fp.quality.alert.stage.supervisor model_fp_quality_alert_stage fusion_plating.group_fusion_plating_supervisor 1 1 1 0
61 access_fp_quality_alert_stage_manager fp.quality.alert.stage.manager model_fp_quality_alert_stage fusion_plating.group_fusion_plating_manager 1 1 1 1
62 access_fp_quality_point_user fp.quality.point.user model_fp_quality_point base.group_user 1 0 0 0
63 access_fp_quality_point_supervisor fp.quality.point.supervisor model_fp_quality_point fusion_plating.group_fusion_plating_supervisor 1 1 1 0
64 access_fp_quality_point_manager fp.quality.point.manager model_fp_quality_point fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,95 @@
/** @odoo-module **/
// Sub 12 Phase D — Unified Quality Dashboard.
// Five tabs (Holds / Checks / NCRs / CAPAs / RMAs) backed by their list
// kanbans, with a header summary card showing open + overdue counts.
// Each tab embeds the corresponding model's kanban via an action service
// switch. The header counters refresh on tab switch and on a 60-second
// poll.
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
const TABS = [
{ id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] },
{ id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] },
{ id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] },
{ id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] },
{ id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] },
];
export class FpQualityDashboard extends Component {
static template = "fusion_plating_quality.FpQualityDashboard";
static props = ["*"];
setup() {
this.action = useService("action");
this.state = useState({
activeTab: "ncrs",
counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}),
});
onWillStart(async () => {
await this._refreshCounts();
});
onMounted(() => {
this._poll = setInterval(() => this._refreshCounts(), 60000);
});
onWillUnmount(() => {
if (this._poll) clearInterval(this._poll);
});
}
async _refreshCounts() {
try {
const result = await rpc("/fp/quality/dashboard/counts");
if (result && typeof result === "object") {
for (const tab of TABS) {
if (result[tab.id]) {
this.state.counts[tab.id] = result[tab.id];
}
}
}
} catch (e) {
// Best-effort; leave counts at zero on RPC failure.
console.warn("FpQualityDashboard: count refresh failed", e);
}
}
selectTab(id) {
this.state.activeTab = id;
}
async openTab(tab) {
// Open the model's full kanban view in the main app area.
await this.action.doAction({
type: "ir.actions.act_window",
name: tab.label,
res_model: tab.model,
view_mode: "kanban,list,form",
views: [[false, "kanban"], [false, "list"], [false, "form"]],
domain: tab.domain,
context: { group_by: tab.group },
});
}
get tabs() {
return TABS;
}
get totalOpen() {
return TABS.reduce(
(sum, t) => sum + (this.state.counts[t.id]?.open || 0), 0,
);
}
get totalOverdue() {
return TABS.reduce(
(sum, t) => sum + (this.state.counts[t.id]?.overdue || 0), 0,
);
}
}
registry.category("actions").add("fp_quality_dashboard", FpQualityDashboard);

View File

@@ -0,0 +1,57 @@
// Sub 12 Phase D — Unified Quality Dashboard styling.
// Reuses the shopfloor SCSS tokens ($fp-page, $fp-card, $fp-border,
// $fp-ink, $fp-accent, etc.) — they are bundled before us via the
// fusion_plating_shopfloor dep, so no @import is needed.
.o_fp_quality_dashboard {
background-color: $fp-page;
min-height: 100%;
.o_fp_card {
background-color: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
}
.o_fp_qd_summary {
min-width: 220px;
}
.o_fp_qd_tile {
cursor: pointer;
min-width: 130px;
text-align: left;
transition: transform 0.08s ease-in-out, box-shadow 0.08s ease-in-out;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
&.o_fp_qd_active {
border: 2px solid $fp-accent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
}
.o_fp_qd_metric_label {
font-size: 0.85em;
color: $fp-ink-mute;
font-weight: 500;
}
.o_fp_qd_metric_value {
font-size: 1.6em;
font-weight: 700;
color: $fp-ink;
line-height: 1.1;
}
.o_fp_qd_metric_sub {
margin-top: 0.25em;
}
.o_fp_qd_panel {
min-height: 200px;
}
}

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_quality.FpQualityDashboard">
<div class="o_fp_quality_dashboard p-3">
<div class="o_fp_qd_header d-flex flex-wrap gap-3 mb-3">
<div class="o_fp_qd_summary o_fp_card flex-grow-1 p-3">
<h2 class="mb-2">Quality Overview</h2>
<div class="d-flex gap-4">
<div>
<div class="o_fp_qd_metric_label">Open across all 5</div>
<div class="o_fp_qd_metric_value"><t t-esc="totalOpen"/></div>
</div>
<div>
<div class="o_fp_qd_metric_label text-danger">Overdue</div>
<div class="o_fp_qd_metric_value text-danger"><t t-esc="totalOverdue"/></div>
</div>
</div>
</div>
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<button class="o_fp_qd_tile o_fp_card p-3 border-0"
t-att-class="{ 'o_fp_qd_active': state.activeTab === tab.id }"
t-on-click="() => this.selectTab(tab.id)">
<div class="o_fp_qd_metric_label"><t t-esc="tab.label"/></div>
<div class="o_fp_qd_metric_value">
<t t-esc="state.counts[tab.id]?.open || 0"/>
</div>
<div class="o_fp_qd_metric_sub text-muted small"
t-if="(state.counts[tab.id]?.overdue || 0) > 0">
<t t-esc="state.counts[tab.id].overdue"/> overdue
</div>
</button>
</t>
</div>
<div class="o_fp_qd_body">
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<div t-if="state.activeTab === tab.id" class="o_fp_qd_panel o_fp_card p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h3 class="mb-1"><t t-esc="tab.label"/></h3>
<div class="text-muted small">
<t t-esc="state.counts[tab.id]?.open || 0"/> open
<t t-if="(state.counts[tab.id]?.overdue || 0) > 0">
<t t-esc="state.counts[tab.id].overdue"/> overdue
</t>
</div>
</div>
<button class="btn btn-primary"
t-on-click="() => this.openTab(tab)">
Open <t t-esc="tab.label"/> Kanban
</button>
</div>
<p class="text-muted">
Click "Open Kanban" to drill into the full
<t t-esc="tab.label.toLowerCase()"/> board with stage / state grouping,
drag-and-drop, and the standard filters.
</p>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -31,6 +31,12 @@
action="action_fp_capa"
sequence="20"/>
<menuitem id="menu_fp_quality_rma"
name="RMAs"
parent="menu_fp_quality"
action="action_fp_rma"
sequence="25"/>
<menuitem id="menu_fp_quality_fair"
name="First Article Inspections"
parent="menu_fp_quality"

View File

@@ -86,6 +86,19 @@
<field name="disposition"/>
</group>
</group>
<group>
<group string="Categorisation">
<field name="team_id"/>
<field name="reason_id"/>
<field name="stage_id"/>
<field name="rma_id" readonly="1"
invisible="not rma_id"/>
</group>
<group string="Tags">
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}" nolabel="1"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description" placeholder="What happened? Be specific."/>

View File

@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase B — back-office views for the four categorisation models
(tag / reason / team / stage). All sit under Configuration → Quality.
-->
<odoo>
<!-- ============================================ TAGS ===== -->
<record id="view_fp_quality_tag_list" model="ir.ui.view">
<field name="name">fp.quality.tag.list</field>
<field name="model">fp.quality.tag</field>
<field name="arch" type="xml">
<list string="Quality Tags" editable="bottom">
<field name="name"/>
<field name="color" widget="color_picker"/>
<field name="description"/>
<field name="active"/>
</list>
</field>
</record>
<record id="action_fp_quality_tag" model="ir.actions.act_window">
<field name="name">Quality Tags</field>
<field name="res_model">fp.quality.tag</field>
<field name="view_mode">list</field>
</record>
<!-- ========================================= REASONS ===== -->
<record id="view_fp_quality_reason_list" model="ir.ui.view">
<field name="name">fp.quality.reason.list</field>
<field name="model">fp.quality.reason</field>
<field name="arch" type="xml">
<list string="Quality Reasons" editable="bottom">
<field name="name"/>
<field name="category"/>
<field name="description"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fp_quality_reason_form" model="ir.ui.view">
<field name="name">fp.quality.reason.form</field>
<field name="model">fp.quality.reason</field>
<field name="arch" type="xml">
<form string="Quality Reason">
<sheet>
<group>
<field name="name"/>
<field name="category"/>
<field name="active"/>
</group>
<field name="description"/>
</sheet>
</form>
</field>
</record>
<record id="action_fp_quality_reason" model="ir.actions.act_window">
<field name="name">Quality Reasons</field>
<field name="res_model">fp.quality.reason</field>
<field name="view_mode">list,form</field>
</record>
<!-- =========================================== TEAMS ===== -->
<record id="view_fp_quality_team_list" model="ir.ui.view">
<field name="name">fp.quality.team.list</field>
<field name="model">fp.quality.team</field>
<field name="arch" type="xml">
<list string="Quality Teams">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="lead_user_id"/>
<field name="escalation_user_id"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fp_quality_team_form" model="ir.ui.view">
<field name="name">fp.quality.team.form</field>
<field name="model">fp.quality.team</field>
<field name="arch" type="xml">
<form string="Quality Team">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="lead_user_id"/>
<field name="escalation_user_id"/>
<field name="color" widget="color_picker"/>
</group>
<group>
<field name="sequence"/>
<field name="active"/>
</group>
</group>
<group string="Members">
<field name="member_ids" widget="many2many_tags" nolabel="1"/>
</group>
<field name="description" placeholder="Team scope, on-call rotation, etc."/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_quality_team" model="ir.actions.act_window">
<field name="name">Quality Teams</field>
<field name="res_model">fp.quality.team</field>
<field name="view_mode">list,form</field>
</record>
<!-- ========================================== STAGES ===== -->
<record id="view_fp_quality_alert_stage_list" model="ir.ui.view">
<field name="name">fp.quality.alert.stage.list</field>
<field name="model">fp.quality.alert.stage</field>
<field name="arch" type="xml">
<list string="Quality Stages" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="fold"/>
<field name="active"/>
</list>
</field>
</record>
<record id="action_fp_quality_alert_stage" model="ir.actions.act_window">
<field name="name">Quality Stages</field>
<field name="res_model">fp.quality.alert.stage</field>
<field name="view_mode">list</field>
<field name="help" type="html">
<p>Shared kanban-stage namespace for NCR + RMA. Codes are
referenced by the state ↔ stage_id sync in
fp_quality_categorisation_links.py — don't rename codes
without checking that file.</p>
</field>
</record>
<!-- ============================== CONFIG MENU ENTRIES ===== -->
<menuitem id="menu_fp_config_quality_tag"
name="Quality Tags"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_tag"
sequence="100"/>
<menuitem id="menu_fp_config_quality_reason"
name="Quality Reasons"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_reason"
sequence="105"/>
<menuitem id="menu_fp_config_quality_team"
name="Quality Teams"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_team"
sequence="110"/>
<menuitem id="menu_fp_config_quality_stage"
name="Quality Stages"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_alert_stage"
sequence="115"/>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase D — client action + menu for the Unified Quality Dashboard.
-->
<odoo>
<record id="action_fp_quality_dashboard" model="ir.actions.client">
<field name="name">Quality Dashboard</field>
<field name="tag">fp_quality_dashboard</field>
<field name="target">current</field>
</record>
<menuitem id="menu_fp_quality_dashboard"
name="Dashboard"
parent="menu_fp_quality"
action="action_fp_quality_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase C — back-office views for fp.quality.point.
-->
<odoo>
<record id="view_fp_quality_point_list" model="ir.ui.view">
<field name="name">fp.quality.point.list</field>
<field name="model">fp.quality.point</field>
<field name="arch" type="xml">
<list string="Quality Points">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="trigger_type"/>
<field name="template_id"/>
<field name="team_id" optional="show"/>
<field name="assignee_user_id" optional="show"/>
<field name="spawn_count" string="Spawned"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_quality_point_form" model="ir.ui.view">
<field name="name">fp.quality.point.form</field>
<field name="model">fp.quality.point</field>
<field name="arch" type="xml">
<form string="Quality Point">
<header>
<button name="action_spawn_manual"
string="Fire Manually"
type="object"
class="btn-primary"
invisible="trigger_type != 'manual'"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Post-bake thickness check"/></h1>
</div>
<group>
<group>
<field name="trigger_type"/>
<field name="template_id"/>
<field name="assignee_user_id"/>
<field name="team_id"/>
</group>
<group>
<field name="active"/>
<field name="sequence"/>
<field name="spawn_count" readonly="1"/>
</group>
</group>
<notebook>
<page string="Filters">
<group>
<group>
<field name="partner_ids" widget="many2many_tags"
placeholder="All customers if empty"/>
<field name="part_catalog_ids" widget="many2many_tags"
placeholder="All parts if empty"/>
</group>
<group>
<field name="coating_config_ids" widget="many2many_tags"
placeholder="All coatings if empty"/>
<field name="step_kind"
invisible="trigger_type != 'job_step_done'"
placeholder="Any step kind if empty"/>
</group>
</group>
</page>
<page string="Tags">
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}"/>
</page>
<page string="Description">
<field name="description" nolabel="1"
placeholder="Why this point exists, what spec it satisfies, when to retire it..."/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_quality_point_search" model="ir.ui.view">
<field name="name">fp.quality.point.search</field>
<field name="model">fp.quality.point</field>
<field name="arch" type="xml">
<search string="Quality Points">
<field name="name"/>
<field name="template_id"/>
<field name="partner_ids"/>
<separator/>
<filter string="Active" name="active_only" domain="[('active','=',True)]"/>
<filter string="Manual Only" name="manual" domain="[('trigger_type','=','manual')]"/>
<separator/>
<group>
<filter string="Trigger" name="g_trigger" context="{'group_by':'trigger_type'}"/>
<filter string="Template" name="g_template" context="{'group_by':'template_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_quality_point" model="ir.actions.act_window">
<field name="name">Quality Points</field>
<field name="res_model">fp.quality.point</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quality_point_search"/>
<field name="context">{'search_default_active_only': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Define a Quality Point</p>
<p>Quality points are auto-fired rules that spawn QC checks
when receiving closes, jobs are confirmed, steps finish, jobs
complete, or sale orders are confirmed. Use filters (customer,
part, coating, step kind) to scope each point.</p>
</field>
</record>
<menuitem id="menu_fp_config_quality_point"
name="Quality Points"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_point"
sequence="120"/>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase D — surface the new smart-button counts on:
- fp.job form
- sale.order form
- res.partner form
Also add the cross-creation buttons:
- NCR form: Spawn CAPA
- CAPA form: Verify Effectiveness
-->
<odoo>
<!-- fp.job smart-button row lives in fusion_plating_jobs because the
button_box is added by that module (see Phase D notes — quality
can't depend on jobs without creating a cycle). -->
<!-- =============================================== sale.order ===== -->
<record id="view_sale_order_form_quality_buttons" model="ir.ui.view">
<field name="name">sale.order.form.quality.buttons</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fp_holds" type="object"
class="oe_stat_button" icon="fa-hand-paper-o">
<field name="fp_qc_hold_count" widget="statinfo" string="Holds"/>
</button>
<button name="action_view_fp_checks" type="object"
class="oe_stat_button" icon="fa-check-square-o">
<field name="fp_qc_check_count" widget="statinfo" string="Checks"/>
</button>
<button name="action_view_fp_ncrs_so" type="object"
class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="fp_qc_ncr_count_so" widget="statinfo" string="NCRs"/>
</button>
<button name="action_view_fp_capas" type="object"
class="oe_stat_button" icon="fa-wrench">
<field name="fp_qc_capa_count" widget="statinfo" string="CAPAs"/>
</button>
<button name="action_view_fp_rmas" type="object"
class="oe_stat_button" icon="fa-undo">
<field name="fp_qc_rma_count" widget="statinfo" string="RMAs"/>
</button>
</xpath>
</field>
</record>
<!-- ============================================== res.partner ===== -->
<record id="view_partner_form_quality_button" model="ir.ui.view">
<field name="name">res.partner.form.quality.button</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fp_quality_history" type="object"
class="oe_stat_button" icon="fa-shield"
groups="fusion_plating.group_fusion_plating_operator">
<field name="fp_qc_quality_history_count"
widget="statinfo" string="Quality History"/>
</button>
</xpath>
</field>
</record>
<!-- ====================================== NCR — Spawn CAPA ===== -->
<record id="view_fp_ncr_form_spawn_capa" model="ir.ui.view">
<field name="name">fp.ncr.form.spawn.capa</field>
<field name="model">fusion.plating.ncr</field>
<field name="inherit_id" ref="view_fp_ncr_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_spawn_capa"
string="Spawn CAPA"
type="object"
class="btn-secondary"
invisible="state not in ('disposition','closed') or severity == 'low'"/>
</xpath>
</field>
</record>
<!-- ============================ CAPA — Verify Effectiveness ===== -->
<record id="view_fp_capa_form_verify" model="ir.ui.view">
<field name="name">fp.capa.form.verify</field>
<field name="model">fusion.plating.capa</field>
<field name="inherit_id" ref="view_fp_capa_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_verify_effectiveness"
string="Schedule Effectiveness Check"
type="object"
class="btn-secondary"
invisible="state not in ('verification','effective')"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,321 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase A — RMA list / form / kanban / search + window action.
-->
<odoo>
<!-- ====================================================== LIST -->
<record id="view_fp_rma_list" model="ir.ui.view">
<field name="name">fp.rma.list</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<list string="RMAs"
decoration-muted="state in ('closed','cancelled')"
decoration-warning="state == 'received'"
decoration-danger="severity == 'critical'">
<field name="name"/>
<field name="create_date" string="Opened"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<field name="trigger_source"/>
<field name="severity" widget="badge"
decoration-info="severity == 'low'"
decoration-warning="severity == 'high'"
decoration-danger="severity == 'critical'"/>
<field name="qty_returned"/>
<field name="qty_received"/>
<field name="resolution_type" optional="show"/>
<field name="ncr_count" optional="show"/>
<field name="hold_count" optional="show"/>
<field name="state" widget="badge"
decoration-info="state in ('authorised','shipped_to_us')"
decoration-warning="state == 'received'"
decoration-success="state in ('triaged','resolving','resolved')"
decoration-muted="state in ('closed','cancelled')"/>
</list>
</field>
</record>
<!-- ====================================================== FORM -->
<record id="view_fp_rma_form" model="ir.ui.view">
<field name="name">fp.rma.form</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<form string="Return Material Authorisation">
<header>
<button name="action_authorise" string="Authorise" type="object"
class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_mark_shipped_to_us" string="Mark as Shipped" type="object"
invisible="state != 'authorised'"/>
<button name="action_mark_received" string="Mark Received" type="object"
invisible="state not in ('authorised','shipped_to_us')"
help="Use only if no fp.receiving record was created automatically."/>
<button name="action_triage_complete" string="Triage Complete" type="object"
class="oe_highlight" invisible="state != 'received'"/>
<button name="action_start_resolving" string="Start Resolving" type="object"
invisible="state != 'triaged'"/>
<button name="action_resolve" string="Resolve" type="object"
class="oe_highlight" invisible="state not in ('triaged','resolving')"/>
<button name="action_close" string="Close" type="object"
invisible="state != 'resolved'"/>
<button name="action_cancel" string="Cancel" type="object"
confirm="Cancel this RMA? Manager only."
invisible="state in ('closed','cancelled')"
groups="fusion_plating.group_fusion_plating_manager"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,authorised,shipped_to_us,received,triaged,resolving,resolved,closed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_inbound_receiving" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="not inbound_receiving_id">
<div class="o_stat_info">
<span class="o_stat_text">Inbound</span>
</div>
</button>
<button name="action_view_ncrs" type="object"
class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="ncr_count" widget="statinfo" string="NCRs"/>
</button>
<button name="action_view_holds" type="object"
class="oe_stat_button" icon="fa-hand-paper-o">
<field name="hold_count" widget="statinfo" string="Holds"/>
</button>
<button name="action_view_capas" type="object"
class="oe_stat_button" icon="fa-wrench">
<field name="capa_count" widget="statinfo" string="CAPAs"/>
</button>
<button name="action_view_replacement_job" type="object"
class="oe_stat_button" icon="fa-cogs"
invisible="not replacement_job_id">
<div class="o_stat_info">
<span class="o_stat_text">Replacement Job</span>
</div>
</button>
<button name="action_view_refund" type="object"
class="oe_stat_button" icon="fa-money"
invisible="not refund_invoice_id">
<div class="o_stat_info">
<span class="o_stat_text">Credit Note</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="partner_id" options="{'no_create': True}"/>
<field name="sale_order_id" options="{'no_create': True}"/>
<field name="sale_order_line_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="trigger_source"/>
<field name="severity" widget="badge"
decoration-info="severity == 'low'"
decoration-warning="severity == 'high'"
decoration-danger="severity == 'critical'"/>
</group>
<group>
<field name="qty_returned"/>
<field name="qty_received"/>
<field name="customer_tracking"/>
<field name="our_tracking"/>
<field name="carrier_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group>
<group string="Categorisation">
<field name="team_id"/>
<field name="reason_id"/>
<field name="stage_id" readonly="1"/>
</group>
<group string="Tags">
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}" nolabel="1"/>
</group>
</group>
<group string="Auto-Spawn (manager-overridable)"
groups="fusion_plating.group_fusion_plating_supervisor">
<group>
<field name="auto_spawn_ncr"/>
</group>
<group>
<field name="auto_spawn_hold"/>
</group>
</group>
<notebook>
<page string="Customer Complaint">
<field name="complaint_description"
placeholder="What did the customer report?"/>
</page>
<page string="Triage Findings"
invisible="state in ('draft','authorised','shipped_to_us')">
<field name="triage_findings"
placeholder="What we found on inspection."/>
</page>
<page string="Resolution"
invisible="state in ('draft','authorised','shipped_to_us','received')">
<group>
<group>
<field name="resolution_type"/>
<field name="replacement_job_id"
invisible="resolution_type not in ('replace','rework')"/>
</group>
<group>
<field name="refund_invoice_id"
invisible="resolution_type != 'refund'"/>
</group>
</group>
<field name="resolution_notes" placeholder="Notes on the chosen resolution path."/>
</page>
<page string="Linked NCRs"
invisible="not linked_ncr_ids">
<field name="linked_ncr_ids" readonly="1">
<list>
<field name="name"/>
<field name="severity" widget="badge"/>
<field name="state" widget="badge"/>
<field name="capa_count"/>
</list>
</field>
</page>
<page string="Linked Holds"
invisible="not linked_hold_ids">
<field name="linked_hold_ids" readonly="1">
<list>
<field name="name"/>
<field name="part_ref"/>
<field name="qty_on_hold"/>
<field name="hold_reason"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
<page string="QR Code">
<group>
<field name="qr_code" widget="image"
options="{'size': [200, 200]}"
nolabel="1"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ====================================================== KANBAN -->
<record id="view_fp_rma_kanban" model="ir.ui.view">
<field name="name">fp.rma.kanban</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_fp_rma_kanban">
<field name="id"/>
<field name="name"/>
<field name="partner_id"/>
<field name="severity"/>
<field name="resolution_type"/>
<field name="qty_returned"/>
<field name="ncr_count"/>
<field name="hold_count"/>
<templates>
<t t-name="card">
<div class="o_fp_card o_fp_rma_card"
t-att-data-severity="record.severity.raw_value">
<div class="d-flex justify-content-between align-items-start">
<strong class="o_fp_card_title"><field name="name"/></strong>
<span class="o_fp_severity_pill"
t-att-data-severity="record.severity.raw_value">
<field name="severity"/>
</span>
</div>
<div class="small mt-1">
<i class="fa fa-user me-1"/><field name="partner_id"/>
</div>
<div class="small text-muted mt-1"
t-if="record.resolution_type.raw_value">
<i class="fa fa-wrench me-1"/><field name="resolution_type"/>
</div>
<div class="d-flex justify-content-between mt-2 small">
<span class="text-muted">Returned</span>
<span class="fw-bold"><field name="qty_returned"/></span>
</div>
<div class="d-flex justify-content-between small">
<span class="text-muted">NCRs / Holds</span>
<span class="fw-bold">
<field name="ncr_count"/> /
<field name="hold_count"/>
</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ====================================================== SEARCH -->
<record id="view_fp_rma_search" model="ir.ui.view">
<field name="name">fp.rma.search</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<search string="RMAs">
<field name="name"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<separator/>
<filter string="Open" name="open"
domain="[('state','in',['draft','authorised','shipped_to_us','received','triaged','resolving'])]"/>
<filter string="Closed" name="closed"
domain="[('state','=','closed')]"/>
<filter string="Critical" name="critical"
domain="[('severity','=','critical')]"/>
<filter string="Awaiting Receipt" name="awaiting_receipt"
domain="[('state','in',['authorised','shipped_to_us'])]"/>
<filter string="Awaiting Triage" name="awaiting_triage"
domain="[('state','=','received')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Status" name="g_state" context="{'group_by':'state'}"/>
<filter string="Customer" name="g_partner" context="{'group_by':'partner_id'}"/>
<filter string="Trigger" name="g_trigger" context="{'group_by':'trigger_source'}"/>
<filter string="Resolution" name="g_resolution" context="{'group_by':'resolution_type'}"/>
</group>
</search>
</field>
</record>
<!-- ====================================================== ACTION -->
<record id="action_fp_rma" model="ir.actions.act_window">
<field name="name">RMAs</field>
<field name="res_model">fusion.plating.rma</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fp_rma_search"/>
<field name="context">{'search_default_open': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Open the first Return Material Authorisation
</p>
<p>RMAs track customer returns, inspection on receipt, root-cause
triage, and resolution (replace / rework / refund / scrap). They
auto-spawn NCRs and Holds when the parts arrive at the shop.</p>
</field>
</record>
</odoo>