From ba79e3d907d27b5c52b49e04dc6013f602a10853 Mon Sep 17 00:00:00 2001 From: waqasahmed-gif Date: Fri, 24 Apr 2026 11:05:56 +0500 Subject: [PATCH 1/5] Added estate and estate account module. --- estate/__init__.py | 1 + estate/__manifest__.py | 44 ++++ estate/data/property_type_data.xml | 20 ++ estate/demo/property_offer_demo.xml | 49 ++++ estate/models/__init__.py | 4 + estate/models/estate_property.py | 184 +++++++++++++++ estate/models/property_offer.py | 90 ++++++++ estate/models/property_tag.py | 12 + estate/models/property_type.py | 33 +++ estate/security/ir.model.access.csv | 9 + estate/security/security.xml | 42 ++++ estate/views/estate_menus.xml | 33 +++ estate/views/estate_property_tag_views.xml | 74 ++++++ estate/views/estate_property_type.xml | 111 +++++++++ estate/views/estate_property_views.xml | 256 +++++++++++++++++++++ estate_account/__init__.py | 1 + estate_account/__manifest__.py | 26 +++ estate_account/models/__init__.py | 1 + estate_account/models/estate_property.py | 29 +++ 19 files changed, 1019 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/data/property_type_data.xml create mode 100644 estate/demo/property_offer_demo.xml create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py create mode 100644 estate/models/property_offer.py create mode 100644 estate/models/property_tag.py create mode 100644 estate/models/property_type.py create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/security/security.xml create mode 100644 estate/views/estate_menus.xml create mode 100644 estate/views/estate_property_tag_views.xml create mode 100644 estate/views/estate_property_type.xml create mode 100644 estate/views/estate_property_views.xml create mode 100644 estate_account/__init__.py create mode 100644 estate_account/__manifest__.py create mode 100644 estate_account/models/__init__.py create mode 100644 estate_account/models/estate_property.py diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..9a7e03eded3 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..522736fcdb7 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'estate', + 'version': '1.0', + 'summary': 'Added Real estate module', + 'description': """ + Real Estate Management Module + ============================= + Manage properties, offers, and real estate transactions. + """, + 'depends': [ + 'base_setup', + ], + 'author': 'Muhammad Qasim Shabbir', + 'license': 'LGPL-3', + 'category': 'Real Estate/Brokerage', + + # Required for Odoo 18 + 'installable': True, + 'application': True, + 'auto_install': False, + + # Add these later as you create files + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + + # 1. FIRST: property views (defines actions) + "views/estate_property_views.xml", + "data/property_type_data.xml", + + # 2. SECOND: type + tag views (define actions used by menu) + "views/estate_property_type.xml", + "views/estate_property_tag_views.xml", + + # 3. LAST: menus (depends on actions above) + "views/estate_menus.xml", + ], + "demo": [ + "demo/property_offer_demo.xml", + + ] +} \ No newline at end of file diff --git a/estate/data/property_type_data.xml b/estate/data/property_type_data.xml new file mode 100644 index 00000000000..00aa518b544 --- /dev/null +++ b/estate/data/property_type_data.xml @@ -0,0 +1,20 @@ + + + + + Residential + + + + Commercial + + + + Industrial + + + + Land + + + \ No newline at end of file diff --git a/estate/demo/property_offer_demo.xml b/estate/demo/property_offer_demo.xml new file mode 100644 index 00000000000..4af15976d6c --- /dev/null +++ b/estate/demo/property_offer_demo.xml @@ -0,0 +1,49 @@ + + + + + + + + + Big Villa + 1000000 + 0 + + + + + Small House + 200000 + 0 + + + + + + + + + + + + 10000 + 14 + + + + + + 1500000 + 14 + + + + + + + 1500001 + 14 + + + \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..4a9364876b5 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,4 @@ +from . import estate_property +from . import property_offer +from . import property_tag +from . import property_type diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..1fa012ca890 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,184 @@ +from odoo import models, fields, api +from datetime import timedelta + +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_is_zero, float_compare + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Real Estate Property" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + copy=False, + default=lambda self: fields.Date.today() + timedelta(days=90) + ) + + + living_area = fields.Integer(string='Living Area (sqm)',default=120) + + active = fields.Boolean(default=True) + state = fields.Selection([ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], required=True, copy=False, default='new') + + + def unlink(self): + for record in self: + if record.state not in ("new", "cancelled"): + raise UserError("You can only delete properties in New or Cancelled state.") + return super().unlink() + + + def action_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError("A sold property cannot be cancelled.") + record.state = 'cancelled' + return True + + def action_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError("A cancelled property cannot be set as sold.") + record.state = 'sold' + return True + + property_type_id = fields.Many2one("estate.property.type") + buyer_id = fields.Many2one("res.partner", copy=False) + seller_id = fields.Many2one("res.users", default=lambda self: self.env.user) + tag_ids = fields.Many2many("estate.property.tag") + offer_ids = fields.One2many("estate.property.offer", "property_id") + + + expected_price = fields.Integer(default=230) + selling_price = fields.Float(copy=False) + bedrooms = fields.Integer(default=2) + facades = fields.Integer(string="Facades",default=130) + garage = fields.Boolean() + + garden = fields.Boolean() + garden_area = fields.Integer(string='Garden Area (sqm)',default=220) + garden_orientation = fields.Selection([ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West') + ]) + + + @api.onchange('garden') + def _onchange_garden(self): + for record in self: + if record.garden: + record.garden_area = 10 + record.garden_orientation = 'north' + else: + record.garden_area = 0 + record.garden_orientation = False + + # Add this computed field + total_area = fields.Integer( + string='Total Area (sqm)', + compute='_compute_total_area', + store=True # Optional: stores in database for better performance + ) + + best_price = fields.Float( + string="Best Offer is 123210 when we computed it to zero.", + compute="_compute_best_price", + store=True, + default=123 + ) + + price_per_sqm = fields.Float( + compute="_compute_price_per_sqm", + store=True + ) + + @api.depends('living_area', 'garden_area','facades') + def _compute_total_area(self): + for record in self: + record.total_area = (record.living_area or 0) + (record.garden_area or 0) + (record.facades or 0) + + + @api.depends('expected_price', 'total_area') + def _compute_price_per_sqm(self): + for record in self: + if record.total_area: + record.price_per_sqm = record.expected_price / record.total_area + else: + record.price_per_sqm = 0 + + @api.depends('offer_ids.price') + def _compute_best_price(self): + for record in self: + prices = record.offer_ids.mapped('price') + record.best_price = max(prices) if prices else 0 + + + + @api.constrains('expected_price', 'selling_price') + def _check_selling_price(self): + for record in self: + + # If selling price is not set yet (0 or False), skip validation + if float_is_zero(record.selling_price, precision_digits=2): + continue + + # Ensure selling price >= 90% of expected price + min_price = record.expected_price * 0.90 + + comparison = float_compare( + record.selling_price, + min_price, + precision_digits=2 + ) + + if comparison < 0: + raise ValidationError( + "Selling price cannot be lower than 90% of expected price." + ) + + + @api.constrains('date_availability') + def _check_date_end(self): + for record in self: + if record.date_availability < fields.Date.today(): + raise ValidationError("The end date cannot be set in the past") + + @api.constrains('expected_price') + def _check_expected_price(self): + for record in self: + if record.expected_price < 220: + raise ValidationError("Expected price cannot be lower than 220") + + + @api.model + def create(self, vals): + + property_id = vals.get("property_id") + amount = vals.get("price") + + if property_id and amount: + property_rec = self.env["estate.property"].browse(property_id) + + existing_offers = property_rec.offer_ids.mapped("price") + + if existing_offers and amount < max(existing_offers): + raise UserError("Offer must be higher than existing offers.") + + # update property state + property_rec.state = "offer_received" + + return super().create(vals) + + \ No newline at end of file diff --git a/estate/models/property_offer.py b/estate/models/property_offer.py new file mode 100644 index 00000000000..e7bfee3bf44 --- /dev/null +++ b/estate/models/property_offer.py @@ -0,0 +1,90 @@ +from odoo import models, fields, api +from datetime import timedelta + +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Property Offer" + _order = "price desc" + + price = fields.Float(required=True) + validity = fields.Integer(default=7) + + date_deadline = fields.Date( + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + store=True + ) + + property_type_id = fields.Many2one( + related='property_id.property_type_id', + store=True, + readonly=True + ) + + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + if record.create_date: + record.date_deadline = record.create_date.date() + timedelta(days=record.validity) + else: + record.date_deadline = fields.Date.today() + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + if record.create_date and record.date_deadline: + record.validity = (record.date_deadline - record.create_date.date()).days + + partner_id = fields.Many2one("res.partner", required=True) + + status = fields.Selection( + [("accepted", "Accepted"), ("refused", "Refused")], + copy=False + ) + + def action_accept_offer(self): + for offer in self: + + # prevent multiple accepted offers + accepted = self.search([ + ('property_id', '=', offer.property_id.id), + ('status', '=', 'accepted') + ]) + if accepted: + raise UserError("Only one offer can be accepted for a property.") + + offer.status = 'accepted' + + # update property + offer.property_id.buyer_id = offer.partner_id + offer.property_id.selling_price = offer.price + offer.property_id.state = 'offer_accepted' + + def action_refuse_offer(self): + for offer in self: + if offer.status == 'accepted': + raise UserError("Accepted offer cannot be refused.") + offer.status = 'refused' + + property_id = fields.Many2one("estate.property", required=True) + + @api.model + def create(self, vals): + + property_id = vals.get("property_id") + amount = vals.get("price") + + if property_id and amount: + property_rec = self.env["estate.property"].browse(property_id) + + existing_offers = property_rec.offer_ids.mapped("price") + + if existing_offers and amount < max(existing_offers): + raise UserError("Offer must be higher than existing offers.") + + # update property state + property_rec.state = "offer_received" + + return super().create(vals) \ No newline at end of file diff --git a/estate/models/property_tag.py b/estate/models/property_tag.py new file mode 100644 index 00000000000..fa3637cbeb9 --- /dev/null +++ b/estate/models/property_tag.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Property Tag" + _order = "name" + + name = fields.Char(required=True) + + # estate.property.tag + color = fields.Integer() \ No newline at end of file diff --git a/estate/models/property_type.py b/estate/models/property_type.py new file mode 100644 index 00000000000..2307f9c5fb1 --- /dev/null +++ b/estate/models/property_type.py @@ -0,0 +1,33 @@ +from odoo import models, fields + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Property Type" + _order = "sequence,name" + + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + + property_ids = fields.One2many( + comodel_name='estate.property', + inverse_name='property_type_id', + string="Properties" + ) + + buyer_id = fields.Many2one("res.partner", string="Buyer") + seller_id = fields.Many2one( + "res.users", + string="Salesperson", + default=lambda self: self.env.user + ) + + offer_ids = fields.One2many( + comodel_name='estate.property.offer', + inverse_name='property_type_id' + ) + + offer_count = fields.Integer(compute="_compute_offer_count") + + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..84cf471d8a0 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property_manager,Estate Property Manager Full Access,model_estate_property,estate.estate_group_manager,1,1,1,0 +access_estate_property_user,Estate Property User Access,model_estate_property,estate.estate_group_user,1,1,1,0 +access_estate_property_type_manager,Estate Property Type Manager,model_estate_property_type,estate.estate_group_manager,1,1,1,1 +access_estate_property_type_user,Estate Property Type User,model_estate_property_type,estate.estate_group_user,1,0,0,0 +access_estate_property_tag_manager,Estate Property Tag Manager,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 +access_estate_property_tag_user,Estate Property Tag User,model_estate_property_tag,estate.estate_group_user,1,0,0,0 +access_estate_property_offer_manager,Estate Property Offer Manager,model_estate_property_offer,estate.estate_group_manager,1,1,1,1 +access_estate_property_offer_user,Estate Property Offer User,model_estate_property_offer,estate.estate_group_user,1,1,1,0 \ No newline at end of file diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..3e78d732f5f --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,42 @@ + + + + + + + + Real Estate + Real Estate Management + 10 + + + + + + + Real Estate + + + + + + + + Agent + + + + + + + + + Manager + + + + + \ No newline at end of file diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..3205d3482c4 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..c34085d82d9 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,74 @@ + + + + + + + + estate.property.tag.list + estate.property.tag + + + + + + + + + + + + + estate.property.tag.form + estate.property.tag + +
+ +
+

+ +

+
+ + + + + + +
+
+
+
+ + + + + + estate.property.tag.search + estate.property.tag + + + + + + + + + + + + Property Tags + estate.property.tag + list,form + +

+ Create a new property tag! +

+

+ Create tags to categorize and label properties + (e.g., Renovated, Garden, Parking, Pet-Friendly, etc.). +

+
+
+ +
\ No newline at end of file diff --git a/estate/views/estate_property_type.xml b/estate/views/estate_property_type.xml new file mode 100644 index 00000000000..57479ae2a4f --- /dev/null +++ b/estate/views/estate_property_type.xml @@ -0,0 +1,111 @@ + + + + + + + + estate.property.type.list + estate.property.type + + + + + + + + + + + + + + estate.property.type.form + estate.property.type + +
+ + + +
+

+ +

+
+ + + + + + + + + + +
+ + + properties of this type exist. + +
+ +
+ No properties of this type yet. +
+ + + + + + + + + + + + + + +
+
+ +
+
+
+
+ + + + + + estate.property.type.search + estate.property.type + + + + + + + + + + + + Property Types + estate.property.type + list,form + +

+ Create a new property type! +

+

+ Define and manage different types of properties. +

+
+
+ +
\ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..94d7c2f57bb --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,256 @@ + + + + + + + + estate.property.kanban + estate.property + + + + + + + + + + + + + + +
+ + + + + + + +
+ +
+ + +
+ +
+ + +
+ Expected: +
+ + +
+ Best Offer: + + + + - +
+ + +
+ Selling Price: +
+ + +
+ Sold + Accepted + Offers + Cancelled + New +
+ +
+
+
+ +
+ +
+
+ + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+ +
+
+ + + +
+ This property has been SOLD +
+ +
+ This property listing has been CANCELLED +
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + Properties + estate.property + list,form,kanban + {'search_default_available': 1} + + +
\ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..9a7e03eded3 --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..931d05ac6c2 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'estate account', + 'version': '1.0', + 'summary': 'Added Real estate account module', + 'description': """ + Real Estate Account Management Module + ============================= + Manage properties, offers, and real estate transactions. + """, + 'depends': [ + 'base_setup', + "estate", + "account" + ], + 'author': 'Muhammad Qasim Shabbir', + 'license': 'LGPL-3', + 'category': 'Real Estate', + + # Required for Odoo 18 + 'installable': True, + 'application': True, + 'auto_install': False, + +} \ No newline at end of file diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..f4c8fd6db6d --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property \ No newline at end of file diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..d380ed4bde5 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,29 @@ +from odoo import models, Command + + +class EstateProperty(models.Model): + _inherit = "estate.property" + def action_sold(self): + res = super().action_sold() + + for record in self: + selling_price = record.selling_price or 0.0 + + self.env["account.move"].create({ + "partner_id": record.buyer_id.id, + "move_type": "out_invoice", + "invoice_line_ids": [ + Command.create({ + "name": "6% Commission", + "quantity": 1, + "price_unit": selling_price * 0.06, + }), + Command.create({ + "name": "Administrative Fees", + "quantity": 1, + "price_unit": 100.0, + }), + ], + }) + + return res \ No newline at end of file From 27199c6b165603122cd4b17e84f6410b76cc17b9 Mon Sep 17 00:00:00 2001 From: waqasahmed-gif Date: Fri, 24 Apr 2026 15:41:35 +0500 Subject: [PATCH 2/5] Added record rules to restrict agents to own/unassigned properties and allow managers full access. --- estate/security/security.xml | 15 +++++++++++++++ estate/tests/__init__.py | 0 estate/tests/test_estate.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 estate/tests/__init__.py create mode 100644 estate/tests/test_estate.py diff --git a/estate/security/security.xml b/estate/security/security.xml index 3e78d732f5f..c5da90eadb2 100644 --- a/estate/security/security.xml +++ b/estate/security/security.xml @@ -39,4 +39,19 @@ ]"/> + + Agent: own or unassigned properties + + + + ['|', ('seller_id', '=', False), ('seller_id', '=', user.id)] + + + + + Manager: see all properties + + + [(1, '=', 1)] + \ No newline at end of file diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/estate/tests/test_estate.py b/estate/tests/test_estate.py new file mode 100644 index 00000000000..7605e62a7c5 --- /dev/null +++ b/estate/tests/test_estate.py @@ -0,0 +1,28 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import tagged + +# The CI will run these tests after all the modules are installed, +# not right after installing the one defining it. +@tagged('post_install', '-at_install') +class EstateTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + # add env on cls and many other things + super(EstateTestCase, cls).setUpClass() + + # create the data for each tests. By doing it in the setUpClass instead + # of in a setUp or in each test case, we reduce the testing time and + # the duplication of code. + cls.properties = cls.env['estate.property'].create([...]) + + def test_creation_area(self): + """Test that the total_area is computed like it should.""" + self.properties.living_area = 20 + self.assertRecordValues(self.properties, [ + {'name': ..., 'total_area': ...}, + {'name': ..., 'total_area': ...}, + ]) + + From f192b33f4d9125ec564cb4b9c5e614f06790dc07 Mon Sep 17 00:00:00 2001 From: Muhammad Qaism Shabbir Date: Tue, 28 Apr 2026 16:59:01 +0500 Subject: [PATCH 3/5] Added restricted access to data for companies and agents. --- estate/models/estate_property.py | 5 +++++ estate/security/security.xml | 10 +++++----- estate/views/estate_menus.xml | 9 ++++++--- estate/views/estate_property_views.xml | 5 ++++- estate_account/models/estate_property.py | 6 +++++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 1fa012ca890..4f068461602 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -57,6 +57,11 @@ def action_sold(self): seller_id = fields.Many2one("res.users", default=lambda self: self.env.user) tag_ids = fields.Many2many("estate.property.tag") offer_ids = fields.One2many("estate.property.offer", "property_id") + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.company + ) expected_price = fields.Integer(default=230) diff --git a/estate/security/security.xml b/estate/security/security.xml index c5da90eadb2..1eb11e7c984 100644 --- a/estate/security/security.xml +++ b/estate/security/security.xml @@ -14,7 +14,7 @@ - Real Estate + Real Estate User @@ -22,7 +22,7 @@ - Agent + Real Estate Agent @@ -31,7 +31,7 @@ - Manager + Real estate Manager - Agent: own or unassigned properties + Agent: own or unassigned properties in user's company - ['|', ('seller_id', '=', False), ('seller_id', '=', user.id)] + ['&', '|', ('seller_id', '=', False), ('seller_id', '=', user.id), ('company_id', '=', user.company_id.id)] diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml index 3205d3482c4..ee1bc7abab2 100644 --- a/estate/views/estate_menus.xml +++ b/estate/views/estate_menus.xml @@ -16,18 +16,21 @@ + sequence="10" + groups="estate.estate_group_manager"/> + sequence="1" + groups="estate.estate_group_manager"/> + sequence="2" + groups="estate.estate_group_manager"/> \ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 94d7c2f57bb..ba1763f46c0 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -98,6 +98,7 @@ + @@ -150,6 +151,7 @@ + @@ -163,7 +165,7 @@ options="{'no_create': True, 'no_open': True}"/> + options="{'no_create': True, 'no_open': True, 'color_field': 'color'}"/> @@ -232,6 +234,7 @@ + diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py index d380ed4bde5..9cbe2585ac1 100644 --- a/estate_account/models/estate_property.py +++ b/estate_account/models/estate_property.py @@ -9,7 +9,11 @@ def action_sold(self): for record in self: selling_price = record.selling_price or 0.0 - self.env["account.move"].create({ + # Check if the current user can update this property + record.check_access('write') + print(f"Access check passed for property {record.name}") + + self.env["account.move"].sudo().create({ "partner_id": record.buyer_id.id, "move_type": "out_invoice", "invoice_line_ids": [ From 9c185336527ffe1f147fcb8fafef81cfda77f63e Mon Sep 17 00:00:00 2001 From: Muhammad Qaism Shabbir Date: Wed, 29 Apr 2026 19:57:37 +0500 Subject: [PATCH 4/5] Added Pharma control Dashboard module. --- estate/__init__.py | 1 - estate/__manifest__.py | 44 -- estate/data/property_type_data.xml | 20 - estate/demo/property_offer_demo.xml | 49 -- estate/models/__init__.py | 4 - estate/models/estate_property.py | 189 ------- estate/models/property_offer.py | 90 --- estate/models/property_tag.py | 12 - estate/models/property_type.py | 33 -- estate/security/ir.model.access.csv | 9 - estate/security/security.xml | 57 -- estate/tests/test_estate.py | 28 - estate/views/estate_menus.xml | 36 -- estate/views/estate_property_tag_views.xml | 74 --- estate/views/estate_property_type.xml | 111 ---- estate/views/estate_property_views.xml | 259 --------- estate_account/__init__.py | 1 - estate_account/__manifest__.py | 26 - estate_account/models/__init__.py | 1 - estate_account/models/estate_property.py | 33 -- pharma_control_center/README.md | 526 ++++++++++++++++++ pharma_control_center/__init__.py | 1 + pharma_control_center/__manifest__.py | 40 ++ pharma_control_center/data/demo_medicines.xml | 52 ++ pharma_control_center/data/demo_patients.xml | 52 ++ pharma_control_center/models/__init__.py | 4 + .../models/pharma_control_center.py | 96 ++++ .../models/pharma_medicine.py | 203 +++++++ .../models/pharmacy_category.py | 12 + .../models/pharmacy_patient.py | 32 ++ pharma_control_center/security/groups.xml | 29 + .../security/ir.model.access.csv | 17 + .../security/pharmacy_patient_security.xml | 25 + .../security/pharmacy_security.xml | 30 + .../security/sale_order_security.xml | 25 + .../test/test_pharma_control_center.py | 0 .../views/pharma_control_center_views.xml | 58 ++ .../views/pharmacy_category_views.xml | 53 ++ .../views/pharmacy_medicine_views.xml | 190 +++++++ .../views/pharmacy_order_views.xml | 32 ++ .../views/pharmacy_patient_views.xml | 64 +++ 41 files changed, 1541 insertions(+), 1077 deletions(-) delete mode 100644 estate/__init__.py delete mode 100644 estate/__manifest__.py delete mode 100644 estate/data/property_type_data.xml delete mode 100644 estate/demo/property_offer_demo.xml delete mode 100644 estate/models/__init__.py delete mode 100644 estate/models/estate_property.py delete mode 100644 estate/models/property_offer.py delete mode 100644 estate/models/property_tag.py delete mode 100644 estate/models/property_type.py delete mode 100644 estate/security/ir.model.access.csv delete mode 100644 estate/security/security.xml delete mode 100644 estate/tests/test_estate.py delete mode 100644 estate/views/estate_menus.xml delete mode 100644 estate/views/estate_property_tag_views.xml delete mode 100644 estate/views/estate_property_type.xml delete mode 100644 estate/views/estate_property_views.xml delete mode 100644 estate_account/__init__.py delete mode 100644 estate_account/__manifest__.py delete mode 100644 estate_account/models/__init__.py delete mode 100644 estate_account/models/estate_property.py create mode 100644 pharma_control_center/README.md create mode 100644 pharma_control_center/__init__.py create mode 100644 pharma_control_center/__manifest__.py create mode 100644 pharma_control_center/data/demo_medicines.xml create mode 100644 pharma_control_center/data/demo_patients.xml create mode 100644 pharma_control_center/models/__init__.py create mode 100644 pharma_control_center/models/pharma_control_center.py create mode 100644 pharma_control_center/models/pharma_medicine.py create mode 100644 pharma_control_center/models/pharmacy_category.py create mode 100644 pharma_control_center/models/pharmacy_patient.py create mode 100644 pharma_control_center/security/groups.xml create mode 100644 pharma_control_center/security/ir.model.access.csv create mode 100644 pharma_control_center/security/pharmacy_patient_security.xml create mode 100644 pharma_control_center/security/pharmacy_security.xml create mode 100644 pharma_control_center/security/sale_order_security.xml rename estate/tests/__init__.py => pharma_control_center/test/test_pharma_control_center.py (100%) create mode 100644 pharma_control_center/views/pharma_control_center_views.xml create mode 100644 pharma_control_center/views/pharmacy_category_views.xml create mode 100644 pharma_control_center/views/pharmacy_medicine_views.xml create mode 100644 pharma_control_center/views/pharmacy_order_views.xml create mode 100644 pharma_control_center/views/pharmacy_patient_views.xml diff --git a/estate/__init__.py b/estate/__init__.py deleted file mode 100644 index 9a7e03eded3..00000000000 --- a/estate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models \ No newline at end of file diff --git a/estate/__manifest__.py b/estate/__manifest__.py deleted file mode 100644 index 522736fcdb7..00000000000 --- a/estate/__manifest__.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. -{ - 'name': 'estate', - 'version': '1.0', - 'summary': 'Added Real estate module', - 'description': """ - Real Estate Management Module - ============================= - Manage properties, offers, and real estate transactions. - """, - 'depends': [ - 'base_setup', - ], - 'author': 'Muhammad Qasim Shabbir', - 'license': 'LGPL-3', - 'category': 'Real Estate/Brokerage', - - # Required for Odoo 18 - 'installable': True, - 'application': True, - 'auto_install': False, - - # Add these later as you create files - "data": [ - "security/security.xml", - "security/ir.model.access.csv", - - # 1. FIRST: property views (defines actions) - "views/estate_property_views.xml", - "data/property_type_data.xml", - - # 2. SECOND: type + tag views (define actions used by menu) - "views/estate_property_type.xml", - "views/estate_property_tag_views.xml", - - # 3. LAST: menus (depends on actions above) - "views/estate_menus.xml", - ], - "demo": [ - "demo/property_offer_demo.xml", - - ] -} \ No newline at end of file diff --git a/estate/data/property_type_data.xml b/estate/data/property_type_data.xml deleted file mode 100644 index 00aa518b544..00000000000 --- a/estate/data/property_type_data.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - Residential - - - - Commercial - - - - Industrial - - - - Land - - - \ No newline at end of file diff --git a/estate/demo/property_offer_demo.xml b/estate/demo/property_offer_demo.xml deleted file mode 100644 index 4af15976d6c..00000000000 --- a/estate/demo/property_offer_demo.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - Big Villa - 1000000 - 0 - - - - - Small House - 200000 - 0 - - - - - - - - - - - - 10000 - 14 - - - - - - 1500000 - 14 - - - - - - - 1500001 - 14 - - - \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py deleted file mode 100644 index 4a9364876b5..00000000000 --- a/estate/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import estate_property -from . import property_offer -from . import property_tag -from . import property_type diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py deleted file mode 100644 index 4f068461602..00000000000 --- a/estate/models/estate_property.py +++ /dev/null @@ -1,189 +0,0 @@ -from odoo import models, fields, api -from datetime import timedelta - -from odoo.exceptions import UserError, ValidationError -from odoo.tools import float_is_zero, float_compare - - -class EstateProperty(models.Model): - _name = "estate.property" - _description = "Real Estate Property" - _order = "id desc" - - name = fields.Char(required=True) - description = fields.Text() - postcode = fields.Char() - date_availability = fields.Date( - copy=False, - default=lambda self: fields.Date.today() + timedelta(days=90) - ) - - - living_area = fields.Integer(string='Living Area (sqm)',default=120) - - active = fields.Boolean(default=True) - state = fields.Selection([ - ('new', 'New'), - ('offer_received', 'Offer Received'), - ('offer_accepted', 'Offer Accepted'), - ('sold', 'Sold'), - ('cancelled', 'Cancelled'), - ], required=True, copy=False, default='new') - - - def unlink(self): - for record in self: - if record.state not in ("new", "cancelled"): - raise UserError("You can only delete properties in New or Cancelled state.") - return super().unlink() - - - def action_cancel(self): - for record in self: - if record.state == 'sold': - raise UserError("A sold property cannot be cancelled.") - record.state = 'cancelled' - return True - - def action_sold(self): - for record in self: - if record.state == 'cancelled': - raise UserError("A cancelled property cannot be set as sold.") - record.state = 'sold' - return True - - property_type_id = fields.Many2one("estate.property.type") - buyer_id = fields.Many2one("res.partner", copy=False) - seller_id = fields.Many2one("res.users", default=lambda self: self.env.user) - tag_ids = fields.Many2many("estate.property.tag") - offer_ids = fields.One2many("estate.property.offer", "property_id") - company_id = fields.Many2one( - "res.company", - required=True, - default=lambda self: self.env.company - ) - - - expected_price = fields.Integer(default=230) - selling_price = fields.Float(copy=False) - bedrooms = fields.Integer(default=2) - facades = fields.Integer(string="Facades",default=130) - garage = fields.Boolean() - - garden = fields.Boolean() - garden_area = fields.Integer(string='Garden Area (sqm)',default=220) - garden_orientation = fields.Selection([ - ('north', 'North'), - ('south', 'South'), - ('east', 'East'), - ('west', 'West') - ]) - - - @api.onchange('garden') - def _onchange_garden(self): - for record in self: - if record.garden: - record.garden_area = 10 - record.garden_orientation = 'north' - else: - record.garden_area = 0 - record.garden_orientation = False - - # Add this computed field - total_area = fields.Integer( - string='Total Area (sqm)', - compute='_compute_total_area', - store=True # Optional: stores in database for better performance - ) - - best_price = fields.Float( - string="Best Offer is 123210 when we computed it to zero.", - compute="_compute_best_price", - store=True, - default=123 - ) - - price_per_sqm = fields.Float( - compute="_compute_price_per_sqm", - store=True - ) - - @api.depends('living_area', 'garden_area','facades') - def _compute_total_area(self): - for record in self: - record.total_area = (record.living_area or 0) + (record.garden_area or 0) + (record.facades or 0) - - - @api.depends('expected_price', 'total_area') - def _compute_price_per_sqm(self): - for record in self: - if record.total_area: - record.price_per_sqm = record.expected_price / record.total_area - else: - record.price_per_sqm = 0 - - @api.depends('offer_ids.price') - def _compute_best_price(self): - for record in self: - prices = record.offer_ids.mapped('price') - record.best_price = max(prices) if prices else 0 - - - - @api.constrains('expected_price', 'selling_price') - def _check_selling_price(self): - for record in self: - - # If selling price is not set yet (0 or False), skip validation - if float_is_zero(record.selling_price, precision_digits=2): - continue - - # Ensure selling price >= 90% of expected price - min_price = record.expected_price * 0.90 - - comparison = float_compare( - record.selling_price, - min_price, - precision_digits=2 - ) - - if comparison < 0: - raise ValidationError( - "Selling price cannot be lower than 90% of expected price." - ) - - - @api.constrains('date_availability') - def _check_date_end(self): - for record in self: - if record.date_availability < fields.Date.today(): - raise ValidationError("The end date cannot be set in the past") - - @api.constrains('expected_price') - def _check_expected_price(self): - for record in self: - if record.expected_price < 220: - raise ValidationError("Expected price cannot be lower than 220") - - - @api.model - def create(self, vals): - - property_id = vals.get("property_id") - amount = vals.get("price") - - if property_id and amount: - property_rec = self.env["estate.property"].browse(property_id) - - existing_offers = property_rec.offer_ids.mapped("price") - - if existing_offers and amount < max(existing_offers): - raise UserError("Offer must be higher than existing offers.") - - # update property state - property_rec.state = "offer_received" - - return super().create(vals) - - \ No newline at end of file diff --git a/estate/models/property_offer.py b/estate/models/property_offer.py deleted file mode 100644 index e7bfee3bf44..00000000000 --- a/estate/models/property_offer.py +++ /dev/null @@ -1,90 +0,0 @@ -from odoo import models, fields, api -from datetime import timedelta - -from odoo.exceptions import UserError - - -class EstatePropertyOffer(models.Model): - _name = "estate.property.offer" - _description = "Property Offer" - _order = "price desc" - - price = fields.Float(required=True) - validity = fields.Integer(default=7) - - date_deadline = fields.Date( - compute="_compute_date_deadline", - inverse="_inverse_date_deadline", - store=True - ) - - property_type_id = fields.Many2one( - related='property_id.property_type_id', - store=True, - readonly=True - ) - - @api.depends('create_date', 'validity') - def _compute_date_deadline(self): - for record in self: - if record.create_date: - record.date_deadline = record.create_date.date() + timedelta(days=record.validity) - else: - record.date_deadline = fields.Date.today() + timedelta(days=record.validity) - - def _inverse_date_deadline(self): - for record in self: - if record.create_date and record.date_deadline: - record.validity = (record.date_deadline - record.create_date.date()).days - - partner_id = fields.Many2one("res.partner", required=True) - - status = fields.Selection( - [("accepted", "Accepted"), ("refused", "Refused")], - copy=False - ) - - def action_accept_offer(self): - for offer in self: - - # prevent multiple accepted offers - accepted = self.search([ - ('property_id', '=', offer.property_id.id), - ('status', '=', 'accepted') - ]) - if accepted: - raise UserError("Only one offer can be accepted for a property.") - - offer.status = 'accepted' - - # update property - offer.property_id.buyer_id = offer.partner_id - offer.property_id.selling_price = offer.price - offer.property_id.state = 'offer_accepted' - - def action_refuse_offer(self): - for offer in self: - if offer.status == 'accepted': - raise UserError("Accepted offer cannot be refused.") - offer.status = 'refused' - - property_id = fields.Many2one("estate.property", required=True) - - @api.model - def create(self, vals): - - property_id = vals.get("property_id") - amount = vals.get("price") - - if property_id and amount: - property_rec = self.env["estate.property"].browse(property_id) - - existing_offers = property_rec.offer_ids.mapped("price") - - if existing_offers and amount < max(existing_offers): - raise UserError("Offer must be higher than existing offers.") - - # update property state - property_rec.state = "offer_received" - - return super().create(vals) \ No newline at end of file diff --git a/estate/models/property_tag.py b/estate/models/property_tag.py deleted file mode 100644 index fa3637cbeb9..00000000000 --- a/estate/models/property_tag.py +++ /dev/null @@ -1,12 +0,0 @@ -from odoo import fields, models - - -class EstatePropertyTag(models.Model): - _name = "estate.property.tag" - _description = "Property Tag" - _order = "name" - - name = fields.Char(required=True) - - # estate.property.tag - color = fields.Integer() \ No newline at end of file diff --git a/estate/models/property_type.py b/estate/models/property_type.py deleted file mode 100644 index 2307f9c5fb1..00000000000 --- a/estate/models/property_type.py +++ /dev/null @@ -1,33 +0,0 @@ -from odoo import models, fields - -class EstatePropertyType(models.Model): - _name = "estate.property.type" - _description = "Property Type" - _order = "sequence,name" - - name = fields.Char(required=True) - sequence = fields.Integer(default=10) - - property_ids = fields.One2many( - comodel_name='estate.property', - inverse_name='property_type_id', - string="Properties" - ) - - buyer_id = fields.Many2one("res.partner", string="Buyer") - seller_id = fields.Many2one( - "res.users", - string="Salesperson", - default=lambda self: self.env.user - ) - - offer_ids = fields.One2many( - comodel_name='estate.property.offer', - inverse_name='property_type_id' - ) - - offer_count = fields.Integer(compute="_compute_offer_count") - - def _compute_offer_count(self): - for record in self: - record.offer_count = len(record.offer_ids) \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv deleted file mode 100644 index 84cf471d8a0..00000000000 --- a/estate/security/ir.model.access.csv +++ /dev/null @@ -1,9 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_estate_property_manager,Estate Property Manager Full Access,model_estate_property,estate.estate_group_manager,1,1,1,0 -access_estate_property_user,Estate Property User Access,model_estate_property,estate.estate_group_user,1,1,1,0 -access_estate_property_type_manager,Estate Property Type Manager,model_estate_property_type,estate.estate_group_manager,1,1,1,1 -access_estate_property_type_user,Estate Property Type User,model_estate_property_type,estate.estate_group_user,1,0,0,0 -access_estate_property_tag_manager,Estate Property Tag Manager,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 -access_estate_property_tag_user,Estate Property Tag User,model_estate_property_tag,estate.estate_group_user,1,0,0,0 -access_estate_property_offer_manager,Estate Property Offer Manager,model_estate_property_offer,estate.estate_group_manager,1,1,1,1 -access_estate_property_offer_user,Estate Property Offer User,model_estate_property_offer,estate.estate_group_user,1,1,1,0 \ No newline at end of file diff --git a/estate/security/security.xml b/estate/security/security.xml deleted file mode 100644 index 1eb11e7c984..00000000000 --- a/estate/security/security.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - Real Estate - Real Estate Management - 10 - - - - - - - Real Estate User - - - - - - - - Real Estate Agent - - - - - - - - - Real estate Manager - - - - - - Agent: own or unassigned properties in user's company - - - - ['&', '|', ('seller_id', '=', False), ('seller_id', '=', user.id), ('company_id', '=', user.company_id.id)] - - - - - Manager: see all properties - - - [(1, '=', 1)] - - \ No newline at end of file diff --git a/estate/tests/test_estate.py b/estate/tests/test_estate.py deleted file mode 100644 index 7605e62a7c5..00000000000 --- a/estate/tests/test_estate.py +++ /dev/null @@ -1,28 +0,0 @@ -from odoo.tests.common import TransactionCase -from odoo.exceptions import UserError -from odoo.tests import tagged - -# The CI will run these tests after all the modules are installed, -# not right after installing the one defining it. -@tagged('post_install', '-at_install') -class EstateTestCase(TransactionCase): - - @classmethod - def setUpClass(cls): - # add env on cls and many other things - super(EstateTestCase, cls).setUpClass() - - # create the data for each tests. By doing it in the setUpClass instead - # of in a setUp or in each test case, we reduce the testing time and - # the duplication of code. - cls.properties = cls.env['estate.property'].create([...]) - - def test_creation_area(self): - """Test that the total_area is computed like it should.""" - self.properties.living_area = 20 - self.assertRecordValues(self.properties, [ - {'name': ..., 'total_area': ...}, - {'name': ..., 'total_area': ...}, - ]) - - diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml deleted file mode 100644 index ee1bc7abab2..00000000000 --- a/estate/views/estate_menus.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml deleted file mode 100644 index c34085d82d9..00000000000 --- a/estate/views/estate_property_tag_views.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - estate.property.tag.list - estate.property.tag - - - - - - - - - - - - - estate.property.tag.form - estate.property.tag - -
- -
-

- -

-
- - - - - - -
-
-
-
- - - - - - estate.property.tag.search - estate.property.tag - - - - - - - - - - - - Property Tags - estate.property.tag - list,form - -

- Create a new property tag! -

-

- Create tags to categorize and label properties - (e.g., Renovated, Garden, Parking, Pet-Friendly, etc.). -

-
-
- -
\ No newline at end of file diff --git a/estate/views/estate_property_type.xml b/estate/views/estate_property_type.xml deleted file mode 100644 index 57479ae2a4f..00000000000 --- a/estate/views/estate_property_type.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - estate.property.type.list - estate.property.type - - - - - - - - - - - - - - estate.property.type.form - estate.property.type - -
- - - -
-

- -

-
- - - - - - - - - - -
- - - properties of this type exist. - -
- -
- No properties of this type yet. -
- - - - - - - - - - - - - - -
-
- -
-
-
-
- - - - - - estate.property.type.search - estate.property.type - - - - - - - - - - - - Property Types - estate.property.type - list,form - -

- Create a new property type! -

-

- Define and manage different types of properties. -

-
-
- -
\ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml deleted file mode 100644 index ba1763f46c0..00000000000 --- a/estate/views/estate_property_views.xml +++ /dev/null @@ -1,259 +0,0 @@ - - - - - - - - estate.property.kanban - estate.property - - - - - - - - - - - - - - -
- - - - - - - -
- -
- - -
- -
- - -
- Expected: -
- - -
- Best Offer: - - - - - -
- - -
- Selling Price: -
- - -
- Sold - Accepted - Offers - Cancelled - New -
- -
-
-
- -
- -
-
- - - - - - estate.property.list - estate.property - - - - - - - - - - - - - - - - - - - - - estate.property.form - estate.property - -
- -
-
- - - -
- This property has been SOLD -
- -
- This property listing has been CANCELLED -
- -

- -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - - - - estate.property.search - estate.property - - - - - - - - - - - - - - - - - - - - Properties - estate.property - list,form,kanban - {'search_default_available': 1} - - -
\ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py deleted file mode 100644 index 9a7e03eded3..00000000000 --- a/estate_account/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models \ No newline at end of file diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py deleted file mode 100644 index 931d05ac6c2..00000000000 --- a/estate_account/__manifest__.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. -{ - 'name': 'estate account', - 'version': '1.0', - 'summary': 'Added Real estate account module', - 'description': """ - Real Estate Account Management Module - ============================= - Manage properties, offers, and real estate transactions. - """, - 'depends': [ - 'base_setup', - "estate", - "account" - ], - 'author': 'Muhammad Qasim Shabbir', - 'license': 'LGPL-3', - 'category': 'Real Estate', - - # Required for Odoo 18 - 'installable': True, - 'application': True, - 'auto_install': False, - -} \ No newline at end of file diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py deleted file mode 100644 index f4c8fd6db6d..00000000000 --- a/estate_account/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import estate_property \ No newline at end of file diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py deleted file mode 100644 index 9cbe2585ac1..00000000000 --- a/estate_account/models/estate_property.py +++ /dev/null @@ -1,33 +0,0 @@ -from odoo import models, Command - - -class EstateProperty(models.Model): - _inherit = "estate.property" - def action_sold(self): - res = super().action_sold() - - for record in self: - selling_price = record.selling_price or 0.0 - - # Check if the current user can update this property - record.check_access('write') - print(f"Access check passed for property {record.name}") - - self.env["account.move"].sudo().create({ - "partner_id": record.buyer_id.id, - "move_type": "out_invoice", - "invoice_line_ids": [ - Command.create({ - "name": "6% Commission", - "quantity": 1, - "price_unit": selling_price * 0.06, - }), - Command.create({ - "name": "Administrative Fees", - "quantity": 1, - "price_unit": 100.0, - }), - ], - }) - - return res \ No newline at end of file diff --git a/pharma_control_center/README.md b/pharma_control_center/README.md new file mode 100644 index 00000000000..06d8ae02a5e --- /dev/null +++ b/pharma_control_center/README.md @@ -0,0 +1,526 @@ +# πŸ’Š Pharma Control Center (Odoo 18+ Module) + +## Overview +**Pharma Control Center** is a comprehensive Odoo 18+ module for modern pharmacy management. It provides complete functionality for medicine cataloguing, batch/expiry tracking, stock management, patient records management, doctor-patient assignments, invoicing, and real-time clinical dashboards. The module follows Odoo 18 best practices (`` instead of deprecated ``, emoji-rich UI, computed fields, and modern QWeb templates) and seamlessly integrates with Odoo's accounting, sales, and product modules for complete pharmacy operations. + +--- + +## ✨ Features + +### πŸ₯ Complete Medicine Management +- **Full Medicine Catalogue** – name, batch number, manufacturer, barcode, category, sub-category +- **Pricing Management** – selling price, cost price, automatic profit margin calculation +- **Stock Control** – tracked quantity, reorder level, "Need Reorder" alerts +- **Expiry Tracking** – automatic days-to-expiry calculation and status (Fresh / Expiring Soon / Expired) +- **Storage Conditions** – room temperature, cold storage, frozen storage +- **License Categorization** – medicines marked with Pharmacy Category (Green / Blue / White) for role-based access control +- **Dosage & Safety** – dosage instructions and side effects documentation + +### πŸ‘₯ Advanced Patient Management +- **Dedicated Patient Model** – comprehensive patient records with personal info, medical history, allergies, blood group +- **Doctor Assignment** – each patient assigned to a specific doctor (user in Doctor group) +- **Role-Based Visibility** – doctors see only their own patients; managers see all patients; patients cannot access patient list +- **Medical Records** – medical history, allergies, blood group, contact information +- **Doctor-Patient Relationship** – secure patient-to-doctor assignment with edit permissions + +### πŸ›’ Order Management & Invoicing (Full Integration) +- **"Order Now" Button** – available on each medicine (form and kanban views) +- **Automatic Sale Order Creation** – creates an Odoo Sale Order with the medicine as line item +- **Automatic Invoice Generation** – automatically creates and posts an invoice upon ordering +- **Order Menu** – dedicated "Orders" menu lists all sale orders created via the module (visible to managers and doctors, read-only for doctors) +- **Order Restrictions** – record rules restrict order editing to managers only; doctors/patients have read-only access +- **Order Status Tracking** – after ordering, the button changes to "βœ… Ordered" and the medicine cannot be re-ordered +- **Product Linking** – automatically creates linked products in Odoo's product module +- **Invoice Tracking** – tracks last sale order and invoice for each medicine + +### πŸ“Š Comprehensive Dashboard +- **Medicine Statistics** – total medicines, total stock quantity, stock value (quantity Γ— price), out-of-stock count, low stock count (1-9 units), expiring soon count (≀30 days), expired count +- **Patient Statistics** – total patients, my patients (for doctors), dynamic patient list +- **Dynamic Patient List** – displayed directly on the dashboard, filtered by user role + - Doctors see only their assigned patients (read-only) + - Managers see all patients (read-only) + - Patients see nothing +- **Real-Time Updates** – statistics update automatically based on current inventory +- **Visual Alerts** – color-coded badges for low stock, expiring soon, and expired medicines + +### πŸ” Role-Based Access Control (Complete) +Three user groups with distinct permissions: + +#### 🟒 **Pharmacy Manager Group** +- **Full Access** to all medicines (all license categories) +- **Full CRUD** on patients +- **Full CRUD** on categories and configuration +- **Can Place Orders** – "Order Now" button visible and functional +- **Can Edit Orders** – full read/write access to all sale orders +- **Can View Dashboard** – see all statistics and all patients +- **Menu Access** – Dashboard, Medicines, Patients, Orders, Categories (Configuration) + +#### πŸ”΅ **Doctor Group** +- **Read-Only Access** to Blue (Limited License) and White (OTC) medicines only +- **Read & Write Own Patients** – can manage patients they are assigned to +- **Cannot Create/Edit Medicines** – read-only access only +- **Cannot Place Orders** – "Order Now" button hidden (UI + security check) +- **Can View Orders** – read-only access to all orders (record rule restricted) +- **Can View Dashboard** – see statistics and their own patients only +- **Menu Access** – Dashboard, Medicines, Patients, Orders (read-only) + +#### βšͺ **Patient Group** +- **Read-Only Access** to White (Basic OTC) medicines only +- **No Patient List Access** – cannot see patient records or list +- **No Dashboard Access** – dashboard menu hidden +- **No Ordering Capability** – "Order Now" button hidden +- **Menu Access** – Medicines (white licenses only) + +### πŸ”’ Security Layer Details + +**Medicine Visibility (Record Rules):** +- Patients see ONLY `license_category = 'white'` medicines +- Doctors see `license_category in ['blue', 'white']` medicines +- Managers see ALL medicines (no restrictions) + +**Patient Visibility (Record Rules):** +- Patients cannot view any patient records (CSV: no read access) +- Doctors can read/write/create patients assigned to them: `[('doctor_id','=',user.id)]` +- Doctors cannot delete patients (unlink=False) +- Managers have full CRUD on all patients + +**Sale Order Visibility (Record Rules):** +- Global rule: all sale orders are read-only by default +- Manager override: managers have full read/write/create/delete on sale orders +- Doctors/Patients have read-only access via global rule + +**Category Visibility:** +- Patients cannot view categories (CSV: no read access) +- Doctors can view categories (read-only) +- Managers have full CRUD on categories + +### 🎨 Modern User Interface +- **List View** – replaces deprecated ``, multi-edit support for batch operations +- **Form View** – intuitive grouped layouts with emojis, placeholders, and badges +- **Kanban View** – card-based design with license/expiry badges, stock status, and "Order Now" button +- **Search View** – advanced filters for: + - Stock status (In Stock, Out of Stock, Need Reorder) + - Expiry status (Expired, Expiring Soon) + - License category filters + - Medicine name, batch, manufacturer search + - Group-by options (Category, Expiry Status, Storage, License) +- **Dashboard Form** – statistics display with visual widgets and alerts +- **Emoji Rich** – πŸ’Š πŸ₯ πŸ“Š πŸ’° πŸ”’ for enhanced visual recognition + +### πŸ“¦ Additional Models & Data Structures + +**pharmacy.category** – Hierarchical Medicine Categories +- Parent-child relationships for taxonomy (e.g., Antibiotics β†’ Penicillin) +- Translatable names and descriptions +- Manager-only access + +**pharmacy.patient** – Patient Records +- Linked to doctors via `doctor_id` (Many2one res.users) +- Medical information (history, allergies, blood group) +- Contact details (phone, email, address) +- Active/inactive status + +--- + +## 🧱 Module Structure +``` +odoo-tutorials/ +└── pharma_control_center/ + β”œβ”€β”€ __init__.py # Module initialization + β”œβ”€β”€ __manifest__.py # Module manifest & settings + β”œβ”€β”€ README.md # This file + β”‚ + β”œβ”€β”€ data/ + β”‚ β”œβ”€β”€ demo_medicines.xml # Demo medicine records (various categories & licenses) + β”‚ └── demo_patients.xml # Demo doctor & patient records + β”‚ + β”œβ”€β”€ models/ + β”‚ β”œβ”€β”€ __init__.py # Model imports + β”‚ β”œβ”€β”€ pharma_control_center.py # Dashboard model (statistics & patient list) + β”‚ β”œβ”€β”€ pharma_medicine.py # Medicine model (CRUD, ordering, invoicing) + β”‚ β”œβ”€β”€ pharmacy_category.py # Medicine category model (hierarchical) + β”‚ └── pharmacy_patient.py # Patient model (doctor assignment, records) + β”‚ + β”œβ”€β”€ security/ + β”‚ β”œβ”€β”€ groups.xml # User groups (Patient, Doctor, Manager) + β”‚ β”œβ”€β”€ ir.model.access.csv # Model-level access permissions + β”‚ β”œβ”€β”€ pharmacy_security.xml # Record rules for medicines (license-based) + β”‚ β”œβ”€β”€ pharmacy_patient_security.xml # Record rules for patients (doctor assignment) + β”‚ └── sale_order_security.xml # Record rules for sale orders (manager access) + β”‚ + β”œβ”€β”€ views/ + β”‚ β”œβ”€β”€ pharma_control_center_views.xml # Dashboard views & root menu + β”‚ β”œβ”€β”€ pharmacy_medicine_views.xml # Medicine list/form/kanban/search & menu + β”‚ β”œβ”€β”€ pharmacy_patient_views.xml # Patient list/form/search & menu + β”‚ β”œβ”€β”€ pharmacy_category_views.xml # Category views & menu + β”‚ └── pharmacy_order_views.xml # Sale order lists & Orders menu + β”‚ + └── test/ + └── test_pharma_control_center.py # Unit tests for core functionality +``` + +--- + +## πŸ”§ Technical Details + +### Model: `pharmacy.medicine` + +| Field | Type | Description | Required | Notes | +|-------|------|-------------|----------|-------| +| `name` | Char | Medicine name | βœ“ | Max 255 chars | +| `description` | Text | Short description | - | Optional | +| `manufacturer` | Char | Manufacturer name | - | Optional | +| `barcode` | Char | Product barcode (EAN/UPC) | - | Optional, for scanning | +| `category_id` | Many2one | Link to `pharmacy.category` | βœ“ | Prevents deletion of category if medicines exist | +| `sub_category` | Char | Optional sub-category | - | Text field (not entity-linked) | +| `license_category` | Selection | `green` / `blue` / `white` | βœ“ | Controls visibility & access | +| `batch_number` | Char | Batch/Lot number | βœ“ | Required for traceability | +| `expiry_date` | Date | Expiry/Expiration date | βœ“ | Used for expiry calculations | +| `price` | Float | Selling price per unit | βœ“ | Required for invoicing | +| `cost_price` | Float | Purchase/cost price | - | Optional, used for margin calculation | +| `profit_margin` | Float | Profit margin % | - | Computed: `((price - cost_price) / cost_price) * 100` | +| `quantity` | Integer | Stock quantity | βœ“ | Default: 0, tracked for inventory | +| `reorder_level` | Integer | Reorder threshold | - | Default: 10, triggers "Need Reorder" alert | +| `need_reorder` | Boolean | Computed low stock flag | - | Computed: `quantity <= reorder_level` | +| `in_stock` | Boolean | Computed stock status | - | Computed: `quantity > 0` | +| `days_to_expiry` | Integer | Days remaining until expiry | - | Computed: `(expiry_date - today).days` | +| `expiry_status` | Selection | Status badge | - | Computed: `fresh` / `expiring_soon` / `expired` | +| `storage_location` | Selection | Storage condition | βœ“ | `room_temp` / `cold` / `frozen` | +| `dosage` | Char | Usage instructions | - | Optional, e.g., "1 tablet twice daily" | +| `side_effects` | Text | Possible side effects | - | Optional, medical notes | +| `product_id` | Many2one | Linked `product.product` | - | Auto-created on first order, read-only | +| `is_ordered` | Boolean | Order status flag | - | True after "Order Now" clicked, prevents re-ordering | +| `last_sale_order_id` | Many2one | Last `sale.order` created | - | Read-only, for tracking | +| `last_invoice_id` | Many2one | Last `account.move` created | - | Read-only, for tracking | + +**Computed Fields (Auto-updated):** +- `profit_margin` – Depends on `price`, `cost_price` +- `need_reorder` – Depends on `quantity`, `reorder_level` +- `in_stock` – Depends on `quantity` +- `days_to_expiry` – Depends on `expiry_date` (daily refresh) +- `expiry_status` – Depends on `expiry_date` (daily refresh) + +### Model: `pharma.control.center` (Dashboard) + +| Field | Type | Description | Computed | Notes | +|-------|------|-------------|----------|-------| +| `name` | Char | Dashboard name | - | Default: "Pharmacy Dashboard", required | +| `description` | Text | Dashboard description | - | Optional | +| `last_updated` | Datetime | Last update timestamp | - | Auto-set on creation | +| `total_medicines` | Integer | Count of all medicines | βœ“ | `COUNT(pharmacy.medicine)` | +| `total_stock_quantity` | Integer | Sum of all quantities | βœ“ | `SUM(quantity)` across all medicines | +| `stock_value` | Float | Inventory value (β‚Ή) | βœ“ | `SUM(quantity Γ— price)` | +| `out_of_stock_count` | Integer | Medicines with qty = 0 | βœ“ | Count where `quantity == 0` | +| `low_stock_count` | Integer | Medicines with 1-9 units | βœ“ | Count where `0 < quantity < 10` | +| `expiring_soon_count` | Integer | Medicines expiring ≀30 days | βœ“ | Count where `today < expiry_date <= today + 30 days` | +| `expired_count` | Integer | Medicines past expiry | βœ“ | Count where `expiry_date < today` | +| `total_patients` | Integer | Total patient records | βœ“ | `COUNT(pharmacy.patient)` | +| `my_patients` | Integer | Doctor's assigned patients | βœ“ | Count for doctors only, `COUNT(pharmacy.patient where doctor_id=user)` | +| `patient_ids` | One2many | Patient records list | βœ“ | Role-filtered (doctors: own patients; managers: all; patients: none) | + +**Computed Fields (Live Updates):** +- All numeric fields recompute on each dashboard access +- `patient_ids` filters based on current user's role + +### Model: `pharmacy.category` + +| Field | Type | Description | Required | Notes | +|-------|------|-------------|----------|-------| +| `name` | Char | Category name | βœ“ | Translatable | +| `code` | Char | Short code | - | Optional, e.g., "ANTIBIOTIC" | +| `description` | Text | Category description | - | Optional | +| `parent_id` | Many2one | Parent category (self) | - | For hierarchical classification | +| `child_ids` | One2many | Child categories (self) | - | Auto-computed inverse | + +**Use Case:** Create taxonomy like: +- Antibiotics (Parent) + - Penicillins (Child) + - Cephalosporins (Child) + +### Model: `pharmacy.patient` + +| Field | Type | Description | Required | Notes | +|-------|------|-------------|----------|-------| +| `name` | Char | Patient full name | βœ“ | 255 chars max | +| `age` | Integer | Patient age | - | Optional | +| `gender` | Selection | Gender | - | `male` / `female` / `other` | +| `phone` | Char | Contact number | - | Optional | +| `email` | Char | Email address | - | Optional | +| `address` | Text | Full address | - | Optional | +| `blood_group` | Selection | Blood type | - | `A+` / `A-` / `B+` / `B-` / `O+` / `O-` / `AB+` / `AB-` | +| `doctor_id` | Many2one | Assigned doctor (`res.users`) | βœ“ | Doctor group member, controls visibility | +| `medical_history` | Text | Past medical conditions | - | Free-form notes | +| `allergies` | Text | Known allergies | - | Critical for prescription safety | +| `active` | Boolean | Is active patient | - | For soft-delete, default True | + +**Access Logic:** +- Doctors can CRUD their assigned patients only +- Managers can CRUD all patients +- Patients have no access + +--- + +## πŸ” Security Groups & Record Rules + +### User Groups (3-tier) + +**1. Patient Group** (`group_pharmacy_patient`) +- Lowest privilege level +- Purpose: End-users buying OTC medicines +- Access: White-license medicines ONLY +- Actions: View, read medicine information +- Visibility: No patient list, no dashboard + +**2. Doctor Group** (`group_pharmacy_doctor`) +- Medium privilege level +- Purpose: Medical professionals prescribing medicines +- Access: Blue & White medicines (licensed + OTC) +- Actions: View medicines, manage assigned patients +- Visibility: See dashboard, patients, orders (read-only), medicines list + +**3. Manager Group** (`group_pharmacy_manager`) +- Full administrative access +- Purpose: Pharmacy administrators/owners +- Access: All medicines (Green, Blue, White) +- Actions: Full CRUD on all objects, place orders, create invoices +- Visibility: Full access to all menus, dashboards, orders + +### Record Rules (ir.rule) + +#### Medicines (`pharmacy.medicine`) +``` +Patient: domain [('license_category', '=', 'white')] β†’ read-only +Doctor: domain [('license_category', 'in', ['blue', 'white'])] β†’ read-only +Manager: no rule β†’ full CRUD +``` + +#### Patients (`pharmacy.patient`) +``` +Patient: no read access (CSV-level) +Doctor: domain [('doctor_id', '=', user.id)] β†’ read/write/create (no delete) +Manager: domain [(1, '=', 1)] β†’ full CRUD +``` + +#### Sale Orders (`sale.order`) +``` +Global: read-only for all users (domain [(1, '=', 1)]) +Manager Override: full CRUD (domain [(1, '=', 1)], all perms=True) +Doctors: read-only via global rule +``` + +#### Categories (`pharmacy.category`) +``` +Patient: no read access (CSV-level) +Doctor: read-only (CSV-level) +Manager: full CRUD (CSV-level) +``` + +### Model-Level Permissions (ir.model.access) + +| Model | Patient | Doctor | Manager | Notes | +|-------|---------|--------|---------|-------| +| `pharmacy.medicine` | Read | Read | Create/Read/Write/Delete | Via record rules | +| `pharma.control.center` | No Access | Read | Create/Read/Write/Delete | Dashboard model | +| `pharmacy.category` | No Access | Read | Create/Read/Write/Delete | Config only for managers | +| `pharmacy.patient` | No Access | Create/Read/Write | Create/Read/Write/Delete | Doc via record rule | +| `sale.order` | No Access | Read | Create/Read/Write/Delete | Via record rules | + +### Menu Item Visibility + +| Menu | Patient | Doctor | Manager | Visible | +|------|---------|--------|---------|---------| +| πŸ’Š Pharma Control Center (root) | - | - | - | Always | +| πŸ“Š Dashboard | βœ— | βœ“ | βœ“ | No | +| πŸ’Š Medicines | βœ— | βœ“ | βœ“ | Yes | +| πŸ‘₯ Patients | βœ— | βœ“ | βœ“ | Yes | +| 🧾 Orders | βœ— | βœ“ | βœ“ | Yes | +| βš™οΈ Categories | βœ— | βœ— | βœ“ | Yes (Config) | + +--- + +## πŸ“¦ Dependencies + +- `base_setup` – Odoo base setup module (users, groups, settings) +- `product` – Odoo product module (for product creation on ordering) +- `account` – Odoo accounting module (for invoice generation) +- `sale` – Odoo sales module (for sale order creation) + +All dependencies are core Odoo modules. No third-party packages required. + +--- + +## πŸš€ Installation & Setup + +### Step 1: Copy Module +```bash +cp -r pharma_control_center /path/to/odoo/addons/ +``` + +### Step 2: Update Apps List (Odoo UI) +1. Go to **Apps** menu +2. Click **Update Apps List** button +3. Click **Update** in the confirmation dialog + +### Step 3: Install Module +1. Search for "Pharma Control Center" in Apps +2. Click the module card +3. Click **Install** button +4. Wait for installation to complete (green checkmark) + +### Step 4: Configure User Groups +1. Go to **Settings** β†’ **Users & Companies** β†’ **Users** (or **Users**) +2. Select a user +3. Scroll to **Access Rights** section +4. Check one of: + - βœ“ **Pharma Control Center / Patient** – for patients/end-users + - βœ“ **Pharma Control Center / Doctor** – for medical staff + - βœ“ **Pharma Control Center / Manager** – for administrators +5. **Save** user + +### Step 5: Create Demo Data (Optional) +Demo data is loaded automatically if `demo_patients.xml` and `demo_medicines.xml` are in `__manifest__.py` `data` list. No additional steps needed. + +### Step 6: Verify Installation +1. **Logout** and **Log In** to refresh user permissions +2. Check if **πŸ’Š Pharma Control Center** menu appears in sidebar +3. Navigate through menus (Dashboard, Medicines, Patients, Orders) +4. Verify correct data is visible based on your user group + +--- + +## πŸ§ͺ Demo Data + +The module includes comprehensive demo data: + +### Demo Users +- **doctor_demo** (Doctor Group) + - Username: `doctor_demo` + - Password: `demo` + - Assigned to: Doctor group + +### Demo Patients (3 records) +- Patient 1 β†’ Assigned to doctor_demo +- Patient 2 β†’ Assigned to doctor_demo +- Patient 3 β†’ Assigned to doctor_demo + +Medical info included: +- Age, gender, blood group +- Medical history +- Allergy information +- Contact details + +### Demo Medicines (Various) +- **Green License (Full):** 1-2 medicines +- **Blue License (Limited):** 2-3 medicines +- **White License (OTC):** 3-4 medicines + +Each includes: +- Batch number & expiry date (mix of fresh, expiring, expired) +- Stock levels (mix of in-stock, low-stock, out-of-stock) +- Pricing (cost & selling price) +- Storage conditions +- Dosage & side effects + +**To Load Demo Data:** +Ensure `demo_patients.xml` and `demo_medicines.xml` are listed in `__manifest__.py` under `data:`. Demo data loads automatically during module install. + +**To Clear Demo Data:** +- Edit demo XML files or delete records via Odoo UI +- No automated cleanup provided + +--- + +## πŸ› οΈ Compatibility & Requirements + +### Odoo Compatibility +- **Odoo 18.0+** (uses `` instead of deprecated ``, modern QWeb) +- **Tested on:** Odoo 18.0 + +### System Requirements +- **Python:** 3.10+ +- **PostgreSQL:** 13+ (recommended: 14+) +- **Browser:** Modern browser supporting ES6 JavaScript (Chrome, Firefox, Safari, Edge) + +### Known Limitations +- Orders (sale orders) created via module are standard Odoo orders; full order workflow (payment, shipping) uses standard Odoo modules (not included) +- Invoices created are account.move records; additional accounting is handled by Odoo account module +- No multi-language support for medicine names (but categories are translatable) +- Medicine photo/image storage not included (use Odoo's attachment system separately) + +--- + +## πŸ§‘β€πŸ’» Development & Customization + +### Adding Custom Fields +1. Edit relevant model file (`pharma_medicine.py`, `pharmacy_patient.py`, etc.) +2. Add field definition: `custom_field = fields.Char(...)` +3. Update XML view to display field +4. Run module upgrade in Odoo UI or CLI + +### Extending Security Rules +1. Edit security XML file (`pharmacy_security.xml`, etc.) +2. Add new `` with custom domain +3. Link to appropriate group +4. Reload module + +### Customizing Dashboard Statistics +Edit `_compute_statistics` method in `pharma_control_center.py` to add custom calculations. + +### Adding Reports +Create new view type in views XML or use Odoo's PDF report engine (separate implementation). + +--- + +## πŸ“„ License + +LGPL-3.0 (GNU Lesser General Public License v3) + +Full license text in LICENSE file. + +--- + +## πŸ™Œ Contributing + +Contributions welcome! To contribute: + +1. **Fork** the module repository +2. **Create feature branch:** `git checkout -b feature/my-feature` +3. **Make changes** with clear commit messages +4. **Test** your changes thoroughly +5. **Submit pull request** with description + +**For major changes:** +- Open an issue first to discuss +- Update README.md with new features +- Add unit tests if applicable +- Provide demo data for new features + +--- + +## πŸ“§ Support & Issues + +- **Report Bugs:** Create issue with clear description, steps to reproduce, attached logs +- **Request Features:** Create issue with `[FEATURE REQUEST]` tag +- **Questions:** Check README.md first, then open discussion + +--- + +## Change History + +### Version 1.0 (Current) +- βœ… Complete medicine management module +- βœ… Patient records with doctor assignment +- βœ… Sales order & invoice integration +- βœ… Role-based access control (Patient/Doctor/Manager) +- βœ… Real-time dashboard with statistics +- βœ… Inventory tracking & expiry management +- βœ… Odoo 18 compatibility + +--- + +**Developed with ❀️ for the Odoo community.** +**Muhammad Qasim Shabbir (AI Trainer/Developer) – 2026** + +**Module Version:** 1.0 +**Last Updated:** April 29, 2026 + diff --git a/pharma_control_center/__init__.py b/pharma_control_center/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/pharma_control_center/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pharma_control_center/__manifest__.py b/pharma_control_center/__manifest__.py new file mode 100644 index 00000000000..d9e15bf5f42 --- /dev/null +++ b/pharma_control_center/__manifest__.py @@ -0,0 +1,40 @@ +{ + 'name': 'Pharma Control Center', + 'version': '1.0', + 'summary': 'Pharmacy Dashboard, Medicines, Inventory & Operations', + 'sequence': 10, + 'description': """ +Pharma Control Center +===================== +Central system for pharmacy operations: +- Medicine catalog with batch, expiry, price, stock +- Dashboard showing total medicines and stock value +- Role‑based access (Patient, Doctor, Manager) + """, + 'category': 'Healthcare/Pharmacy', + 'author': "Muhammad Qasim Shabbir AI developer.", + 'website': 'https://www.odoo.com/app/invoicing', + 'depends': [ + 'base_setup', + 'product', + 'account', + 'sale', + ], + 'data': [ + 'security/groups.xml', + 'security/pharmacy_security.xml', + 'security/pharmacy_patient_security.xml', + 'security/sale_order_security.xml', + 'security/ir.model.access.csv', + 'data/demo_patients.xml', + 'data/demo_medicines.xml', + 'views/pharma_control_center_views.xml', # root menu defined here + 'views/pharmacy_medicine_views.xml', + 'views/pharmacy_patient_views.xml', # now includes the menuitem + 'views/pharmacy_category_views.xml', + 'views/pharmacy_order_views.xml', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} \ No newline at end of file diff --git a/pharma_control_center/data/demo_medicines.xml b/pharma_control_center/data/demo_medicines.xml new file mode 100644 index 00000000000..afc65902ebc --- /dev/null +++ b/pharma_control_center/data/demo_medicines.xml @@ -0,0 +1,52 @@ + + + + + + Painkiller + PAIN + Medicines for pain relief + + + + + Paracetamol 500mg + BATCH-001 + 2025-12-31 + 5.99 + 3.50 + 100 + white + + room_temp + 1 tablet every 4-6 hours + + + + + Amoxicillin 500mg + BATCH-002 + 2026-06-30 + 12.99 + 7.25 + 50 + blue + + cold + 1 capsule every 8 hours + + + + Morphine Sulphate + BATCH-003 + 2025-08-15 + 49.99 + 25.00 + 10 + green + + room_temp + As prescribed by doctor + + + \ No newline at end of file diff --git a/pharma_control_center/data/demo_patients.xml b/pharma_control_center/data/demo_patients.xml new file mode 100644 index 00000000000..afc65902ebc --- /dev/null +++ b/pharma_control_center/data/demo_patients.xml @@ -0,0 +1,52 @@ + + + + + + Painkiller + PAIN + Medicines for pain relief + + + + + Paracetamol 500mg + BATCH-001 + 2025-12-31 + 5.99 + 3.50 + 100 + white + + room_temp + 1 tablet every 4-6 hours + + + + + Amoxicillin 500mg + BATCH-002 + 2026-06-30 + 12.99 + 7.25 + 50 + blue + + cold + 1 capsule every 8 hours + + + + Morphine Sulphate + BATCH-003 + 2025-08-15 + 49.99 + 25.00 + 10 + green + + room_temp + As prescribed by doctor + + + \ No newline at end of file diff --git a/pharma_control_center/models/__init__.py b/pharma_control_center/models/__init__.py new file mode 100644 index 00000000000..7c968d03959 --- /dev/null +++ b/pharma_control_center/models/__init__.py @@ -0,0 +1,4 @@ +from . import pharma_control_center +from . import pharma_medicine +from . import pharmacy_category +from . import pharmacy_patient \ No newline at end of file diff --git a/pharma_control_center/models/pharma_control_center.py b/pharma_control_center/models/pharma_control_center.py new file mode 100644 index 00000000000..85c0d3cbaee --- /dev/null +++ b/pharma_control_center/models/pharma_control_center.py @@ -0,0 +1,96 @@ +from odoo import models, fields, api +from datetime import date, timedelta + +class PharmaControlCenter(models.Model): + _name = "pharma.control.center" + _description = "Pharma Control Center" + + name = fields.Char(string="Name", required=True, default="Pharmacy Dashboard") + description = fields.Text(string="Description") + last_updated = fields.Datetime(string="Last Updated", default=fields.Datetime.now) + + # Medicine statistics + total_medicines = fields.Integer( + string="Total Medicines", compute="_compute_statistics", store=False + ) + total_stock_quantity = fields.Integer( + string="Total Stock Quantity", compute="_compute_statistics", store=False + ) + stock_value = fields.Float( + string="Stock Value", compute="_compute_statistics", store=False + ) + out_of_stock_count = fields.Integer( + string="Out of Stock Medicines", compute="_compute_statistics", store=False + ) + low_stock_count = fields.Integer( + string="Low Stock (< 10 units)", compute="_compute_statistics", store=False + ) + expiring_soon_count = fields.Integer( + string="Expiring Within 30 Days", compute="_compute_statistics", store=False + ) + expired_count = fields.Integer( + string="Expired Medicines", compute="_compute_statistics", store=False + ) + + # Patient statistics (optional – you can keep or remove) + total_patients = fields.Integer( + string="Total Patients", compute="_compute_statistics", store=False + ) + my_patients = fields.Integer( + string="My Patients", compute="_compute_statistics", store=False + ) + + patient_ids = fields.One2many( + 'pharmacy.patient', + string="Patients", + compute='_compute_patient_ids', + readonly=True # because list is read‑only + ) + @api.depends() + def _compute_statistics(self): + Medicine = self.env['pharmacy.medicine'] + Patient = self.env['pharmacy.patient'] + today = date.today() + thirty_days_later = today + timedelta(days=30) + + for record in self: + # Medicine stats + all_meds = Medicine.search([]) + record.total_medicines = len(all_meds) + record.total_stock_quantity = sum(med.quantity for med in all_meds) + record.stock_value = sum(med.quantity * med.price for med in all_meds) + record.out_of_stock_count = sum(1 for med in all_meds if med.quantity == 0) + record.low_stock_count = sum(1 for med in all_meds if 0 < med.quantity < 10) + record.expiring_soon_count = sum( + 1 for med in all_meds + if med.expiry_date and med.expiry_date <= thirty_days_later and med.expiry_date > today + ) + record.expired_count = sum( + 1 for med in all_meds if med.expiry_date and med.expiry_date < today + ) + + # Patient stats (optional) + if Patient: + record.total_patients = Patient.search_count([]) + if self.env.user.has_group('pharma_control_center.group_pharmacy_doctor'): + record.my_patients = Patient.search_count([('doctor_id', '=', self.env.user.id)]) + else: + record.my_patients = 0 + else: + record.total_patients = 0 + record.my_patients = 0 + + @api.depends() + def _compute_patient_ids(self): + """Compute the patient list based on user's role.""" + Patient = self.env['pharmacy.patient'] + for record in self: + if self.env.user.has_group('pharma_control_center.group_pharmacy_doctor'): + # Doctor: see only patients assigned to them + record.patient_ids = Patient.search([('doctor_id', '=', self.env.user.id)]) + elif self.env.user.has_group('pharma_control_center.group_pharmacy_manager'): + # Manager: see all patients + record.patient_ids = Patient.search([]) + else: + # Others (Patients, etc.) see no patient list + record.patient_ids = False \ No newline at end of file diff --git a/pharma_control_center/models/pharma_medicine.py b/pharma_control_center/models/pharma_medicine.py new file mode 100644 index 00000000000..1a5ecff7bbd --- /dev/null +++ b/pharma_control_center/models/pharma_medicine.py @@ -0,0 +1,203 @@ +from odoo import models, fields, api +from datetime import date, timedelta + +class PharmacyMedicine(models.Model): + _name = "pharmacy.medicine" + _description = "Pharmacy Medicine" + _order = "name" + + # Basic info + name = fields.Char(string="Medicine Name", required=True) + description = fields.Text(string="Description") + manufacturer = fields.Char(string="Manufacturer", help="Company that produces this medicine") + barcode = fields.Char(string="Barcode", help="Product barcode (EAN/UPC)") + + # Category (Many2one for flexibility) + category_id = fields.Many2one('pharmacy.category', string="Medicine Category", required=True, ondelete='restrict') + sub_category = fields.Char(string="Sub‑Category") + + # Batch and expiry + batch_number = fields.Char(string="Batch Number", required=True) + expiry_date = fields.Date(string="Expiry Date", required=True) + + # Pricing and costs + price = fields.Float(string="Selling Price", required=True) + cost_price = fields.Float(string="Cost Price", help="Purchase cost per unit") + profit_margin = fields.Float(string="Profit Margin (%)", compute="_compute_profit_margin", store=True) + + # Stock + quantity = fields.Integer(string="Stock Quantity", required=True, default=0) + reorder_level = fields.Integer(string="Reorder Level", default=10, help="Notify when stock falls below this number") + need_reorder = fields.Boolean(string="Need Reorder", compute="_compute_need_reorder", store=True) + + # Statuses + in_stock = fields.Boolean(string="In Stock", compute="_compute_in_stock", store=True) + days_to_expiry = fields.Integer(string="Days to Expiry", compute="_compute_expiry_days", store=True) + expiry_status = fields.Selection([ + ('fresh', 'Fresh'), + ('expiring_soon', 'Expiring Soon'), + ('expired', 'Expired'), + ], string="Expiry Status", compute="_compute_expiry_status", store=True) + + # Storage + storage_location = fields.Selection([ + ('room_temp', 'Room Temperature'), + ('cold', 'Cold Storage'), + ('frozen', 'Frozen'), + ], string="Storage Condition", required=True) + + # Notes + side_effects = fields.Text(string="Side Effects") + dosage = fields.Char(string="Dosage", help="e.g., 1 tablet twice daily") + + # License category (access control) + license_category = fields.Selection([ + ('green', '🟒 Pharmacy (Category A) - Full license'), + ('blue', 'πŸ”΅ Medical Store (Category B) - Limited license'), + ('white', 'βšͺ Drug Store (Category C) - Basic OTC'), + ], string="Pharmacy License Category", required=True, default='white', + help="Determines which user groups can access this medicine") + + # Order/invoice integration + product_id = fields.Many2one('product.product', string="Linked Product", readonly=True) + is_ordered = fields.Boolean(string="Ordered", default=False, copy=False) + last_sale_order_id = fields.Many2one('sale.order', string="Last Sale Order", readonly=True) + last_invoice_id = fields.Many2one('account.move', string="Last Invoice", readonly=True) + + # ------------------------------------------------------------------------- + # Order & Invoice Actions + # ------------------------------------------------------------------------- + def action_order_medicine(self): + """Create a sale order and invoice, mark medicine as ordered.""" + self.ensure_one() + # 1. Ensure a linked product exists + product = self.product_id + if not product: + product = self._create_product() + self.product_id = product + + # 2. Create sale order + sale_order = self.env['sale.order'].create({ + 'partner_id': self.env.user.partner_id.id, + 'order_line': [(0, 0, { + 'product_id': product.id, + 'product_uom_qty': 1, + 'price_unit': self.price, + })], + }) + # 3. Confirm order + sale_order.action_confirm() + # 4. Create and post invoice + invoice = sale_order._create_invoices() + invoice.action_post() + + # 5. Update medicine record + self.is_ordered = True + self.last_sale_order_id = sale_order.id + self.last_invoice_id = invoice.id + + # 6. Return action to open the invoice + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'res_id': invoice.id, + 'view_mode': 'form', + 'target': 'new', + } + + def action_view_invoice(self): + """Open the last invoice created for this medicine.""" + if self.last_invoice_id: + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'res_id': self.last_invoice_id.id, + 'view_mode': 'form', + 'target': 'new', + } + else: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': 'No invoice found for this medicine.', + 'type': 'warning', + } + } + + def action_view_order(self): + """Open the last sale order created for this medicine.""" + if self.last_sale_order_id: + return { + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'res_id': self.last_sale_order_id.id, + 'view_mode': 'form', + 'target': 'new', + } + else: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': 'No sale order found for this medicine.', + 'type': 'warning', + } + } + + def _create_product(self): + """Create a consumable product (no inventory tracking) from the medicine.""" + product = self.env['product.product'].create({ + 'name': self.name, + 'list_price': self.price, + 'standard_price': self.cost_price or 0.0, + 'type': 'consu', # consumable, does not require stock module + 'sale_ok': True, + 'purchase_ok': False, + }) + self.product_id = product + return product + + # ------------------------------------------------------------------------- + # Compute Methods + # ------------------------------------------------------------------------- + @api.depends('price', 'cost_price') + def _compute_profit_margin(self): + for med in self: + if med.cost_price and med.cost_price > 0: + med.profit_margin = ((med.price - med.cost_price) / med.cost_price) * 100 + else: + med.profit_margin = 0.0 + + @api.depends('quantity', 'reorder_level') + def _compute_need_reorder(self): + for med in self: + med.need_reorder = med.quantity <= med.reorder_level + + @api.depends('quantity') + def _compute_in_stock(self): + for med in self: + med.in_stock = med.quantity > 0 + + @api.depends('expiry_date') + def _compute_expiry_days(self): + today = date.today() + for med in self: + if med.expiry_date: + delta = (med.expiry_date - today).days + med.days_to_expiry = delta + else: + med.days_to_expiry = 0 + + @api.depends('expiry_date') + def _compute_expiry_status(self): + today = date.today() + for med in self: + if not med.expiry_date: + med.expiry_status = 'fresh' + elif med.expiry_date < today: + med.expiry_status = 'expired' + elif med.expiry_date <= today + timedelta(days=30): + med.expiry_status = 'expiring_soon' + else: + med.expiry_status = 'fresh' \ No newline at end of file diff --git a/pharma_control_center/models/pharmacy_category.py b/pharma_control_center/models/pharmacy_category.py new file mode 100644 index 00000000000..5bd419a3c81 --- /dev/null +++ b/pharma_control_center/models/pharmacy_category.py @@ -0,0 +1,12 @@ +from odoo import models, fields + +class PharmacyCategory(models.Model): + _name = "pharmacy.category" + _description = "Medicine Category" + _order = "name" + + name = fields.Char(string="Category Name", required=True, translate=True) + code = fields.Char(string="Category Code", help="Short code, e.g., ANTIBIOTIC") + description = fields.Text(string="Description") + parent_id = fields.Many2one('pharmacy.category', string="Parent Category", ondelete='restrict') + child_ids = fields.One2many('pharmacy.category', 'parent_id', string="Child Categories") \ No newline at end of file diff --git a/pharma_control_center/models/pharmacy_patient.py b/pharma_control_center/models/pharmacy_patient.py new file mode 100644 index 00000000000..a7df6f7036a --- /dev/null +++ b/pharma_control_center/models/pharmacy_patient.py @@ -0,0 +1,32 @@ +from odoo import models, fields, api + +class PharmacyPatient(models.Model): + _name = "pharmacy.patient" + _description = "Patient" + _order = "name" + + doctor_id = fields.Many2one('res.users', string="Assigned Doctor", required=True) + name = fields.Char(string="Patient Name", required=True) + age = fields.Integer(string="Age") + gender = fields.Selection([ + ('male', 'Male'), + ('female', 'Female'), + ('other', 'Other'), + ], string="Gender") + phone = fields.Char(string="Phone") + email = fields.Char(string="Email") + address = fields.Text(string="Address") + blood_group = fields.Selection([ + ('A+', 'A+'), ('A-', 'A-'), + ('B+', 'B+'), ('B-', 'B-'), + ('O+', 'O+'), ('O-', 'O-'), + ('AB+', 'AB+'), ('AB-', 'AB-'), + ], string="Blood Group") + medical_history = fields.Text(string="Medical History") + allergies = fields.Text(string="Allergies") + # Removed prescription_ids – will be added later with a proper prescription model + active = fields.Boolean(string="Active", default=True) + + _sql_constraints = [ + ('unique_email', 'UNIQUE(email)', 'Email must be unique!'), + ] \ No newline at end of file diff --git a/pharma_control_center/security/groups.xml b/pharma_control_center/security/groups.xml new file mode 100644 index 00000000000..295c73c6a28 --- /dev/null +++ b/pharma_control_center/security/groups.xml @@ -0,0 +1,29 @@ + + + + + + Pharma Control Center + 10 + + + + + Patient + + + + + + Doctor + + + + + + Manager + + + + + \ No newline at end of file diff --git a/pharma_control_center/security/ir.model.access.csv b/pharma_control_center/security/ir.model.access.csv new file mode 100644 index 00000000000..2925cacd6d8 --- /dev/null +++ b/pharma_control_center/security/ir.model.access.csv @@ -0,0 +1,17 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_pharmacy_medicine_admin,pharmacy.medicine.admin,model_pharmacy_medicine,base.group_user,1,1,1,1 +access_pharmacy_medicine_patient,pharmacy.medicine.patient,model_pharmacy_medicine,pharma_control_center.group_pharmacy_patient,1,0,0,0 +access_pharmacy_medicine_doctor,pharmacy.medicine.doctor,model_pharmacy_medicine,pharma_control_center.group_pharmacy_doctor,1,0,0,0 +access_pharmacy_medicine_manager,pharmacy.medicine.manager,model_pharmacy_medicine,pharma_control_center.group_pharmacy_manager,1,1,1,1 +access_pharma_control_center_admin,pharma.control.center.admin,model_pharma_control_center,base.group_user,1,1,1,1 +access_pharma_control_center_patient,pharma.control.center.patient,model_pharma_control_center,pharma_control_center.group_pharmacy_patient,1,0,0,0 +access_pharma_control_center_doctor,pharma.control.center.doctor,model_pharma_control_center,pharma_control_center.group_pharmacy_doctor,1,0,0,0 +access_pharma_control_center_manager,pharma.control.center.manager,model_pharma_control_center,pharma_control_center.group_pharmacy_manager,1,1,1,1 +access_pharmacy_category_admin,pharmacy.category.admin,model_pharmacy_category,base.group_user,1,1,1,1 +access_pharmacy_category_patient,pharmacy.category.patient,model_pharmacy_category,pharma_control_center.group_pharmacy_patient,1,0,0,0 +access_pharmacy_category_doctor,pharmacy.category.doctor,model_pharmacy_category,pharma_control_center.group_pharmacy_doctor,1,0,0,0 +access_pharmacy_category_manager,pharmacy.category.manager,model_pharmacy_category,pharma_control_center.group_pharmacy_manager,1,1,1,1 +access_pharmacy_patient_admin,pharmacy.patient.admin,model_pharmacy_patient,base.group_user,1,1,1,1 +access_pharmacy_patient_patient,pharmacy.patient.patient,model_pharmacy_patient,pharma_control_center.group_pharmacy_patient,1,0,0,0 +access_pharmacy_patient_doctor,pharmacy.patient.doctor,model_pharmacy_patient,pharma_control_center.group_pharmacy_doctor,1,1,1,1 +access_pharmacy_patient_manager,pharmacy.patient.manager,model_pharmacy_patient,pharma_control_center.group_pharmacy_manager,1,1,1,1 \ No newline at end of file diff --git a/pharma_control_center/security/pharmacy_patient_security.xml b/pharma_control_center/security/pharmacy_patient_security.xml new file mode 100644 index 00000000000..00cd26424da --- /dev/null +++ b/pharma_control_center/security/pharmacy_patient_security.xml @@ -0,0 +1,25 @@ + + + + Doctor: own patients only + + + [('doctor_id', '=', user.id)] + + + + + + + + Manager: all patients + + + [(1, '=', 1)] + + + + + + \ No newline at end of file diff --git a/pharma_control_center/security/pharmacy_security.xml b/pharma_control_center/security/pharmacy_security.xml new file mode 100644 index 00000000000..9a326660469 --- /dev/null +++ b/pharma_control_center/security/pharmacy_security.xml @@ -0,0 +1,30 @@ + + + + + + Patient: only White (OTC) medicines + + + [('license_category', '=', 'white')] + + + + + + + + Doctor: Blue + White medicines + + + [('license_category', 'in', ['blue', 'white'])] + + + + + + + + + + \ No newline at end of file diff --git a/pharma_control_center/security/sale_order_security.xml b/pharma_control_center/security/sale_order_security.xml new file mode 100644 index 00000000000..779aa12c8d7 --- /dev/null +++ b/pharma_control_center/security/sale_order_security.xml @@ -0,0 +1,25 @@ + + + + Sale Order: Global read-only + + + + + + + [(1, '=', 1)] + + + + + Sale Order: Manager full access + + + + + + + [(1, '=', 1)] + + \ No newline at end of file diff --git a/estate/tests/__init__.py b/pharma_control_center/test/test_pharma_control_center.py similarity index 100% rename from estate/tests/__init__.py rename to pharma_control_center/test/test_pharma_control_center.py diff --git a/pharma_control_center/views/pharma_control_center_views.xml b/pharma_control_center/views/pharma_control_center_views.xml new file mode 100644 index 00000000000..20cd8a53835 --- /dev/null +++ b/pharma_control_center/views/pharma_control_center_views.xml @@ -0,0 +1,58 @@ + + + + + pharma.control.center.form + pharma.control.center + +
+ +
+ +
+
+

πŸ₯ Pharma Control Center

+

πŸ“Š Your pharmacy at a glance

+
+ + + + + + + + + + + + + + +
+
+
+
+ + + + πŸ’Š Pharma Dashboard + pharma.control.center + form + + main + + + + + + +
\ No newline at end of file diff --git a/pharma_control_center/views/pharmacy_category_views.xml b/pharma_control_center/views/pharmacy_category_views.xml new file mode 100644 index 00000000000..ee1e3fc1209 --- /dev/null +++ b/pharma_control_center/views/pharmacy_category_views.xml @@ -0,0 +1,53 @@ + + + + + pharmacy.category.list + pharmacy.category + + + + + + + + + + + pharmacy.category.form + pharmacy.category + +
+ + + + + + + + +
+
+
+ + + πŸ“‚ Categories + pharmacy.category + list,form + + + + + + + +
\ No newline at end of file diff --git a/pharma_control_center/views/pharmacy_medicine_views.xml b/pharma_control_center/views/pharmacy_medicine_views.xml new file mode 100644 index 00000000000..b09a1a3a01e --- /dev/null +++ b/pharma_control_center/views/pharmacy_medicine_views.xml @@ -0,0 +1,190 @@ + + + + + + pharmacy.medicine.list + pharmacy.medicine + + + + + + + + + + + + + + + + + + pharmacy.medicine.form + pharmacy.medicine + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + pharmacy.medicine.kanban + pharmacy.medicine + + + + + + + + + + + + +
+
+ + 🟒 Full License + πŸ”΅ Limited License + βšͺ Basic OTC + ⚠️ Expired + ⏰ Expiring soon + βœ… Fresh +
+
+
πŸ“‚
+
πŸ”’ Batch:
+
πŸ“… Expiry:
+
πŸ“¦ Stock:
+
πŸ’° Price:
+
+
+ + βœ… In Stock + + + ❌ Out of Stock + + + πŸ”„ Need reorder + + +
+ + + + + βœ… Ordered + +
+
+
+
+
+
+
+
+ + + + pharmacy.medicine.search + pharmacy.medicine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + πŸ’Š Medicines + pharmacy.medicine + kanban,list,form + + + + + + +
\ No newline at end of file diff --git a/pharma_control_center/views/pharmacy_order_views.xml b/pharma_control_center/views/pharmacy_order_views.xml new file mode 100644 index 00000000000..d471b64c459 --- /dev/null +++ b/pharma_control_center/views/pharmacy_order_views.xml @@ -0,0 +1,32 @@ + + + + + pharmacy.sale.order.list + sale.order + + + + + + + + + + + + + + 🧾 Orders + sale.order + list,form + + + + + \ No newline at end of file diff --git a/pharma_control_center/views/pharmacy_patient_views.xml b/pharma_control_center/views/pharmacy_patient_views.xml new file mode 100644 index 00000000000..b432b464848 --- /dev/null +++ b/pharma_control_center/views/pharmacy_patient_views.xml @@ -0,0 +1,64 @@ + + + + + pharmacy.patient.list + pharmacy.patient + + + + + + + + + + + + + + pharmacy.patient.form + pharmacy.patient + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + + Patients + pharmacy.patient + list,form + +

+ No patients yet. Create your first patient. +

+
+
+ + +
\ No newline at end of file From 25977b5824225ab09b537da153cfe13569836f22 Mon Sep 17 00:00:00 2001 From: Muhammad Qaism Shabbir Date: Thu, 30 Apr 2026 19:54:38 +0500 Subject: [PATCH 5/5] Added cart feature. --- pharma_control_center/models/__init__.py | 4 +- .../models/pharma_control_center.py | 70 +++++- .../models/pharma_medicine.py | 230 ++++++++++-------- pharma_control_center/security/groups.xml | 10 +- .../views/pharma_control_center_views.xml | 36 ++- .../views/pharmacy_medicine_views.xml | 117 +++++---- 6 files changed, 285 insertions(+), 182 deletions(-) diff --git a/pharma_control_center/models/__init__.py b/pharma_control_center/models/__init__.py index 7c968d03959..ca99335b447 100644 --- a/pharma_control_center/models/__init__.py +++ b/pharma_control_center/models/__init__.py @@ -1,4 +1,4 @@ -from . import pharma_control_center from . import pharma_medicine from . import pharmacy_category -from . import pharmacy_patient \ No newline at end of file +from . import pharmacy_patient +from . import pharma_control_center diff --git a/pharma_control_center/models/pharma_control_center.py b/pharma_control_center/models/pharma_control_center.py index 85c0d3cbaee..e7934f10f31 100644 --- a/pharma_control_center/models/pharma_control_center.py +++ b/pharma_control_center/models/pharma_control_center.py @@ -1,4 +1,4 @@ -from odoo import models, fields, api +from odoo import models, fields, api, _ from datetime import date, timedelta class PharmaControlCenter(models.Model): @@ -32,20 +32,32 @@ class PharmaControlCenter(models.Model): string="Expired Medicines", compute="_compute_statistics", store=False ) - # Patient statistics (optional – you can keep or remove) + # Patient statistics total_patients = fields.Integer( string="Total Patients", compute="_compute_statistics", store=False ) my_patients = fields.Integer( string="My Patients", compute="_compute_statistics", store=False ) - patient_ids = fields.One2many( 'pharmacy.patient', string="Patients", compute='_compute_patient_ids', - readonly=True # because list is read‑only + readonly=True + ) + + # Today's orders summary + today_order_total_qty = fields.Float( + string="Total Quantity Today", + compute="_compute_today_orders_summary", + store=False ) + today_order_total_amount = fields.Float( + string="Total Sales Today", + compute="_compute_today_orders_summary", + store=False + ) + @api.depends() def _compute_statistics(self): Medicine = self.env['pharmacy.medicine'] @@ -69,7 +81,7 @@ def _compute_statistics(self): 1 for med in all_meds if med.expiry_date and med.expiry_date < today ) - # Patient stats (optional) + # Patient stats if Patient: record.total_patients = Patient.search_count([]) if self.env.user.has_group('pharma_control_center.group_pharmacy_doctor'): @@ -82,15 +94,53 @@ def _compute_statistics(self): @api.depends() def _compute_patient_ids(self): - """Compute the patient list based on user's role.""" Patient = self.env['pharmacy.patient'] for record in self: if self.env.user.has_group('pharma_control_center.group_pharmacy_doctor'): - # Doctor: see only patients assigned to them record.patient_ids = Patient.search([('doctor_id', '=', self.env.user.id)]) elif self.env.user.has_group('pharma_control_center.group_pharmacy_manager'): - # Manager: see all patients record.patient_ids = Patient.search([]) else: - # Others (Patients, etc.) see no patient list - record.patient_ids = False \ No newline at end of file + record.patient_ids = False + + @api.depends() + def _compute_today_orders_summary(self): + """Compute total quantity and amount for today's orders. + Non‑managers see only their own orders.""" + today = fields.Date.today() + tomorrow = today + timedelta(days=1) + domain = [ + ('order_id.date_order', '>=', today), + ('order_id.date_order', '<', tomorrow), + ('order_id.state', 'not in', ['cancel']) + ] + if not self.env.user.has_group('pharma_control_center.group_pharmacy_manager'): + domain.append(('create_uid', '=', self.env.user.id)) + order_lines = self.env['sale.order.line'].search(domain) + self.today_order_total_qty = sum(order_lines.mapped('product_uom_qty')) + self.today_order_total_amount = sum(order_lines.mapped('price_subtotal')) + + def action_view_today_orders(self): + """Open a list of today's order lines.""" + today = fields.Date.today() + tomorrow = today + timedelta(days=1) + domain = [ + ('order_id.date_order', '>=', today), + ('order_id.date_order', '<', tomorrow), + ('order_id.state', 'not in', ['cancel']) + ] + if not self.env.user.has_group('pharma_control_center.group_pharmacy_manager'): + domain.append(('create_uid', '=', self.env.user.id)) + + # Use the custom view + view = self.env.ref('pharma_control_center.view_sale_order_line_today_list', raise_if_not_found=False) + view_id = view.id if view else False + return { + 'type': 'ir.actions.act_window', + 'name': "Today's Orders", + 'res_model': 'sale.order.line', + 'view_mode': 'list,form', + 'target': 'current', + 'domain': domain, + 'views': [(view_id, 'list')] if view_id else [(False, 'list')], + } \ No newline at end of file diff --git a/pharma_control_center/models/pharma_medicine.py b/pharma_control_center/models/pharma_medicine.py index 1a5ecff7bbd..a17a0f13fbd 100644 --- a/pharma_control_center/models/pharma_medicine.py +++ b/pharma_control_center/models/pharma_medicine.py @@ -1,36 +1,32 @@ -from odoo import models, fields, api -from datetime import date, timedelta +import json +from odoo import models, fields, api, _ +from datetime import date, timedelta, datetime +from odoo.exceptions import UserError + +class ResUsers(models.Model): + _inherit = 'res.users' + cart_data = fields.Text(string="Cart Data", default="{}", help="JSON cart for pharmacy module") class PharmacyMedicine(models.Model): _name = "pharmacy.medicine" _description = "Pharmacy Medicine" _order = "name" - # Basic info name = fields.Char(string="Medicine Name", required=True) description = fields.Text(string="Description") - manufacturer = fields.Char(string="Manufacturer", help="Company that produces this medicine") - barcode = fields.Char(string="Barcode", help="Product barcode (EAN/UPC)") - - # Category (Many2one for flexibility) + manufacturer = fields.Char(string="Manufacturer") + barcode = fields.Char(string="Barcode") category_id = fields.Many2one('pharmacy.category', string="Medicine Category", required=True, ondelete='restrict') sub_category = fields.Char(string="Sub‑Category") - - # Batch and expiry batch_number = fields.Char(string="Batch Number", required=True) expiry_date = fields.Date(string="Expiry Date", required=True) - - # Pricing and costs price = fields.Float(string="Selling Price", required=True) - cost_price = fields.Float(string="Cost Price", help="Purchase cost per unit") + cost_price = fields.Float(string="Cost Price") profit_margin = fields.Float(string="Profit Margin (%)", compute="_compute_profit_margin", store=True) - - # Stock quantity = fields.Integer(string="Stock Quantity", required=True, default=0) - reorder_level = fields.Integer(string="Reorder Level", default=10, help="Notify when stock falls below this number") + reorder_level = fields.Integer(string="Reorder Level", default=10) need_reorder = fields.Boolean(string="Need Reorder", compute="_compute_need_reorder", store=True) - - # Statuses + order_qty = fields.Integer(string="Order Quantity", default=1) in_stock = fields.Boolean(string="In Stock", compute="_compute_in_stock", store=True) days_to_expiry = fields.Integer(string="Days to Expiry", compute="_compute_expiry_days", store=True) expiry_status = fields.Selection([ @@ -38,65 +34,123 @@ class PharmacyMedicine(models.Model): ('expiring_soon', 'Expiring Soon'), ('expired', 'Expired'), ], string="Expiry Status", compute="_compute_expiry_status", store=True) - - # Storage storage_location = fields.Selection([ ('room_temp', 'Room Temperature'), ('cold', 'Cold Storage'), ('frozen', 'Frozen'), ], string="Storage Condition", required=True) - - # Notes side_effects = fields.Text(string="Side Effects") - dosage = fields.Char(string="Dosage", help="e.g., 1 tablet twice daily") - - # License category (access control) + dosage = fields.Char(string="Dosage") license_category = fields.Selection([ ('green', '🟒 Pharmacy (Category A) - Full license'), ('blue', 'πŸ”΅ Medical Store (Category B) - Limited license'), ('white', 'βšͺ Drug Store (Category C) - Basic OTC'), - ], string="Pharmacy License Category", required=True, default='white', - help="Determines which user groups can access this medicine") - - # Order/invoice integration + ], string="Pharmacy License Category", required=True, default='white') product_id = fields.Many2one('product.product', string="Linked Product", readonly=True) - is_ordered = fields.Boolean(string="Ordered", default=False, copy=False) last_sale_order_id = fields.Many2one('sale.order', string="Last Sale Order", readonly=True) last_invoice_id = fields.Many2one('account.move', string="Last Invoice", readonly=True) - - # ------------------------------------------------------------------------- - # Order & Invoice Actions - # ------------------------------------------------------------------------- - def action_order_medicine(self): - """Create a sale order and invoice, mark medicine as ordered.""" + today_orders_qty = fields.Float(string="Today Orders", compute="_compute_today_orders", store=False) + + # Cart fields (computed from user cart data) + cart_quantity = fields.Integer(string="Cart Quantity", compute="_compute_cart_fields") + cart_subtotal = fields.Float(string="Cart Subtotal", compute="_compute_cart_fields") + cart_item_ids = fields.Many2many('pharmacy.medicine', compute="_compute_cart_item_ids") + cart_total = fields.Float(string="Cart Total", compute="_compute_cart_total") + + # ------------------------------------------------------------ + # Cart helper methods + # ------------------------------------------------------------ + def _get_cart_dict(self): + """Return dict {medicine_id: quantity} from user's cart_data""" + data = self.env.user.cart_data + if not data: + return {} + return json.loads(data) + + def _set_cart_dict(self, cart_dict): + self.env.user.sudo().write({'cart_data': json.dumps(cart_dict)}) + + def _compute_cart_fields(self): + cart = self._get_cart_dict() + for med in self: + qty = cart.get(str(med.id), 0) + med.cart_quantity = qty + med.cart_subtotal = qty * med.price + + def _compute_cart_item_ids(self): + cart = self._get_cart_dict() + self.cart_item_ids = self.browse([int(k) for k in cart.keys()]) + + def _compute_cart_total(self): + cart = self._get_cart_dict() + total = 0.0 + for med_id, qty in cart.items(): + med = self.browse(int(med_id)) + total += med.price * qty + self.cart_total = total + + # ------------------------------------------------------------ + # Cart actions + # ------------------------------------------------------------ + def add_to_cart(self): self.ensure_one() - # 1. Ensure a linked product exists - product = self.product_id - if not product: - product = self._create_product() - self.product_id = product + if self.order_qty <= 0: + raise UserError(_("Quantity must be greater than zero")) + if self.order_qty > self.quantity: + raise UserError(_("Not enough stock. Available: %s") % self.quantity) + + cart = self._get_cart_dict() + key = str(self.id) + cart[key] = cart.get(key, 0) + self.order_qty + self._set_cart_dict(cart) + self.order_qty = 1 + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Added to Cart'), + 'message': _("Added %s to your cart.") % self.name, + 'type': 'success', + 'sticky': False, + } + } - # 2. Create sale order + def remove_from_cart(self): + self.ensure_one() + cart = self._get_cart_dict() + key = str(self.id) + if key in cart: + del cart[key] + self._set_cart_dict(cart) + return {'type': 'ir.actions.client', 'tag': 'reload'} + + def checkout(self): + cart = self._get_cart_dict() + if not cart: + raise UserError(_("Your cart is empty.")) + order_lines = [] + for med_id, qty in cart.items(): + med = self.browse(int(med_id)) + if not med.product_id: + med._create_product() + order_lines.append((0, 0, { + 'product_id': med.product_id.id, + 'product_uom_qty': qty, + 'price_unit': med.price, + })) sale_order = self.env['sale.order'].create({ 'partner_id': self.env.user.partner_id.id, - 'order_line': [(0, 0, { - 'product_id': product.id, - 'product_uom_qty': 1, - 'price_unit': self.price, - })], + 'order_line': order_lines, }) - # 3. Confirm order sale_order.action_confirm() - # 4. Create and post invoice invoice = sale_order._create_invoices() invoice.action_post() - # 5. Update medicine record - self.is_ordered = True - self.last_sale_order_id = sale_order.id - self.last_invoice_id = invoice.id + for med_id, qty in cart.items(): + med = self.browse(int(med_id)) + med.quantity -= qty - # 6. Return action to open the invoice + self._set_cart_dict({}) return { 'type': 'ir.actions.act_window', 'res_model': 'account.move', @@ -105,62 +159,24 @@ def action_order_medicine(self): 'target': 'new', } - def action_view_invoice(self): - """Open the last invoice created for this medicine.""" - if self.last_invoice_id: - return { - 'type': 'ir.actions.act_window', - 'res_model': 'account.move', - 'res_id': self.last_invoice_id.id, - 'view_mode': 'form', - 'target': 'new', - } - else: - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'message': 'No invoice found for this medicine.', - 'type': 'warning', - } - } - - def action_view_order(self): - """Open the last sale order created for this medicine.""" - if self.last_sale_order_id: - return { - 'type': 'ir.actions.act_window', - 'res_model': 'sale.order', - 'res_id': self.last_sale_order_id.id, - 'view_mode': 'form', - 'target': 'new', - } - else: - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'message': 'No sale order found for this medicine.', - 'type': 'warning', - } - } - + # ------------------------------------------------------------ + # Helper + # ------------------------------------------------------------ def _create_product(self): - """Create a consumable product (no inventory tracking) from the medicine.""" product = self.env['product.product'].create({ 'name': self.name, 'list_price': self.price, 'standard_price': self.cost_price or 0.0, - 'type': 'consu', # consumable, does not require stock module + 'type': 'consu', 'sale_ok': True, 'purchase_ok': False, }) self.product_id = product return product - # ------------------------------------------------------------------------- - # Compute Methods - # ------------------------------------------------------------------------- + # ------------------------------------------------------------ + # Compute methods (unchanged) + # ------------------------------------------------------------ @api.depends('price', 'cost_price') def _compute_profit_margin(self): for med in self: @@ -200,4 +216,20 @@ def _compute_expiry_status(self): elif med.expiry_date <= today + timedelta(days=30): med.expiry_status = 'expiring_soon' else: - med.expiry_status = 'fresh' \ No newline at end of file + med.expiry_status = 'fresh' + + @api.depends('product_id') + def _compute_today_orders(self): + today_start = datetime.now().replace(hour=0, minute=0, second=0) + today_end = today_start + timedelta(days=1) + for med in self: + total = 0.0 + if med.product_id: + lines = self.env['sale.order.line'].search([ + ('product_id', '=', med.product_id.id), + ('order_id.date_order', '>=', today_start), + ('order_id.date_order', '<', today_end), + ('order_id.state', 'not in', ['cancel']) + ]) + total = sum(lines.mapped('product_uom_qty')) + med.today_orders_qty = total \ No newline at end of file diff --git a/pharma_control_center/security/groups.xml b/pharma_control_center/security/groups.xml index 295c73c6a28..00a9392318f 100644 --- a/pharma_control_center/security/groups.xml +++ b/pharma_control_center/security/groups.xml @@ -7,23 +7,25 @@ 10
- + Patient + - + Doctor + - + Manager - + \ No newline at end of file diff --git a/pharma_control_center/views/pharma_control_center_views.xml b/pharma_control_center/views/pharma_control_center_views.xml index 20cd8a53835..854d3f68ecc 100644 --- a/pharma_control_center/views/pharma_control_center_views.xml +++ b/pharma_control_center/views/pharma_control_center_views.xml @@ -7,15 +7,13 @@
-
- -
+

πŸ₯ Pharma Control Center

πŸ“Š Your pharmacy at a glance

- + @@ -26,6 +24,27 @@ + + + + + + + + + - - - βœ… Ordered - +
@@ -142,7 +145,6 @@
- pharmacy.medicine.search pharmacy.medicine @@ -157,7 +159,6 @@ - @@ -172,7 +173,6 @@
- πŸ’Š Medicines pharmacy.medicine @@ -180,7 +180,6 @@ -