changes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_qc_controller
|
||||
from . import fp_quality_dashboard
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
},
|
||||
}
|
||||
@@ -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 > 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]))
|
||||
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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([])
|
||||
@@ -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
|
||||
199
fusion_plating/fusion_plating_quality/models/fp_quality_point.py
Normal file
199
fusion_plating/fusion_plating_quality/models/fp_quality_point.py
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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.'),
|
||||
]
|
||||
775
fusion_plating/fusion_plating_quality/models/fp_rma.py
Normal file
775
fusion_plating/fusion_plating_quality/models/fp_rma.py
Normal 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',
|
||||
)
|
||||
156
fusion_plating/fusion_plating_quality/models/fp_rma_links.py
Normal file
156
fusion_plating/fusion_plating_quality/models/fp_rma_links.py
Normal 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')
|
||||
210
fusion_plating/fusion_plating_quality/scripts/battle_test.py
Normal file
210
fusion_plating/fusion_plating_quality/scripts/battle_test.py
Normal 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 ==')
|
||||
150
fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
Normal file
150
fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
Normal 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 ==')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.0–5.5 g/L (Fischerscope reading)</li>'
|
||||
'<li>pH must be 4.4–4.8 — adjust with ammonium hydroxide if needed</li>'
|
||||
'<li>Bath temp 88–93°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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
107
fusion_plating/fusion_plating_quality/scripts/step3_verify.py
Normal file
107
fusion_plating/fusion_plating_quality/scripts/step3_verify.py
Normal 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} ==')
|
||||
@@ -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()
|
||||
@@ -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 ==')
|
||||
103
fusion_plating/fusion_plating_quality/scripts/step6_verify.py
Normal file
103
fusion_plating/fusion_plating_quality/scripts/step6_verify.py
Normal 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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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."/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
321
fusion_plating/fusion_plating_quality/views/fp_rma_views.xml
Normal file
321
fusion_plating/fusion_plating_quality/views/fp_rma_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user