diff --git a/lims/README.rst b/lims/README.rst new file mode 100644 index 0000000..42f2bd4 --- /dev/null +++ b/lims/README.rst @@ -0,0 +1,124 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +==== +Lims +==== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:05b7fd50b44f8d195624bc3de3642e125de904aa3b3dd42d9f5cebb7f4917ead + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector--lims-lightgray.png?logo=github + :target: https://github.com/OCA/connector-lims/tree/19.0/lims + :alt: OCA/connector-lims +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-lims-19-0/connector-lims-19-0-lims + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/connector-lims&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to manage a Laboratory from an odoo instance. + +It implements a simple LIMS for your company. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module gives a base for a vertical for laboratory companies. + +By default, it adds Samples and Analysis and some configurations related +to it. + +In order to keep odoo modular, we will split some functionality to make +it more feasible later on. + +Usage +===== + +1. A sampler user will create a sample from LIMS > Sample > Samples + + 1. Add a sample date, customer and sample type + 2. Add the different analysis you will do + 3. Receive the sample + +2. An analyst user will review al the samples to analyze and will set + the value. Once it has been set, he will submit the results + +3. A verifier user will verify all the results. The verifier must be + different than the analyst + +Known issues / Roadmap +====================== + +The following characteristics are still Work In Progress: + +- Usage of worklist +- Integration with devices +- Integration with sales / accounting +- Calculations +- Quality controls +- Batching of samples +- Sample storage + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit +* Creu Blanca + +Contributors +------------ + +- Dixmit + + - Enric Tobella + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/connector-lims `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/lims/__init__.py b/lims/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/lims/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/lims/__manifest__.py b/lims/__manifest__.py new file mode 100644 index 0000000..fab4160 --- /dev/null +++ b/lims/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2023 Dixmit +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Lims", + "summary": """ + Laboratory Information Management System""", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "author": "Dixmit, Creu Blanca,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/connector-lims", + "depends": ["mail", "uom", "account"], + # Account is needed to add subsections... + # things that you cannot understand from odoo... + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "data/ir_sequence_data.xml", + "data/uom_data.xml", + "views/menu.xml", + "views/lims_analyte.xml", + "views/lims_sample_type.xml", + "views/lims_sample.xml", + "views/lims_analysis.xml", + "reports/lims_sample_report.xml", + ], + "demo": ["demo/demo.xml"], + "assets": { + "web.assets_backend": [ + "lims/static/src/**/*.esm.js", + "lims/static/src/**/*.xml", + "lims/static/src/**/*.scss", + ], + "web.assets_unit_tests": [ + "lims/static/tests/**/*", + ], + "web.report_assets_common": [ + "lims/static/src/report.scss", + ], + }, +} diff --git a/lims/data/ir_sequence_data.xml b/lims/data/ir_sequence_data.xml new file mode 100644 index 0000000..6604bb4 --- /dev/null +++ b/lims/data/ir_sequence_data.xml @@ -0,0 +1,19 @@ + + + + + LIMS Sample sequence + lims.sample + SPL + 6 + + + LIMS Analysis sequence + lims.analysis + LA + 6 + + diff --git a/lims/data/uom_data.xml b/lims/data/uom_data.xml new file mode 100644 index 0000000..5b1338f --- /dev/null +++ b/lims/data/uom_data.xml @@ -0,0 +1,12 @@ + + + + + mg + + + + diff --git a/lims/demo/demo.xml b/lims/demo/demo.xml new file mode 100644 index 0000000..d7df58f --- /dev/null +++ b/lims/demo/demo.xml @@ -0,0 +1,76 @@ + + + + + Demo Laboratory + + + + + Water + + + Blood + + + Calcium + + Ca + + + + + + + lt + + + gt + + + Magnesium + + Mg + + + + + + + lt + + + gt + + + Total Hardness + + THCaCO3 + + + + + + + lt + + + gt + + + pH + + pH + + + + + + + lt + + + gt + + diff --git a/lims/models/__init__.py b/lims/models/__init__.py new file mode 100644 index 0000000..6d8450f --- /dev/null +++ b/lims/models/__init__.py @@ -0,0 +1,4 @@ +from . import lims_sample +from . import lims_sample_type +from . import lims_analysis +from . import lims_analyte diff --git a/lims/models/lims_analysis.py b/lims/models/lims_analysis.py new file mode 100644 index 0000000..2320d66 --- /dev/null +++ b/lims/models/lims_analysis.py @@ -0,0 +1,319 @@ +# Copyright 2023 Dixmit +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models +from odoo.exceptions import AccessDenied +from odoo.tools import format_date, format_datetime + + +class LimsAnalysis(models.Model): + """ + Identifies the specific value provided by the laboratory. + It is linked to a sample and a product (which identifies the type of analysis). + """ + + _name = "lims.analysis" + _description = "Analysis" + _check_company_auto = True + + identifier = fields.Char(required=True, default="/", readonly=True) + sample_id = fields.Many2one("lims.sample", required=True) + company_id = fields.Many2one( + "res.company", related="sample_id.company_id", store=True + ) + state = fields.Selection( + [ + ("registered", "Registered"), + ("to_analyze", "To Analyze"), + ("to_be_verified", "To be verified"), + ("verified", "Verified"), + ("rejected", "Rejected"), + ], + required=True, + default="registered", + readonly=True, + ) + sequence = fields.Integer( + default=10, + ) + display_type = fields.Selection( + [ + ("analyte", "Analyte"), + ("line_section", "Section"), + ("line_subsection", "Subsection"), + ("line_note", "Note"), + ], + default="analyte", + ) + analyte_id = fields.Many2one( + "lims.analyte", + readonly=True, + ) + name = fields.Char( + required=True, + readonly=True, + compute="_compute_name", + store=True, + ) + analyst_id = fields.Many2one("res.users", readonly=True) + capture_date = fields.Datetime(readonly=True) + submitted_date = fields.Datetime() + verified_by = fields.Many2one("res.users", readonly=True) + due_date = fields.Datetime(readonly=True) + verification_date = fields.Datetime(readonly=True) + uom_id = fields.Many2one( + "uom.uom", + readonly=True, + compute="_compute_uom_id", + store=True, + ) + progress = fields.Float(compute="_compute_progress", store=True) + can_verify = fields.Boolean(compute="_compute_can_verify") + value = fields.Json( + readonly=True, + compute="_compute_value", + store=True, + ) + value_state = fields.Selection( + [ + ("valid", "Valid"), + ("min", "Minimum"), + ("max", "Maximum"), + ("min_warning", "Minimum Warning"), + ("max_warning", "Maximum Warning"), + ], + compute="_compute_value_state", + store=True, + ) + _identifier_unique = models.Constraint( + "unique(identifier, company_id)", "Analysis identifier must be unique" + ) + + @api.depends("analyte_id") + def _compute_name(self): + for record in self: + if record.analyte_id: + record.name = record.analyte_id.name + + @api.depends("analyte_id") + def _compute_value(self): + for record in self: + record.value = record.analyte_id._get_default_value( + record.sample_id.sample_type_id + ) + + @api.depends("analyte_id") + def _compute_uom_id(self): + for record in self: + record.uom_id = record.analyte_id.uom_id + + @api.depends("value") + def _compute_value_state(self): + for record in self: + record.value_state = record._get_value_state() + + def _get_value_state(self): + if not self.value: + return False + if self.value.get("result_type") != "float": + return False + if not self.value.get("min_operator") and not self.value.get("max_operator"): + return False + value = self.value.get("value", 0.0) + if self.value.get("min_operator") and self._get_value_evaluation( + self.value["min_operator"], value, self.value.get("min", 0.0) + ): + return "min" + if self.value.get("max_operator") and self._get_value_evaluation( + self.value["max_operator"], value, self.value.get("max", 0.0) + ): + return "max" + if self.value.get("min_operator") and self._get_value_evaluation( + self.value["min_operator"], value, self.value.get("min_warning", 0.0) + ): + return "min_warning" + if self.value.get("max_operator") and self._get_value_evaluation( + self.value["max_operator"], value, self.value.get("max_warning", 0.0) + ): + return "max_warning" + return "valid" + + def _get_value_evaluation(self, operator, value, warning): + if operator == "lt": + return value < warning + if operator == "le": + return value <= warning + if operator == "gt": + return value > warning + if operator == "ge": + return value >= warning + + @api.model_create_multi + def create(self, mvals): + for vals in mvals: + if vals.get("identifier", "/") == "/": + vals["identifier"] = self._get_identifier(vals) + return super().create(mvals) + + def _get_identifier(self, vals): + return ( + self.env["ir.sequence"] + .with_company(vals.get("company_id", self.env.company.id)) + .next_by_code("lims.analysis") + or "/" + ) + + @api.model + def _add_missing_default_values(self, values): + defaults = super()._add_missing_default_values(values) + analyte = self.env["lims.analyte"].browse(defaults.get("analyte_id")).exists() + if "uom_id" not in values: + defaults["uom_id"] = analyte.uom_id.id + if "name" not in values: + defaults["name"] = analyte.name + return defaults + + def _receive_sample(self): + for record in self: + record.write(record._receive_sample_vals()) + + def _receive_sample_vals(self): + return { + "state": "to_analyze", + } + + def analyze_action(self): + if not self.env.user.has_group("lims.group_lims_analyst"): + raise AccessDenied(self.env._("You are not allowed to analyze this")) + for record in self.filtered(lambda r: r.state == "to_analyze"): + record.write(record._analyze_action_vals()) + # We need to use sudo as an analyst shouldn't be able to modify the sample, + # but we want to trigger the check to verify which is based on analyses states + self.mapped("sample_id").sudo()._check_analysis_state() + + def _analyze_action_vals(self): + return { + "state": "to_be_verified", + "analyst_id": self.env.user.id, + "submitted_date": fields.Datetime.now(), + } + + def verify_action(self): + if not self.env.user.has_group("lims.group_lims_verifier"): + raise AccessDenied(self.env._("You are not allowed to verify an analysis")) + for record in self.filtered(lambda r: r.can_verify): + record.write(record._verify_action_vals()) + # We need to use sudo as a verifier shouldn't be able to modify the sample, + # but we want to trigger the check verify which is based on analyses states + self.mapped("sample_id").sudo()._check_analysis_state() + + def _verify_action_vals(self): + return { + "state": "verified", + "verified_by": self.env.user.id, + "verification_date": fields.Datetime.now(), + } + + @api.depends("state") + def _compute_progress(self): + for record in self: + record.progress = record._get_progress() + + def final_states(self): + return ["verified"] + + def _get_progress(self): + if self.state in self.final_states(): + return 100 + if self.state == "to_be_verified": + return 50 + return 0 + + @api.depends_context("uid") + @api.depends("state", "analyst_id") + def _compute_can_verify(self): + verify_param = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("lims.unforce_double_verification_manager"), + ) and self.env.user.has_group("lims.group_lims_manager") + for record in self: + record.can_verify = record.state == "to_be_verified" and ( + verify_param + or record.analyst_id != self.env.user + or record.analyte_id.autoverify + ) + + def retract_action(self): + if not self.env.user.has_group("lims.group_lims_verifier"): + raise AccessDenied( + self.env._("You are not allowed to retract an analysis to be verified") + ) + for record in self.filtered( + lambda r: r.state in ["to_be_verified", "rejected"] + ): + record.write(record._retract_action_vals()) + # We need to use sudo as a verifier shouldn't be able to modify the sample, + # but we want to trigger the check verify which is based on analyses states + self.mapped("sample_id").sudo()._check_analysis_state() + + def _retract_action_vals(self): + return { + "state": "to_analyze", + "analyst_id": False, + "submitted_date": False, + } + + def reject_action(self): + self.write({"state": "rejected"}) + self.mapped("sample_id").sudo()._check_analysis_state() + + def _get_report_value(self): + self.ensure_one() + if self.value["result_type"] == "float": + lang = self.env["res.lang"]._lang_get( + self.env.context.get("lang") or self.env.user.lang + ) + return lang.format( + f"%.{self.value.get('digits')}f", self.value.get("value"), grouping=True + ) + if self.value["result_type"] == "date" and self.value.get("value"): + return format_date(self.env, fields.Date.to_date(self.value.get("value"))) + if self.value["result_type"] == "datetime" and self.value.get("value"): + return format_datetime( + self.env, fields.Datetime.to_datetime(self.value.get("value")) + ) + if self.value["result_type"] == "multiselection": + return ", ".join(self.value.get("value", [])) + return self.value.get("value") + + def _get_reference_value(self): + self.ensure_one() + if self.value["result_type"] != "float": + return "" + value = [] + lang = self.env["res.lang"]._lang_get( + self.env.context.get("lang") or self.env.user.lang + ) + if self.value.get("min_operator"): + value.append( + lang.format( + f"%.{self.value.get('digits')}f", + self.value.get("min_warning"), + grouping=True, + ) + ) + if self.value.get("max_operator"): + value.append( + lang.format( + f"%.{self.value.get('digits')}f", + self.value.get("max_warning"), + grouping=True, + ) + ) + if not value: + return "" + result = self.env._("RV: ") + ("/".join(value)) + if self.uom_id: + result += f" {self.uom_id.name}" + return result diff --git a/lims/models/lims_analyte.py b/lims/models/lims_analyte.py new file mode 100644 index 0000000..8fc03e5 --- /dev/null +++ b/lims/models/lims_analyte.py @@ -0,0 +1,99 @@ +# Copyright 2026 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + +OPERATORS = [ + ("lt", "<"), + ("lte", "<="), + ("gte", ">="), + ("gt", ">"), +] + + +class LimsAnalyte(models.Model): + _name = "lims.analyte" + _description = "Lims Analyte" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + description = fields.Html(translate=True) + active = fields.Boolean(default=True) + uom_id = fields.Many2one("uom.uom") + specification_ids = fields.One2many("lims.analyte.specs", inverse_name="analyte_id") + result_type = fields.Selection( + [ + ("float", "Numeric"), + ("char", "String"), + ("text", "Text"), + ("boolean", "Boolean"), + ("selection", "Selection"), + ("multiselection", "Multi-selection"), + ("multiselection-check", "Multi-selection with checks"), + ("date", "Date"), + ("datetime", "Datetime"), + ], + required=True, + default="float", + ) + autoverify = fields.Boolean( + help="Whether the analysis can be verified by the user if he is a verifier." + ) + precision_digits = fields.Integer( + help="""Number of decimals to consider when comparing numeric results with + specifications. Ignored for other result types.""", + default=2, + ) + option_ids = fields.One2many("lims.analyte.option", inverse_name="analyte_id") + _code_unique = models.Constraint("unique(code)", "Code must be unique.") + + def _get_default_value(self, sample_type): + if not self: + return {} + self.ensure_one() + value = {"value": False, "result_type": self.result_type} + if self.result_type in ("selection", "multiselection"): + value["selection"] = self.option_ids.mapped("value") + if self.result_type == "multiselection": + value["value"] = [] + if self.result_type == "multiselection-check": + value["value"] = {option.value: False for option in self.option_ids} + if self.result_type == "float": + value["digits"] = self.precision_digits + specs = self.specification_ids.filtered( + lambda s: s.sample_type_id == sample_type + ) + if specs and specs.min_operator: + value["min"] = specs.min_value + value["min_warning"] = specs.min_warning_value + value["min_operator"] = specs.min_operator + if specs and specs.max_operator: + value["max"] = specs.max_value + value["max_warning"] = specs.max_warning_value + value["max_operator"] = specs.max_operator + return value + + +class LimsAnalyteOption(models.Model): + _name = "lims.analyte.option" + _description = "Lims Analyte Option" + _order = "sequence, id" + + analyte_id = fields.Many2one("lims.analyte", required=True) + sequence = fields.Integer(required=True, default=10) + value = fields.Char(required=True) + + +class LimsAnalyteSpecs(models.Model): + _name = "lims.analyte.specs" + _description = "Lims Analyte Specs" + + analyte_id = fields.Many2one("lims.analyte", required=True) + sample_type_id = fields.Many2one("lims.sample.type", required=True) + min_operator = fields.Selection(OPERATORS, default="lt") + min_value = fields.Float() + min_warning_value = fields.Float() + max_value = fields.Float() + max_warning_value = fields.Float() + max_operator = fields.Selection(OPERATORS, default="gt") diff --git a/lims/models/lims_sample.py b/lims/models/lims_sample.py new file mode 100644 index 0000000..6231a13 --- /dev/null +++ b/lims/models/lims_sample.py @@ -0,0 +1,141 @@ +# Copyright 2023 Dixmit +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class LimsSample(models.Model): + _name = "lims.sample" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Sample" + _rec_name = "identifier" + _check_company_auto = True + + identifier = fields.Char(required=True, default="/", readonly=True, copy=False) + external_identifier = fields.Char() + state = fields.Selection( + [ + ("registered", "Registered"), + ("scheduled_sampling", "Scheduled Sampling"), + ("due", "Sample due"), + ("received", "Received"), + ("to_be_verified", "To be verified"), + ("verified", "Verified"), + ("cancelled", "Cancelled"), + ("invalid", "Invalid"), + ], + required=True, + default="due", + readonly=True, + tracking=True, + ) + sample_type_id = fields.Many2one( + "lims.sample.type", + required=True, + tracking=True, + ) + sample_date = fields.Datetime( + default=fields.Datetime.now(), + required=True, + readonly=True, + tracking=True, + ) + received_date = fields.Datetime(readonly=True) + verification_date = fields.Datetime(readonly=True) + partner_id = fields.Many2one("res.partner", tracking=True) + company_id = fields.Many2one( + "res.company", default=lambda self: self.env.company.id, tracking=True + ) + priority = fields.Selection( + [ + ("1", "Highest"), + ("2", "High"), + ("3", "Normal"), + ("4", "Low"), + ("5", "Lowest"), + ], + required=True, + default="3", + ) + analysis_ids = fields.One2many("lims.analysis", inverse_name="sample_id") + interpretation = fields.Html() + progress = fields.Float( + compute="_compute_progress", + store=True, + ) + _identifier_unique = models.Constraint( + "unique(identifier, company_id)", "Sample identifier must be unique" + ) + + @api.model_create_multi + def create(self, mvals): + for vals in mvals: + if vals.get("identifier", "/") == "/": + vals["identifier"] = self._get_identifier(vals) + return super().create(mvals) + + def _get_identifier(self, vals): + return ( + self.env["ir.sequence"] + .with_company(vals.get("company_id", self.env.company.id)) + .next_by_code("lims.sample") + or "/" + ) + + def receive_sample_action(self): + for record in self.filtered(lambda r: r.state == "due"): + record.write(record._receive_sample_vals()) + record.analysis_ids._receive_sample() + + def _receive_sample_vals(self): + return { + "state": "received", + "received_date": fields.Datetime.now(), + } + + def _check_analysis_state(self): + for record in self: + if record.state in ["registered", "scheduled_sampling", "due"]: + continue + if record._check_verify(): + if record.state != "verified": + record.write(record._check_verify_vals()) + elif record._check_to_verify(): + if record.state != "to_be_verified": + record.write(record._check_to_verify_vals()) + else: + record.write({"state": "received"}) + + def _check_to_verify(self): + return not self.analysis_ids.filtered( + lambda r: r.state in ["registered", "to_analyze"] + and r.display_type == "analyte" + ) + + def _check_to_verify_vals(self): + return {"state": "to_be_verified"} + + def _check_verify(self): + return not self.analysis_ids.filtered( + lambda r: r.state in ["registered", "to_analyze", "to_be_verified"] + and r.display_type == "analyte" + ) + + def _check_verify_vals(self): + return {"state": "verified", "verification_date": fields.Datetime.now()} + + @api.depends("analysis_ids", "analysis_ids.progress") + def _compute_progress(self): + for record in self: + record.progress = record._get_progress() + + def get_analysis(self): + return self.analysis_ids.filtered( + lambda r: r.state not in ["invalid"] and r.display_type == "analyte" + ) + + def _get_progress(self): + analysis = self.get_analysis() + if not analysis: + return 0 + return sum(analysis.mapped("progress")) / len(analysis) diff --git a/lims/models/lims_sample_type.py b/lims/models/lims_sample_type.py new file mode 100644 index 0000000..0352f3c --- /dev/null +++ b/lims/models/lims_sample_type.py @@ -0,0 +1,14 @@ +# Copyright 2023 Dixmit +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class LimsSampleType(models.Model): + _name = "lims.sample.type" + _description = "Sample Type" + + name = fields.Char(required=True) + description = fields.Text() + hazardous = fields.Boolean() + active = fields.Boolean(default=True) diff --git a/lims/pyproject.toml b/lims/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/lims/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/lims/readme/CONTEXT.md b/lims/readme/CONTEXT.md new file mode 100644 index 0000000..c886f7f --- /dev/null +++ b/lims/readme/CONTEXT.md @@ -0,0 +1,5 @@ +This module gives a base for a vertical for laboratory companies. + +By default, it adds Samples and Analysis and some configurations related to it. + +In order to keep odoo modular, we will split some functionality to make it more feasible later on. diff --git a/lims/readme/CONTRIBUTORS.md b/lims/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..ac3e05e --- /dev/null +++ b/lims/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Dixmit + - Enric Tobella diff --git a/lims/readme/DESCRIPTION.md b/lims/readme/DESCRIPTION.md new file mode 100644 index 0000000..73a3252 --- /dev/null +++ b/lims/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module allows to manage a Laboratory from an odoo instance. + +It implements a simple LIMS for your company. diff --git a/lims/readme/ROADMAP.md b/lims/readme/ROADMAP.md new file mode 100644 index 0000000..1550cd1 --- /dev/null +++ b/lims/readme/ROADMAP.md @@ -0,0 +1,9 @@ +The following characteristics are still Work In Progress: + +- Usage of worklist +- Integration with devices +- Integration with sales / accounting +- Calculations +- Quality controls +- Batching of samples +- Sample storage diff --git a/lims/readme/USAGE.md b/lims/readme/USAGE.md new file mode 100644 index 0000000..53dccf8 --- /dev/null +++ b/lims/readme/USAGE.md @@ -0,0 +1,10 @@ +1. A sampler user will create a sample from LIMS \> Sample \> Samples + 1. Add a sample date, customer and sample type + 2. Add the different analysis you will do + 3. Receive the sample + +2. An analyst user will review al the samples to analyze and will set + the value. Once it has been set, he will submit the results + +3. A verifier user will verify all the results. The verifier must be + different than the analyst diff --git a/lims/reports/lims_sample_report.xml b/lims/reports/lims_sample_report.xml new file mode 100644 index 0000000..8719d67 --- /dev/null +++ b/lims/reports/lims_sample_report.xml @@ -0,0 +1,219 @@ + + + + LIMS Sample Report + lims.sample + qweb-pdf + lims.lims_sample_report_template + lims.lims_sample_report_template + + report + + + + diff --git a/lims/security/ir.model.access.csv b/lims/security/ir.model.access.csv new file mode 100644 index 0000000..b6754f9 --- /dev/null +++ b/lims/security/ir.model.access.csv @@ -0,0 +1,15 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_lims_sample_type,access_lims_analysis,model_lims_sample_type,lims.group_lims_base,1,0,0,0 +manage_lims_sample_type,access_lims_analysis,model_lims_sample_type,lims.group_lims_manager,1,1,1,1 +access_lims_sample,access_lims_analysis,model_lims_sample,lims.group_lims_base,1,0,0,0 +manage_lims_sample,access_lims_analysis,model_lims_sample,lims.group_lims_sampler,1,1,1,0 +access_lims_analysis,access_lims_analysis,model_lims_analysis,lims.group_lims_base,1,0,0,0 +access_lims_analyte,access_lims_analyte,model_lims_analyte,lims.group_lims_base,1,0,0,0 +manage_lims_analyte,access_lims_analyte,model_lims_analyte,lims.group_lims_manager,1,1,1,1 +access_lims_analyte_specs,access_lims_analyte_specs,model_lims_analyte_specs,lims.group_lims_base,1,0,0,0 +manage_lims_analyte_specs,access_lims_analyte_specs,model_lims_analyte_specs,lims.group_lims_manager,1,1,1,1 +access_lims_analyte_option,access_lims_analyte_option,model_lims_analyte_option,lims.group_lims_base,1,0,0,0 +manage_lims_analyte_option,access_lims_analyte_option,model_lims_analyte_option,lims.group_lims_manager,1,1,1,1 +manage_lims_analysis_analyst,access_lims_analysis,model_lims_analysis,lims.group_lims_analyst,1,1,1,1 +manage_lims_analysis_verifier,access_lims_analysis,model_lims_analysis,lims.group_lims_verifier,1,1,1,1 +manage_lims_analysis_publisher,access_lims_analysis,model_lims_analysis,lims.group_lims_publisher,1,1,1,1 diff --git a/lims/security/security.xml b/lims/security/security.xml new file mode 100644 index 0000000..5273242 --- /dev/null +++ b/lims/security/security.xml @@ -0,0 +1,54 @@ + + + + + LIMS + + + LIMS + + 7 + + + LIMS Base Root Menu + + + Sampler + + + + + Analyst + + + + + Verifier + + + + + Publisher + + + + + Manager + + + + + + diff --git a/lims/static/description/icon.png b/lims/static/description/icon.png new file mode 100644 index 0000000..b10a88e Binary files /dev/null and b/lims/static/description/icon.png differ diff --git a/lims/static/description/icon.svg b/lims/static/description/icon.svg new file mode 100644 index 0000000..f92765f --- /dev/null +++ b/lims/static/description/icon.svg @@ -0,0 +1,42 @@ + + diff --git a/lims/static/description/index.html b/lims/static/description/index.html new file mode 100644 index 0000000..8c1565b --- /dev/null +++ b/lims/static/description/index.html @@ -0,0 +1,473 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Lims

+ +

Beta License: LGPL-3 OCA/connector-lims Translate me on Weblate Try me on Runboat

+

This module allows to manage a Laboratory from an odoo instance.

+

It implements a simple LIMS for your company.

+

Table of contents

+ +
+

Use Cases / Context

+

This module gives a base for a vertical for laboratory companies.

+

By default, it adds Samples and Analysis and some configurations related +to it.

+

In order to keep odoo modular, we will split some functionality to make +it more feasible later on.

+
+
+

Usage

+
    +
  1. A sampler user will create a sample from LIMS > Sample > Samples
      +
    1. Add a sample date, customer and sample type
    2. +
    3. Add the different analysis you will do
    4. +
    5. Receive the sample
    6. +
    +
  2. +
  3. An analyst user will review al the samples to analyze and will set +the value. Once it has been set, he will submit the results
  4. +
  5. A verifier user will verify all the results. The verifier must be +different than the analyst
  6. +
+
+
+

Known issues / Roadmap

+

The following characteristics are still Work In Progress:

+
    +
  • Usage of worklist
  • +
  • Integration with devices
  • +
  • Integration with sales / accounting
  • +
  • Calculations
  • +
  • Quality controls
  • +
  • Batching of samples
  • +
  • Sample storage
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
  • Creu Blanca
  • +
+
+
+

Contributors

+
    +
  • Dixmit
      +
    • Enric Tobella
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/connector-lims project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.esm.js b/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.esm.js new file mode 100644 index 0000000..ca85ef2 --- /dev/null +++ b/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.esm.js @@ -0,0 +1,22 @@ +// Copyright 2026 Dixmit +// License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +const {Component} = owl; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +export class LaboratoryEvaluationField extends Component { + get value() { + return this.props.record.data[this.props.name]; + } +} +LaboratoryEvaluationField.template = "lims.LaboratoryEvaluationField"; +LaboratoryEvaluationField.props = { + ...standardFieldProps, +}; +export const laboratoryEvaluationField = { + component: LaboratoryEvaluationField, + supportedTypes: ["selection"], +}; + +registry.category("fields").add("laboratory_evaluation", laboratoryEvaluationField); diff --git a/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.scss b/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.scss new file mode 100644 index 0000000..b350f6f --- /dev/null +++ b/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.scss @@ -0,0 +1,8 @@ +.o_field_laboratory_evaluation { + .fa.fa-stack.o_stack { + line-height: 1.5em; + font-size: 0.5rem; + width: 0; + height: 1rem; + } +} diff --git a/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.xml b/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.xml new file mode 100644 index 0000000..d58ab5f --- /dev/null +++ b/lims/static/src/fields/laboratory_evaluation/laboratory_evaluation.xml @@ -0,0 +1,14 @@ + + + + +
+ = + + + + +
+
+
diff --git a/lims/static/src/fields/laboratory_value/laboratory_value.esm.js b/lims/static/src/fields/laboratory_value/laboratory_value.esm.js new file mode 100644 index 0000000..9969913 --- /dev/null +++ b/lims/static/src/fields/laboratory_value/laboratory_value.esm.js @@ -0,0 +1,263 @@ +// Copyright 2026 Dixmit +// License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +const {Component, useEffect, useRef, useState} = owl; + +import { + areDatesEqual, + deserializeDate, + deserializeDateTime, + parseDate, + parseDateTime, + serializeDate, + serializeDateTime, +} from "@web/core/l10n/dates"; +import {formatDate, formatDateTime, formatFloat} from "@web/views/fields/formatters"; + +import {CheckBox} from "@web/core/checkbox/checkbox"; +import {SelectMenu} from "@web/core/select_menu/select_menu"; +import {TagsList} from "@web/core/tags_list/tags_list"; + +import {parseFloat} from "@web/views/fields/parsers"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; +import {useDateTimePicker} from "@web/core/datetime/datetime_picker_hook"; +import {useInputField} from "@web/views/fields/input_field_hook"; +import {useTagNavigation} from "@web/core/record_selectors/tag_navigation_hook"; + +export class LaboratoryValueField extends Component { + setup() { + super.setup(...arguments); + this.numpadInputRef = useInputField({ + getValue: () => this.value, + refName: "numpadDecimal", + parse: (v) => { + return { + ...this.props.record.data[this.props.name], + value: this.parse(v), + }; + }, + }); + this.textInputRef = useInputField({ + getValue: () => this.value, + refName: "textInput", + parse: (v) => { + return {...this.props.record.data[this.props.name], value: v}; + }, + }); + this.charInputRef = useInputField({ + getValue: () => this.value, + refName: "charInput", + parse: (v) => { + return {...this.props.record.data[this.props.name], value: v}; + }, + }); + if ( + this.props.record.data[this.props.name].result_type === "datetime" || + this.props.record.data[this.props.name].result_type === "date" + ) { + const getPickerProps = () => { + var value = this.props.record.data[this.props.name].value; + if (value && typeof value === "string") { + if ( + this.props.record.data[this.props.name].result_type === "date" + ) { + value = deserializeDate(value); + } else { + value = deserializeDateTime(value); + } + } + /** @type {DateTimePickerProps} */ + const pickerProps = { + value, + type: this.props.record.data[this.props.name].result_type, + range: false, + rounding: 0, + }; + return pickerProps; + }; + const dateTimePicker = useDateTimePicker({ + target: "root", + showSeconds: true, + get pickerProps() { + return getPickerProps(); + }, + onChange: () => { + this.state.range = false; + }, + onClose: () => { + this.picker.activeInput = ""; + this.state.value = deserializeDateTime( + this.props.record.data[this.props.name].value + ); + }, + onApply: async () => { + if ( + this.props.record.data[this.props.name].result_type === "date" + ) { + await this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: serializeDate(this.state.value), + }, + }); + } else { + await this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: serializeDateTime(this.state.value), + }, + }); + } + }, + }); + this.state = useState(dateTimePicker.state); + this.picker = useState({activeInput: ""}); + this.openPicker = dateTimePicker.open; + this.dateInput = useRef("dateInput"); + useEffect( + () => { + if ( + this.dateInput.el?.getAttribute("data-field") === + this.picker.activeInput + ) { + this.dateInput.el.focus(); + this.openPicker(); + } + }, + () => [this.dateInput.el?.tagName, this.picker.activeInput] + ); + } + if (this.type === "multiselection") { + useTagNavigation("multiselectionInput", { + isEnabled: () => !this.props.readonly, + delete: (index) => this.deleteTagByIndex(index), + }); + } + } + get type() { + return this.props.record.data[this.props.name].result_type; + } + parse(value) { + if (this.type === "float") { + return parseFloat(value, {allowOperation: true}); + } + if (this.type === "text" || this.type === "char") { + return value.trim(); + } + if (this.type === "date") { + return formatDate(value); + } + if (this.type === "datetime") { + return formatDateTime(value); + } + return value; + } + get value() { + const data = this.props.record.data[this.props.name]; + if (this.type === "float") { + return formatFloat(data.value || 0, {digits: [16, data.digits || 0]}); + } + if (this.type === "boolean") { + return data.value; + } + + if (this.type === "date" && data.value) { + return formatDate(deserializeDate(data.value), {numeric: true}); + } + if (this.type === "datetime" && data.value) { + return formatDateTime(deserializeDateTime(data.value), {numeric: true}); + } + return data.value || ""; + } + onChangeBoolean(newValue) { + this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: this.parse(newValue), + }, + }); + } + triggerDateIsDirty(isDirty) { + this.props.record.model.bus.trigger( + "FIELD_IS_DIRTY", + isDirty ?? + !areDatesEqual( + this.props.record.data[this.props.name].value, + this.state.value + ) + ); + } + get formattedDate() { + var formattedValue = false; + if (this.state.value && this.type === "date") { + formattedValue = formatDate(this.state.value, { + numeric: true, + }); + } else if (this.state.value && this.type === "datetime") { + formattedValue = formatDateTime(this.state.value, { + numeric: true, + }); + } + return formattedValue; + } + onDateInput() { + this.triggerDateIsDirty(true); + } + async onDateChange() { + const value = this.dateInput.el.value; + if (!value) { + this.state.value = null; + this.triggerDateIsDirty(false); + return; + } + if (this.type === "date") { + this.state.value = parseDate(value); + await this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: serializeDate(this.state.value), + }, + }); + } else if (this.type === "datetime") { + this.state.value = parseDateTime(value); + await this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: serializeDateTime(this.state.value), + }, + }); + } + } + onChangeMultiSelectionCheck(value) { + this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: { + ...this.props.record.data[this.props.name].value, + [value]: !this.props.record.data[this.props.name].value?.[value], + }, + }, + }); + } + onChangeSelection(value) { + this.props.record.update({ + [this.props.name]: { + ...this.props.record.data[this.props.name], + value: value, + }, + }); + } +} + +LaboratoryValueField.template = "lims.LaboratoryValueField"; +LaboratoryValueField.components = {CheckBox, SelectMenu, TagsList}; +LaboratoryValueField.props = { + ...standardFieldProps, +}; +export const laboratoryValueField = { + component: LaboratoryValueField, + supportedTypes: ["json"], +}; + +registry.category("fields").add("laboratory_value", laboratoryValueField); diff --git a/lims/static/src/fields/laboratory_value/laboratory_value.scss b/lims/static/src/fields/laboratory_value/laboratory_value.scss new file mode 100644 index 0000000..d6b93ee --- /dev/null +++ b/lims/static/src/fields/laboratory_value/laboratory_value.scss @@ -0,0 +1,20 @@ +.o_field_laboratory_value { + ul { + list-style-type: none; + margin: 0; + padding: 0; + li:before { + content: "\f096"; + font-family: "FontAwesome"; + font-weight: 900; + } + li & { + .selected:before { + content: "\f046"; + } + } + } + .o_lims_laboratory_value_text { + white-space: pre-wrap; + } +} diff --git a/lims/static/src/fields/laboratory_value/laboratory_value.xml b/lims/static/src/fields/laboratory_value/laboratory_value.xml new file mode 100644 index 0000000..5a6c041 --- /dev/null +++ b/lims/static/src/fields/laboratory_value/laboratory_value.xml @@ -0,0 +1,127 @@ + + + + +
+ +
    +
  • + +
  • +
+ + + +
+ + +