diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..899bcc97f0f --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,2 @@ +from . import models + diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..54558280f34 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,46 @@ +# -*- 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', + + ] +} + + 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..723e7da7c85 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,183 @@ +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) + diff --git a/estate/models/property_offer.py b/estate/models/property_offer.py new file mode 100644 index 00000000000..4710f2d9398 --- /dev/null +++ b/estate/models/property_offer.py @@ -0,0 +1,91 @@ +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) + diff --git a/estate/models/property_tag.py b/estate/models/property_tag.py new file mode 100644 index 00000000000..9a1f44d33a3 --- /dev/null +++ b/estate/models/property_tag.py @@ -0,0 +1,13 @@ +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() + diff --git a/estate/models/property_type.py b/estate/models/property_type.py new file mode 100644 index 00000000000..17d16b0838e --- /dev/null +++ b/estate/models/property_type.py @@ -0,0 +1,35 @@ +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) + 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..c5da90eadb2 --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,57 @@ + + + + + + + + Real Estate + Real Estate Management + 10 + + + + + + + Real Estate + + + + + + + + Agent + + + + + + + + + Manager + + + + + + 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..8b137891791 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ + 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': ...}, + ]) + + 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..899bcc97f0f --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1,2 @@ +from . import models + diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..91537d814e6 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,27 @@ +# -*- 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, + +} + diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..c5006b18cf8 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,2 @@ +from . import estate_property + diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..e19596ee5ba --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,31 @@ +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 + diff --git a/pharma_control_center/README.md b/pharma_control_center/README.md new file mode 100644 index 00000000000..a15967d4525 --- /dev/null +++ b/pharma_control_center/README.md @@ -0,0 +1,757 @@ +# Pharma Control Center (Odoo 18+) + +Pharma Control Center is an Odoo backend module for basic pharmacy operations: a medicine catalog (batch/expiry/custom stock), a per-user cart checkout flow (Sales Order + Invoice), an operational dashboard, and simple patient records with doctor assignment. + +This module is implemented as standard Odoo models + backend views (no website/portal pages). + +## What’s Included (As Implemented) + +- **Medicines** (`pharmacy.medicine`) + - Batch + expiry tracking (`expiry_date`, `days_to_expiry`, `expiry_status`) + - Custom stock quantity + reorder indicator (`quantity`, `reorder_level`, `need_reorder`) + - License category field (`green` / `blue` / `white`) used in medicine visibility record rules + - Kanban / list / form views + search filters + +- **Cart & checkout** (`pharmacy.cart`, `pharmacy.cart.line`) + - Add medicines to your cart (including quick add by barcode) + - Shows interaction warnings for medicines currently in the cart + - Checkout creates and confirms a `sale.order`, creates an invoice (`account.move`), posts it, reduces `pharmacy.medicine.quantity`, and clears the cart + +- **Dashboard** (`pharma.control.center`) + - Profile panel (avatar stored on the dashboard; name/email/phone synced to the linked `res.users` / `res.partner`) + - Live KPIs (medicine counts, stock value, expiring soon, etc.) + - β€œToday’s orders” summary + drill-down list + +- **Patients** (`pharmacy.patient`) + - Doctors can manage patients assigned to them (record rule on `doctor_id`) + - Managers can see all patients + +- **Drug interactions** (`pharmacy.interaction`) + - Managers define medicine pairs with severity + warning text + - Cart shows warnings; checkout blocks when any interaction is **Severe** + +- **Manager analytics** + - Graph/pivot views on `sale.order` under the **Analytics** menu (manager-only) + +## Dependencies + +Declared in [`__manifest__.py`](__manifest__.py): `base_setup`, `product`, `sale`, `account`. + +## Installation + +1. Put `pharma_control_center/` in one of your Odoo `addons_path` directories. +2. Restart Odoo, then go to **Apps** and click **Update Apps List**. +3. Search for **Pharma Control Center** and install it. + +## Configuration (Roles) + +Assign users to one of these groups: +- **Pharma Control Center / Patient** +- **Pharma Control Center / Doctor** +- **Pharma Control Center / Manager** + +## Demo Data + +When demo data is enabled, the module loads: +- Sample medicines in [`data/demo_medicines.xml`](data/demo_medicines.xml) +- Sample patients in [`data/demo_patients.xml`](data/demo_patients.xml) + +## Notes / Limitations + +- Inventory is tracked on `pharmacy.medicine.quantity` (this module does **not** use Odoo’s Inventory/Stock app and does not generate stock moves). +- Checkout posts the invoice automatically; this requires your Odoo Accounting configuration to be set up (journals, accounts, etc.). + +## Documentation + +- [ARCHITECTURE.md](docs/ARCHITECTURE.md) +- [TECHNICAL_DETAILS.md](docs/TECHNICAL_DETAILS.md) +- [SECURITY.md](docs/SECURITY.md) +- [USER_GUIDE.md](docs/USER_GUIDE.md) +- [DEMO_DATA.md](docs/DEMO_DATA.md) +- [CHANGELOG.md](docs/CHANGELOG.md) +- [TESTING.md](TESTING.md) + +## License + +LGPL-3 (see `license` in the manifest). +# πŸ’Š Pharma Control Center (Odoo 18+ Module) + +## Overview + +**Pharma Control Center** is a comprehensive, production-ready Odoo 18+ module designed for modern pharmacy management systems. It provides a complete ecosystem for medicine cataloguing, batch and expiry tracking, intelligent inventory management, patient records with doctor assignments, a sophisticated cart-based ordering system, and real-time operational dashboards. The module strictly adheres to Odoo 18 conventions (using `` instead of deprecated ``), seamlessly integrates with Odoo's sales, accounting, and product modules, and implements multi-tier role-based access control (Patient / Doctor / Manager) with fine-grained security rules at both the model and record levels. + +**Key Highlights:** +- πŸ₯ **Professional-grade medicine management** with expiry tracking and license categories +- πŸ‘₯ **Patient-doctor relationship system** with secure record visibility +- πŸ›’ **Cart-based ordering workflow** with one-click checkout and automatic invoicing +- πŸ“Š **Real-time dashboard** with user profile, statistics, and operational alerts +- πŸ” **Enterprise-grade security** with role-based access control and record-level rules +- ✨ **Odoo 18 compliant** with modern views, computed fields, and QWeb templates + +--- + +## ✨ Key Features + +### πŸ’Š Complete Medicine Management System + +#### Comprehensive Medicine Catalog +- **Full data model** including medicine name, manufacturer, batch number, barcode, and universal product code (UPC) +- **Hierarchical categorization** with parent-child category relationships for logical organization +- **License-based access model** (Green/Blue/White) determines which user groups can view and interact with each medicine +- **Storage conditions tracking** – room temperature, cold storage (2-8Β°C), and frozen storage support +- **Batch and traceability** – complete batch number tracking for recalls and regulatory compliance +- **Regulatory metadata** – dosage instructions, side effects documentation, and safety information + +#### Intelligent Pricing & Profitability +- **Dual pricing system** – selling price and cost price with automatic profit margin calculation +- **Real-time margin analytics** – computed field updates profit percentage dynamically +- **Price-based stock valuation** – total inventory value calculated as sum of (quantity Γ— selling price) + +#### Advanced Stock Management +- **Real-time inventory tracking** – quantity-based stock status (in-stock, low-stock, out-of-stock) +- **Intelligent reorder system** – configurable reorder level with "Need Reorder" alerts +- **Low stock detection** – automatic flagging of medicines with 1-9 units remaining +- **Automatic stock reduction** – inventory decremented on successful cart checkout +- **Stock status badges** – visual indicators in list and kanban views for quick inventory assessment + +#### Expiry Management & Alerts +- **Automated expiry calculations** – computed field tracks remaining days until expiration +- **Three-tier expiry status** – Fresh (>30 days), Expiring Soon (≀30 days), Expired (