Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py
gsinghpal 6658544f85 feat(fusion_plating): Tier 2 (quality + audit) and Tier 3 (business) features
Tier 2 — Quality & audit readiness:

- T2.1 SPC on thickness readings (fp.certificate)
  - spec_min_mils / spec_max_mils auto-pulled from coating config on create
  - Computed: std_dev_mils, min/max, cpk, cpk_status (incapable/marginal/
    capable/excellent/insufficient)
  - Western Electric trend rules (rule 1: any point beyond 3σ; rule 4:
    8 consecutive on one side of mean) → trend_alert + explanation
  - New SPC group on certificate form with badge-coloured indicators

- T2.2 Operator certification enforcement (fp.operator.certification)
  - Per (employee, process_type) records with issued/expires dates,
    training record attachment, revocation workflow
  - State auto-computed: active → expired when date passes
  - MrpWorkorder.button_start() blocks with UserError if current user's
    linked hr.employee lacks an active cert for the bath's process_type
  - Managers bypass the check; expiring-soon filter in search view
  - HR Employee form: "Plating Certifications" tab

- T2.3 Material traceability chain
  - fusion.plating.batch.workorder_id (new Many2one) + production_id
    (related through WO) for full chain
  - fp.certificate gets computed batch_ids / bath_ids / batch_count
  - "Batches" stat button → list of batches used for this cert's MO,
    with their chemistry logs intact

- T2.4 Pre-treatment as first-class baths
  - process_family selection on fusion.plating.process.type
    (pre_treatment / plating / post_treatment / bake / strip / passivation /
    masking / inspection)
  - Bath search view: Pre-Treatments / Plating / Post-Treatments / Strip
    quick filters
  - Existing bath infra (logs, replenishment, SPC) now applies to pre-
    treatment baths equally

Tier 3 — Business / revenue:

- T3.1 Customer-specific price lists (fp.customer.price.list)
  - Per (customer, coating_config) with unit_price + basis (per_part /
    sqin / sqft / lb)
  - effective_from / effective_to for annual contract pricing
  - min_quantity for volume breaks (cheapest price at requested qty wins)
  - _find_price() helper resolves active entry by date + qty
  - Direct Order wizard auto-fills unit_price on (partner, coating, qty)
    change unless operator has typed an override
  - Configurator menu → Customer Price Lists

- T3.2 Quote win/loss tracking (fp.quote.configurator)
  - State values: draft → confirmed (won) / lost / expired / cancelled
  - lost_reason selection (price / lead_time / tech / spec_mismatch /
    no_bid / no_response / competitor / other) + lost_competitor_name
    + lost_details text
  - Action buttons: Mark as Lost (requires reason), Mark as Expired
  - won_date auto-set on SO creation; lost_date auto-set on mark_lost
  - New "Win / Loss" tab on configurator form

- T3.3 Actuals vs. quoted margin (mrp.production)
  - Computed monetary fields: x_fc_consumables_cost, x_fc_labour_cost,
    x_fc_actual_cost, x_fc_quoted_revenue, x_fc_margin_actual,
    x_fc_margin_pct
  - Labour = sum(WO duration × workcentre cost_hour)
  - Revenue = SO amount_untaxed via mo.origin lookup
  - New "Job Costing" group on MO form with badge-coloured margin

- T3.4 Job consumables tracking (fp.job.consumption)
  - One row per consumable event (bath replenisher, masking tape, PPE,
    chemistry): product, qty, uom, unit_cost (snapshot), total_cost,
    source, optional workorder link
  - One2many x_fc_consumption_ids on mrp.production
  - "Consumables" stat button on MO → filtered list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:55:22 -04:00

171 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpBathReplenishmentRule(models.Model):
"""Linear replenishment rule: when a chemistry reading drifts outside
target, calculate how much replenisher to add.
The formula is deliberately simple:
dose = deficit × bath.volume × dose_rate
where deficit = (target_min value) for below_min rules
or = (value target_max) for above_max rules.
Shops wanting non-linear or piecewise rules can extend this model.
"""
_name = 'fusion.plating.bath.replenishment.rule'
_description = 'Fusion Plating — Replenishment Rule'
_order = 'process_type_id, parameter_id'
name = fields.Char(string='Rule Name', required=True)
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type',
help='If set, this rule applies to every bath running this process. '
'Leave blank and set bath_id for a bath-specific rule.',
)
bath_id = fields.Many2one(
'fusion.plating.bath', string='Specific Bath',
help='Narrow the rule to a single bath (overrides process-level rule).',
)
parameter_id = fields.Many2one(
'fusion.plating.bath.parameter', string='Parameter', required=True,
)
trigger = fields.Selection(
[('below_min', 'Reading Below Target Min'),
('above_max', 'Reading Above Target Max')],
string='Trigger', required=True, default='below_min',
)
product_name = fields.Char(
string='Replenisher Name', required=True,
help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"',
)
product_id = fields.Many2one(
'product.product', string='Product (Inventory)',
help='Optional link to an inventory product for consumption tracking.',
)
dose_rate = fields.Float(
string='Dose Rate', required=True, digits=(12, 4),
help='Amount of replenisher per unit of parameter deficit per gallon '
'of bath volume. E.g. 0.5 means "add 0.5 mL per (g/L deficit) per gallon".',
)
dose_uom = fields.Selection(
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
string='Dose UoM', required=True, default='ml',
)
min_dose = fields.Float(
string='Minimum Dose', default=0.0,
help='Do not suggest doses below this (useful to avoid noise).',
)
max_dose = fields.Float(
string='Safety Cap', default=0.0,
help='Cap the suggested dose. 0 = no cap.',
)
notes = fields.Text(string='Operator Notes')
active = fields.Boolean(default=True)
@api.model
def _find_rules(self, bath, parameter_id):
"""Return rules applicable to this (bath, parameter). Bath-specific
rules take precedence over process-level ones.
"""
bath_rule = self.search([
('bath_id', '=', bath.id),
('parameter_id', '=', parameter_id),
('active', '=', True),
])
if bath_rule:
return bath_rule
return self.search([
('bath_id', '=', False),
('process_type_id', '=', bath.process_type_id.id),
('parameter_id', '=', parameter_id),
('active', '=', True),
])
def _compute_dose(self, value, target_min, target_max, bath_volume):
"""Return a dose amount for this rule given the reading context.
Returns 0.0 if the trigger doesn't apply.
"""
self.ensure_one()
deficit = 0.0
if self.trigger == 'below_min' and target_min and value < target_min:
deficit = target_min - value
elif self.trigger == 'above_max' and target_max and value > target_max:
deficit = value - target_max
if deficit <= 0:
return 0.0
dose = deficit * (bath_volume or 1.0) * self.dose_rate
if self.min_dose and dose < self.min_dose:
return 0.0
if self.max_dose and dose > self.max_dose:
dose = self.max_dose
return round(dose, 3)
class FpBathReplenishmentSuggestion(models.Model):
"""One suggestion generated from a bath-log reading. Operators mark
them applied or dismissed once the dose has been added."""
_name = 'fusion.plating.bath.replenishment.suggestion'
_description = 'Fusion Plating — Replenishment Suggestion'
_inherit = ['mail.thread']
_order = 'create_date desc, id desc'
bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', required=True, ondelete='cascade',
)
log_line_id = fields.Many2one(
'fusion.plating.bath.log.line', string='Triggering Reading',
ondelete='cascade',
)
rule_id = fields.Many2one(
'fusion.plating.bath.replenishment.rule', string='Rule',
ondelete='set null',
)
parameter_id = fields.Many2one(
'fusion.plating.bath.parameter', string='Parameter', required=True,
)
current_value = fields.Float(string='Current Reading', digits=(12, 4))
target_min = fields.Float(string='Target Min', digits=(12, 4))
target_max = fields.Float(string='Target Max', digits=(12, 4))
product_name = fields.Char(string='Replenisher', required=True)
dose_amount = fields.Float(string='Suggested Dose', digits=(12, 3))
dose_uom = fields.Selection(
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
string='UoM', required=True, default='ml',
)
state = fields.Selection(
[('pending', 'Pending'), ('applied', 'Applied'), ('dismissed', 'Dismissed')],
default='pending', tracking=True,
)
applied_at = fields.Datetime(readonly=True)
applied_by_id = fields.Many2one('res.users', readonly=True)
charged_to_mo_ref = fields.Char(
string='Charged to MO',
help='Manufacturing order this replenishment was charged against '
'(for job costing). Blank = unassigned.',
)
def action_apply(self):
"""Mark applied + log to bath chatter. A follow-up JobConsumption
record can be created by `action_apply_and_charge()` to attribute
cost to a specific MO.
"""
for rec in self:
rec.write({
'state': 'applied',
'applied_at': fields.Datetime.now(),
'applied_by_id': self.env.user.id,
})
rec.bath_id.message_post(
body=f'Replenishment applied: {rec.dose_amount} {rec.dose_uom} '
f'of {rec.product_name} (parameter: {rec.parameter_id.name})'
)
def action_dismiss(self):
self.write({'state': 'dismissed'})