folder rename
This commit is contained in:
15
fusion_plating/fusion_plating_safety/models/__init__.py
Normal file
15
fusion_plating/fusion_plating_safety/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_sds
|
||||
from . import fp_chemical
|
||||
from . import fp_training_type
|
||||
from . import fp_training_record
|
||||
from . import fp_exposure_monitoring
|
||||
from . import fp_jhsc
|
||||
from . import fp_jhsc_meeting
|
||||
from . import fp_incident
|
||||
from . import fp_ppe_issuance
|
||||
from . import hr_employee
|
||||
96
fusion_plating/fusion_plating_safety/models/fp_chemical.py
Normal file
96
fusion_plating/fusion_plating_safety/models/fp_chemical.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# -*- 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 FpChemical(models.Model):
|
||||
"""Physical chemical container in the shop's chemical inventory.
|
||||
|
||||
A chemical record represents a managed container — drum, tote, jug,
|
||||
cylinder — of a specific product, stored in a specific facility and
|
||||
location, with on-hand quantity and reorder thresholds. It links to the
|
||||
Safety Data Sheet that governs handling, and may optionally link to the
|
||||
Odoo product/stock record when the same chemistry is also tracked as
|
||||
inventory.
|
||||
|
||||
Storage compatibility (acid vs base, oxidizer vs flammable, etc.) is
|
||||
captured via a self-referential many2many of incompatible chemicals so
|
||||
a future workflow can warn on co-located storage.
|
||||
"""
|
||||
_name = 'fusion.plating.chemical'
|
||||
_description = 'Fusion Plating — Chemical'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Chemical',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
sds_id = fields.Many2one(
|
||||
'fusion.plating.sds',
|
||||
string='Safety Data Sheet',
|
||||
tracking=True,
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Stock Product',
|
||||
help='Optional link to the Odoo product when the chemistry is also '
|
||||
'tracked as inventory.',
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
tracking=True,
|
||||
)
|
||||
location = fields.Char(
|
||||
string='Storage Location',
|
||||
help='Free-text storage location, e.g. "Acid Cabinet 2" or "Drum bay B".',
|
||||
)
|
||||
container_size = fields.Float(
|
||||
string='Container Size',
|
||||
)
|
||||
container_uom = fields.Char(
|
||||
string='Container UoM',
|
||||
help='Free-text unit of measure for the container size, '
|
||||
'e.g. L, kg, lb, gal.',
|
||||
)
|
||||
quantity_on_hand = fields.Float(
|
||||
string='Quantity On Hand',
|
||||
tracking=True,
|
||||
)
|
||||
reorder_point = fields.Float(
|
||||
string='Reorder Point',
|
||||
help='When quantity on hand falls below this level, the chemical '
|
||||
'should be reordered.',
|
||||
)
|
||||
incompatible_with_ids = fields.Many2many(
|
||||
'fusion.plating.chemical',
|
||||
'fp_chemical_incompat_rel',
|
||||
'chemical_id',
|
||||
'incompatible_id',
|
||||
string='Incompatible With',
|
||||
help='Chemicals that must not be stored next to this one.',
|
||||
)
|
||||
needs_reorder = fields.Boolean(
|
||||
string='Needs Reorder',
|
||||
compute='_compute_needs_reorder',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
@api.depends('quantity_on_hand', 'reorder_point')
|
||||
def _compute_needs_reorder(self):
|
||||
for rec in self:
|
||||
rec.needs_reorder = bool(
|
||||
rec.reorder_point and rec.quantity_on_hand <= rec.reorder_point
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- 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 FpExposureMonitoring(models.Model):
|
||||
"""An exposure monitoring sample.
|
||||
|
||||
A monitoring event captures one measurement of a worker's or area's
|
||||
exposure to a hazardous agent — air contaminant, biological marker,
|
||||
noise or vibration. The result is compared against an Occupational
|
||||
Exposure Limit (OEL), most commonly the time-weighted average (TWA)
|
||||
or short-term exposure limit (STEL) published by the relevant
|
||||
jurisdiction (e.g. Ontario Reg. 833 in Canada).
|
||||
|
||||
The percent-of-OEL is calculated automatically and the record is
|
||||
classified as below, approaching or exceeding the limit, providing
|
||||
an early warning when controls need to be tightened.
|
||||
"""
|
||||
_name = 'fusion.plating.exposure.monitoring'
|
||||
_description = 'Fusion Plating — Exposure Monitoring'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'sample_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,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
tracking=True,
|
||||
)
|
||||
sample_date = fields.Date(
|
||||
string='Sample Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
sample_type = fields.Selection(
|
||||
[
|
||||
('personal_air', 'Personal Air'),
|
||||
('area_air', 'Area Air'),
|
||||
('biological', 'Biological'),
|
||||
('noise', 'Noise'),
|
||||
('vibration', 'Vibration'),
|
||||
],
|
||||
string='Sample Type',
|
||||
default='personal_air',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
substance = fields.Char(
|
||||
string='Substance / Agent',
|
||||
tracking=True,
|
||||
)
|
||||
concentration = fields.Float(
|
||||
string='Concentration',
|
||||
tracking=True,
|
||||
)
|
||||
uom = fields.Char(
|
||||
string='Unit of Measure',
|
||||
help='Free-text unit, e.g. mg/m3, ppm, dBA.',
|
||||
)
|
||||
oel_reference = fields.Char(
|
||||
string='OEL Reference',
|
||||
help='Citation for the exposure limit, e.g. "Ontario Reg. 833 TWA".',
|
||||
)
|
||||
oel_limit = fields.Float(
|
||||
string='OEL Limit',
|
||||
)
|
||||
percent_of_oel = fields.Float(
|
||||
string='% of OEL',
|
||||
compute='_compute_percent_of_oel',
|
||||
store=True,
|
||||
)
|
||||
result = fields.Selection(
|
||||
[
|
||||
('below', 'Below'),
|
||||
('approaching', 'Approaching'),
|
||||
('exceed', 'Exceeds'),
|
||||
],
|
||||
string='Result',
|
||||
compute='_compute_result',
|
||||
store=True,
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ==========================================================================
|
||||
# Defaults
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.exposure.monitoring')
|
||||
return seq or '/'
|
||||
|
||||
# ==========================================================================
|
||||
# Computes
|
||||
# ==========================================================================
|
||||
@api.depends('concentration', 'oel_limit')
|
||||
def _compute_percent_of_oel(self):
|
||||
for rec in self:
|
||||
if rec.oel_limit:
|
||||
rec.percent_of_oel = (rec.concentration / rec.oel_limit) * 100.0
|
||||
else:
|
||||
rec.percent_of_oel = 0.0
|
||||
|
||||
@api.depends('percent_of_oel')
|
||||
def _compute_result(self):
|
||||
for rec in self:
|
||||
if rec.percent_of_oel >= 100.0:
|
||||
rec.result = 'exceed'
|
||||
elif rec.percent_of_oel >= 50.0:
|
||||
rec.result = 'approaching'
|
||||
else:
|
||||
rec.result = 'below'
|
||||
142
fusion_plating/fusion_plating_safety/models/fp_incident.py
Normal file
142
fusion_plating/fusion_plating_safety/models/fp_incident.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# -*- 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 FpIncident(models.Model):
|
||||
"""A safety incident, near-miss, or environmental event.
|
||||
|
||||
The incident register is the central log of anything that did, or
|
||||
almost did, harm a worker or the environment. Each record walks
|
||||
through draft → investigation → closed and captures who was hurt,
|
||||
where, what happened, the immediate response, the investigation
|
||||
findings, the root cause and the corrective action.
|
||||
|
||||
For Ontario operations the WSIB Form 7 fields flag whether the
|
||||
incident is reportable to the Workplace Safety and Insurance Board
|
||||
and whether the form has actually been filed.
|
||||
"""
|
||||
_name = 'fusion.plating.incident'
|
||||
_description = 'Fusion Plating — Incident'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'incident_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,
|
||||
)
|
||||
incident_date = fields.Datetime(
|
||||
string='Incident Date',
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
tracking=True,
|
||||
)
|
||||
reported_by_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Reported By',
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True,
|
||||
)
|
||||
incident_type = fields.Selection(
|
||||
[
|
||||
('injury', 'Injury'),
|
||||
('near_miss', 'Near Miss'),
|
||||
('first_aid', 'First Aid'),
|
||||
('lost_time', 'Lost Time'),
|
||||
('medical', 'Medical'),
|
||||
('property_damage', 'Property Damage'),
|
||||
('environmental', 'Environmental'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Type',
|
||||
default='near_miss',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee Involved',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
location = fields.Char(
|
||||
string='Location',
|
||||
tracking=True,
|
||||
)
|
||||
description = fields.Html(
|
||||
string='Description',
|
||||
)
|
||||
immediate_action = fields.Html(
|
||||
string='Immediate Action',
|
||||
)
|
||||
investigation = fields.Html(
|
||||
string='Investigation',
|
||||
)
|
||||
root_cause = fields.Html(
|
||||
string='Root Cause',
|
||||
)
|
||||
corrective_action = fields.Html(
|
||||
string='Corrective Action',
|
||||
)
|
||||
wsib_reportable = fields.Boolean(
|
||||
string='WSIB Reportable',
|
||||
tracking=True,
|
||||
)
|
||||
wsib_form_7_submitted = fields.Boolean(
|
||||
string='WSIB Form 7 Submitted',
|
||||
tracking=True,
|
||||
)
|
||||
lost_time_days = fields.Integer(
|
||||
string='Lost-Time Days',
|
||||
tracking=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('investigation', 'Investigation'),
|
||||
('closed', 'Closed'),
|
||||
],
|
||||
string='Status',
|
||||
default='draft',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ==========================================================================
|
||||
# Defaults
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.incident')
|
||||
return seq or '/'
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_start_investigation(self):
|
||||
self.write({'state': 'investigation'})
|
||||
|
||||
def action_close(self):
|
||||
self.write({'state': 'closed'})
|
||||
|
||||
def action_reopen(self):
|
||||
self.write({'state': 'draft'})
|
||||
80
fusion_plating/fusion_plating_safety/models/fp_jhsc.py
Normal file
80
fusion_plating/fusion_plating_safety/models/fp_jhsc.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpJhsc(models.Model):
|
||||
"""A Joint Health and Safety Committee.
|
||||
|
||||
Most Canadian jurisdictions require workplaces above a certain employee
|
||||
count to maintain a Joint Health and Safety Committee (JHSC) with at
|
||||
least one worker representative and one management representative.
|
||||
The committee meets on a regular cadence to review hazards, incidents,
|
||||
and proposed improvements.
|
||||
|
||||
A site can have one or more committees (e.g. when multiple buildings
|
||||
or shifts each maintain their own). Membership is tracked as overall
|
||||
members plus the specific worker and management representative subsets.
|
||||
"""
|
||||
_name = 'fusion.plating.jhsc'
|
||||
_description = 'Fusion Plating — JHSC'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Committee',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
member_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'fp_jhsc_member_rel',
|
||||
'jhsc_id',
|
||||
'employee_id',
|
||||
string='Members',
|
||||
)
|
||||
worker_rep_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'fp_jhsc_worker_rep_rel',
|
||||
'jhsc_id',
|
||||
'employee_id',
|
||||
string='Worker Representatives',
|
||||
)
|
||||
mgmt_rep_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'fp_jhsc_mgmt_rep_rel',
|
||||
'jhsc_id',
|
||||
'employee_id',
|
||||
string='Management Representatives',
|
||||
)
|
||||
meeting_ids = fields.One2many(
|
||||
'fusion.plating.jhsc.meeting',
|
||||
'jhsc_id',
|
||||
string='Meetings',
|
||||
)
|
||||
member_count = fields.Integer(
|
||||
compute='_compute_counts',
|
||||
)
|
||||
meeting_count = fields.Integer(
|
||||
compute='_compute_counts',
|
||||
)
|
||||
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.member_count = len(rec.member_ids)
|
||||
rec.meeting_count = len(rec.meeting_ids)
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpJhscMeeting(models.Model):
|
||||
"""A scheduled or held JHSC meeting.
|
||||
|
||||
Captures the meeting's date, attendees, agenda, minutes, action items
|
||||
and supporting attachments. The state field walks the meeting through
|
||||
its lifecycle from planned through closed so the JHSC can audit which
|
||||
meetings still need minutes posted.
|
||||
"""
|
||||
_name = 'fusion.plating.jhsc.meeting'
|
||||
_description = 'Fusion Plating — JHSC Meeting'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'meeting_date desc, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Subject',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
jhsc_id = fields.Many2one(
|
||||
'fusion.plating.jhsc',
|
||||
string='Committee',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
related='jhsc_id.facility_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
meeting_date = fields.Date(
|
||||
string='Meeting Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
attendee_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'fp_jhsc_meeting_attendee_rel',
|
||||
'meeting_id',
|
||||
'employee_id',
|
||||
string='Attendees',
|
||||
)
|
||||
agenda = fields.Html(
|
||||
string='Agenda',
|
||||
)
|
||||
minutes = fields.Html(
|
||||
string='Minutes',
|
||||
)
|
||||
action_items = fields.Html(
|
||||
string='Action Items',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('planned', 'Planned'),
|
||||
('held', 'Held'),
|
||||
('minutes_ready', 'Minutes Ready'),
|
||||
('closed', 'Closed'),
|
||||
],
|
||||
string='Status',
|
||||
default='planned',
|
||||
tracking=True,
|
||||
required=True,
|
||||
)
|
||||
attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_jhsc_meeting_attachment_rel',
|
||||
'meeting_id',
|
||||
'attachment_id',
|
||||
string='Attachments',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
def action_mark_held(self):
|
||||
self.write({'state': 'held'})
|
||||
|
||||
def action_post_minutes(self):
|
||||
self.write({'state': 'minutes_ready'})
|
||||
|
||||
def action_close(self):
|
||||
self.write({'state': 'closed'})
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- 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 FpPpeIssuance(models.Model):
|
||||
"""Per-employee Personal Protective Equipment issuance log.
|
||||
|
||||
Each record captures one issuance of a piece of PPE — respirator,
|
||||
gloves, apron, face shield — to an employee, with the size, quantity,
|
||||
issue date and the date the equipment is next due for replacement.
|
||||
|
||||
A historical PPE log demonstrates that the employer is providing
|
||||
appropriate protective equipment, which is itself an obligation under
|
||||
most occupational health and safety regulations.
|
||||
"""
|
||||
_name = 'fusion.plating.ppe.issuance'
|
||||
_description = 'Fusion Plating — PPE Issuance'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'issue_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
tracking=True,
|
||||
)
|
||||
issue_date = fields.Date(
|
||||
string='Issue Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
ppe_type = fields.Selection(
|
||||
[
|
||||
('respirator', 'Respirator'),
|
||||
('gloves', 'Gloves'),
|
||||
('apron', 'Apron'),
|
||||
('face_shield', 'Face Shield'),
|
||||
('safety_glasses', 'Safety Glasses'),
|
||||
('boots', 'Boots'),
|
||||
('hearing', 'Hearing Protection'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='PPE Type',
|
||||
required=True,
|
||||
default='gloves',
|
||||
tracking=True,
|
||||
)
|
||||
size = fields.Char(
|
||||
string='Size',
|
||||
)
|
||||
quantity = fields.Integer(
|
||||
string='Quantity',
|
||||
default=1,
|
||||
)
|
||||
next_replacement = fields.Date(
|
||||
string='Next Replacement',
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.depends('employee_id', 'ppe_type', 'issue_date')
|
||||
def _compute_display_name(self):
|
||||
ppe_label = dict(self._fields['ppe_type'].selection)
|
||||
for rec in self:
|
||||
parts = []
|
||||
if rec.employee_id:
|
||||
parts.append(rec.employee_id.name)
|
||||
if rec.ppe_type:
|
||||
parts.append(ppe_label.get(rec.ppe_type, rec.ppe_type))
|
||||
if rec.issue_date:
|
||||
parts.append(fields.Date.to_string(rec.issue_date))
|
||||
rec.display_name = ' — '.join(parts) if parts else 'PPE Issuance'
|
||||
180
fusion_plating/fusion_plating_safety/models/fp_sds.py
Normal file
180
fusion_plating/fusion_plating_safety/models/fp_sds.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpSds(models.Model):
|
||||
"""Safety Data Sheet library entry.
|
||||
|
||||
A Safety Data Sheet (SDS) is a 16-section document supplied by a chemical
|
||||
manufacturer that describes the hazards, handling, storage, exposure
|
||||
controls and emergency information for a product. Under the WHMIS 2015 /
|
||||
GHS framework an SDS is considered current for three years from its
|
||||
issue date — after which a refresh from the supplier is required.
|
||||
|
||||
Each SDS in the library carries supplier metadata, hazard classification,
|
||||
GHS pictogram codes, language coverage and a link to the original PDF
|
||||
via ir.attachment.
|
||||
"""
|
||||
_name = 'fusion.plating.sds'
|
||||
_description = 'Fusion Plating — Safety Data Sheet'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'product_name, version desc, issue_date desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Internal reference for this SDS entry, often the product name.',
|
||||
)
|
||||
product_name = fields.Char(
|
||||
string='Product Name',
|
||||
tracking=True,
|
||||
)
|
||||
supplier_name = fields.Char(
|
||||
string='Supplier (Text)',
|
||||
tracking=True,
|
||||
help='Free-text supplier name as printed on the SDS, used when '
|
||||
'no res.partner record exists yet.',
|
||||
)
|
||||
supplier_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Supplier',
|
||||
tracking=True,
|
||||
)
|
||||
cas_number = fields.Char(
|
||||
string='CAS Number',
|
||||
tracking=True,
|
||||
)
|
||||
version = fields.Char(
|
||||
string='Version',
|
||||
tracking=True,
|
||||
)
|
||||
issue_date = fields.Date(
|
||||
string='Issue Date',
|
||||
tracking=True,
|
||||
)
|
||||
expiry_date = fields.Date(
|
||||
string='Expiry Date',
|
||||
compute='_compute_expiry_date',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help='Computed as issue date + 3 years per WHMIS / GHS rule. '
|
||||
'Refresh from the supplier is required before this date.',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('current', 'Current'),
|
||||
('expiring_soon', 'Expiring Soon'),
|
||||
('expired', 'Expired'),
|
||||
('withdrawn', 'Withdrawn'),
|
||||
],
|
||||
string='Status',
|
||||
compute='_compute_state',
|
||||
store=True,
|
||||
tracking=True,
|
||||
)
|
||||
hazard_class = fields.Selection(
|
||||
[
|
||||
('flammable', 'Flammable'),
|
||||
('oxidizer', 'Oxidizer'),
|
||||
('compressed_gas', 'Compressed Gas'),
|
||||
('corrosive', 'Corrosive'),
|
||||
('toxic', 'Toxic'),
|
||||
('carcinogen', 'Carcinogen'),
|
||||
('reproductive_toxin', 'Reproductive Toxin'),
|
||||
('sensitizer', 'Sensitizer'),
|
||||
('aquatic_toxin', 'Aquatic Toxin'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Primary Hazard Class',
|
||||
tracking=True,
|
||||
)
|
||||
ghs_pictograms = fields.Char(
|
||||
string='GHS Pictograms',
|
||||
help='Comma-separated GHS pictogram codes, e.g. GHS01,GHS02,GHS05.',
|
||||
)
|
||||
language = fields.Selection(
|
||||
[
|
||||
('en', 'English'),
|
||||
('fr', 'French'),
|
||||
('both', 'Bilingual (EN/FR)'),
|
||||
],
|
||||
string='Language',
|
||||
default='en',
|
||||
)
|
||||
attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='SDS Document',
|
||||
help='The original SDS PDF supplied by the manufacturer.',
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
withdrawn = fields.Boolean(
|
||||
string='Withdrawn',
|
||||
tracking=True,
|
||||
help='Manually mark this SDS as withdrawn (e.g. product discontinued).',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
chemical_ids = fields.One2many(
|
||||
'fusion.plating.chemical',
|
||||
'sds_id',
|
||||
string='Chemicals',
|
||||
)
|
||||
chemical_count = fields.Integer(
|
||||
compute='_compute_chemical_count',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Computes
|
||||
# ==========================================================================
|
||||
@api.depends('issue_date')
|
||||
def _compute_expiry_date(self):
|
||||
for rec in self:
|
||||
if rec.issue_date:
|
||||
rec.expiry_date = rec.issue_date + relativedelta(years=3)
|
||||
else:
|
||||
rec.expiry_date = False
|
||||
|
||||
@api.depends('expiry_date', 'withdrawn')
|
||||
def _compute_state(self):
|
||||
today = fields.Date.context_today(self)
|
||||
warn_window = today + relativedelta(months=3)
|
||||
for rec in self:
|
||||
if rec.withdrawn:
|
||||
rec.state = 'withdrawn'
|
||||
elif not rec.expiry_date:
|
||||
rec.state = 'current'
|
||||
elif rec.expiry_date < today:
|
||||
rec.state = 'expired'
|
||||
elif rec.expiry_date <= warn_window:
|
||||
rec.state = 'expiring_soon'
|
||||
else:
|
||||
rec.state = 'current'
|
||||
|
||||
def _compute_chemical_count(self):
|
||||
for rec in self:
|
||||
rec.chemical_count = len(rec.chemical_ids)
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_mark_withdrawn(self):
|
||||
self.write({'withdrawn': True})
|
||||
|
||||
def action_mark_active(self):
|
||||
self.write({'withdrawn': False})
|
||||
@@ -0,0 +1,126 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpTrainingRecord(models.Model):
|
||||
"""Per-employee record of a completed training course.
|
||||
|
||||
Each record links an employee to a training type and captures when the
|
||||
course was completed, who delivered it, the certificate reference, and
|
||||
optionally a numeric score. The expiry date is computed automatically
|
||||
from the training type's validity window, and the state field rolls up
|
||||
to current / expiring soon / expired so the training matrix can be
|
||||
queried at a glance.
|
||||
"""
|
||||
_name = 'fusion.plating.training.record'
|
||||
_description = 'Fusion Plating — Training Record'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'completion_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
tracking=True,
|
||||
)
|
||||
training_type_id = fields.Many2one(
|
||||
'fusion.plating.training.type',
|
||||
string='Training Type',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
completion_date = fields.Date(
|
||||
string='Completion Date',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
expiry_date = fields.Date(
|
||||
string='Expiry Date',
|
||||
compute='_compute_expiry_date',
|
||||
store=True,
|
||||
)
|
||||
trainer = fields.Char(
|
||||
string='Trainer',
|
||||
tracking=True,
|
||||
)
|
||||
certificate_ref = fields.Char(
|
||||
string='Certificate Reference',
|
||||
tracking=True,
|
||||
)
|
||||
score = fields.Float(
|
||||
string='Score',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('current', 'Current'),
|
||||
('expiring_soon', 'Expiring Soon'),
|
||||
('expired', 'Expired'),
|
||||
],
|
||||
string='Status',
|
||||
compute='_compute_state',
|
||||
store=True,
|
||||
)
|
||||
attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Certificate',
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ==========================================================================
|
||||
# Computes
|
||||
# ==========================================================================
|
||||
@api.depends('completion_date', 'training_type_id', 'training_type_id.validity_months')
|
||||
def _compute_expiry_date(self):
|
||||
for rec in self:
|
||||
if rec.completion_date and rec.training_type_id and rec.training_type_id.validity_months:
|
||||
rec.expiry_date = rec.completion_date + relativedelta(months=rec.training_type_id.validity_months)
|
||||
else:
|
||||
rec.expiry_date = False
|
||||
|
||||
@api.depends('expiry_date')
|
||||
def _compute_state(self):
|
||||
today = fields.Date.context_today(self)
|
||||
warn_window = today + relativedelta(months=2)
|
||||
for rec in self:
|
||||
if not rec.expiry_date:
|
||||
rec.state = 'current'
|
||||
elif rec.expiry_date < today:
|
||||
rec.state = 'expired'
|
||||
elif rec.expiry_date <= warn_window:
|
||||
rec.state = 'expiring_soon'
|
||||
else:
|
||||
rec.state = 'current'
|
||||
|
||||
@api.depends('employee_id', 'training_type_id', 'completion_date')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
parts = []
|
||||
if rec.employee_id:
|
||||
parts.append(rec.employee_id.name)
|
||||
if rec.training_type_id:
|
||||
parts.append(rec.training_type_id.name)
|
||||
if rec.completion_date:
|
||||
parts.append(fields.Date.to_string(rec.completion_date))
|
||||
rec.display_name = ' — '.join(parts) if parts else 'Training Record'
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpTrainingType(models.Model):
|
||||
"""Master catalogue of training courses required in the shop.
|
||||
|
||||
A training type defines a class of certification — WHMIS 2015, TDG Road,
|
||||
Standard First Aid / CPR, LOTO, Confined Space — together with how long
|
||||
a completion remains valid before retraining is required. The validity
|
||||
window drives the per-employee training record's automatic expiry.
|
||||
|
||||
Seed data ships a generic Canadian set; jurisdiction-specific compliance
|
||||
packs may add more.
|
||||
"""
|
||||
_name = 'fusion.plating.training.type'
|
||||
_description = 'Fusion Plating — Training Type'
|
||||
_order = 'category, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Training Type',
|
||||
required=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
)
|
||||
category = fields.Selection(
|
||||
[
|
||||
('whmis', 'WHMIS'),
|
||||
('tdg', 'TDG'),
|
||||
('first_aid', 'First Aid'),
|
||||
('loto', 'Lockout / Tagout'),
|
||||
('confined_space', 'Confined Space'),
|
||||
('process_specific', 'Process Specific'),
|
||||
('ppe', 'PPE'),
|
||||
('ergonomic', 'Ergonomic'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Category',
|
||||
default='other',
|
||||
required=True,
|
||||
)
|
||||
validity_months = fields.Integer(
|
||||
string='Validity (Months)',
|
||||
default=12,
|
||||
help='How long a completion remains valid before retraining is '
|
||||
'required. Set to 0 for one-time training that never expires.',
|
||||
)
|
||||
description = fields.Html(
|
||||
string='Description',
|
||||
)
|
||||
required_for_roles = fields.Char(
|
||||
string='Required For Roles',
|
||||
help='Free-text list of job titles or roles that require this '
|
||||
'training, e.g. "All operators, Supervisors".',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
75
fusion_plating/fusion_plating_safety/models/hr_employee.py
Normal file
75
fusion_plating/fusion_plating_safety/models/hr_employee.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- 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 HrEmployee(models.Model):
|
||||
"""Adds Fusion Plating safety roll-ups to hr.employee.
|
||||
|
||||
Each employee gets reverse links to their training records, exposure
|
||||
monitoring samples and incident records, plus a single boolean that
|
||||
rolls up to True only when every required training course is current.
|
||||
Field names use the x_fc_ prefix per the Fusion Central convention for
|
||||
extensions to base Odoo models.
|
||||
"""
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
x_fc_training_ids = fields.One2many(
|
||||
'fusion.plating.training.record',
|
||||
'employee_id',
|
||||
string='Training Records',
|
||||
)
|
||||
x_fc_training_current = fields.Boolean(
|
||||
string='Training Current',
|
||||
compute='_compute_x_fc_training_current',
|
||||
store=True,
|
||||
help='True when every training record on this employee is currently '
|
||||
'in date (no expired records).',
|
||||
)
|
||||
x_fc_exposure_ids = fields.One2many(
|
||||
'fusion.plating.exposure.monitoring',
|
||||
'employee_id',
|
||||
string='Exposure Samples',
|
||||
)
|
||||
x_fc_incident_ids = fields.One2many(
|
||||
'fusion.plating.incident',
|
||||
'employee_id',
|
||||
string='Incidents',
|
||||
)
|
||||
x_fc_ppe_ids = fields.One2many(
|
||||
'fusion.plating.ppe.issuance',
|
||||
'employee_id',
|
||||
string='PPE Issued',
|
||||
)
|
||||
x_fc_training_count = fields.Integer(
|
||||
compute='_compute_x_fc_safety_counts',
|
||||
)
|
||||
x_fc_exposure_count = fields.Integer(
|
||||
compute='_compute_x_fc_safety_counts',
|
||||
)
|
||||
x_fc_incident_count = fields.Integer(
|
||||
compute='_compute_x_fc_safety_counts',
|
||||
)
|
||||
x_fc_ppe_count = fields.Integer(
|
||||
compute='_compute_x_fc_safety_counts',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_training_ids', 'x_fc_training_ids.state')
|
||||
def _compute_x_fc_training_current(self):
|
||||
for rec in self:
|
||||
if not rec.x_fc_training_ids:
|
||||
rec.x_fc_training_current = True
|
||||
else:
|
||||
rec.x_fc_training_current = not any(
|
||||
t.state == 'expired' for t in rec.x_fc_training_ids
|
||||
)
|
||||
|
||||
def _compute_x_fc_safety_counts(self):
|
||||
for rec in self:
|
||||
rec.x_fc_training_count = len(rec.x_fc_training_ids)
|
||||
rec.x_fc_exposure_count = len(rec.x_fc_exposure_ids)
|
||||
rec.x_fc_incident_count = len(rec.x_fc_incident_ids)
|
||||
rec.x_fc_ppe_count = len(rec.x_fc_ppe_ids)
|
||||
Reference in New Issue
Block a user