diff --git a/addons/dine360_dashboard/controllers/__pycache__/main.cpython-310.pyc b/addons/dine360_dashboard/controllers/__pycache__/main.cpython-310.pyc index 26b8ec4..028b542 100644 Binary files a/addons/dine360_dashboard/controllers/__pycache__/main.cpython-310.pyc and b/addons/dine360_dashboard/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/dine360_dashboard/controllers/main.py b/addons/dine360_dashboard/controllers/main.py index 09a55fc..56c3cf9 100644 --- a/addons/dine360_dashboard/controllers/main.py +++ b/addons/dine360_dashboard/controllers/main.py @@ -46,12 +46,27 @@ class ImageHome(Website): filtered_menus.append(menu) + # Low Stock Alerts (Ingredients) + low_stock_products = [] + try: + # Try to get low stock products if the model and method exist + ProductTemplate = request.env['product.template'].sudo() + if hasattr(ProductTemplate, 'get_low_stock_products'): + low_stock_products = ProductTemplate.get_low_stock_products(limit=5) + except Exception: + # Fallback if module is not yet fully loaded or method missing + low_stock_products = [] + + + return request.render('dine360_dashboard.image_home_template', { 'menus': filtered_menus, - 'user_id': request.env.user + 'user_id': request.env.user, + 'low_stock_products': low_stock_products }) + @http.route('/home', type='http', auth="public", website=True, sitemap=True) def website_home(self, **kw): # Explicit route for standard Website Homepage diff --git a/addons/dine360_dashboard/views/home_template.xml b/addons/dine360_dashboard/views/home_template.xml index 9596672..ae0bc52 100644 --- a/addons/dine360_dashboard/views/home_template.xml +++ b/addons/dine360_dashboard/views/home_template.xml @@ -52,7 +52,31 @@
+ + +
+
+ +
Low Stock Alert!
+ + Manage Inventory + + +
+
+ +
+ : + + +
+
+
+
+
+
+ diff --git a/addons/dine360_recipe/__init__.py b/addons/dine360_recipe/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/dine360_recipe/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/dine360_recipe/__manifest__.py b/addons/dine360_recipe/__manifest__.py new file mode 100644 index 0000000..d78d226 --- /dev/null +++ b/addons/dine360_recipe/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': 'Dine360 Recipe & Inventory', + 'version': '1.0', + 'category': 'Manufacturing', + 'summary': 'Manage recipes and automatic ingredient-level inventory deduction', + 'author': 'Dine360', + 'depends': ['point_of_sale', 'stock', 'dine360_restaurant'], + 'data': [ + 'security/ir.model.access.csv', + 'views/recipe_views.xml', + 'views/product_views.xml', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} diff --git a/addons/dine360_recipe/models/__init__.py b/addons/dine360_recipe/models/__init__.py new file mode 100644 index 0000000..d659f5c --- /dev/null +++ b/addons/dine360_recipe/models/__init__.py @@ -0,0 +1,4 @@ +from . import recipe +from . import product +from . import pos_order +from . import sale_order diff --git a/addons/dine360_recipe/models/pos_order.py b/addons/dine360_recipe/models/pos_order.py new file mode 100644 index 0000000..6d4b67a --- /dev/null +++ b/addons/dine360_recipe/models/pos_order.py @@ -0,0 +1,66 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + +class PosOrder(models.Model): + _inherit = 'pos.order' + + def action_pos_order_paid(self): + """ + Override to deduct recipe ingredients when an order is paid. + """ + res = super(PosOrder, self).action_pos_order_paid() + for order in self: + order._deduct_recipe_ingredients() + return res + + def _deduct_recipe_ingredients(self): + """ + Calculates and deducts total ingredients for all lines in the order. + """ + self.ensure_one() + + # 1. Find a suitable Kitchen/Scrap Location + # We assume the default warehouse's stock location for now, + # but in a real setup, we might want a 'Production' or 'Virtual/Consumed' location. + location_src_id = self.config_id.warehouse_id.lot_stock_id.id + location_dest_id = self.env.ref('stock.stock_location_customers').id # Deducting to customers + + # If the warehouse isn't configured, fallback to finding any internal location + if not location_src_id: + location_src_id = self.env['stock.location'].search([('usage', '=', 'internal')], limit=1).id + + moves = [] + for line in self.lines: + recipe = self.env['dine360.recipe'].search([('product_tmpl_id', '=', line.product_id.product_tmpl_id.id)], limit=1) + + if recipe: + for ingredient in recipe.ingredient_ids: + # Calculate total quantity (qty of dish * qty of ingredient in recipe) + total_qty = line.qty * ingredient.quantity + + moves.append({ + 'name': f"Recipe Deduction: {line.product_id.name} ({self.name})", + 'product_id': ingredient.product_id.id, + 'product_uom': ingredient.uom_id.id, + 'product_uom_qty': total_qty, + 'location_id': location_src_id, + 'location_dest_id': location_dest_id, + 'origin': self.name, + 'state': 'draft', + }) + + if moves: + _logger.info(f"Dine360 Recipe: Creating {len(moves)} stock moves for order {self.name}") + stock_moves = self.env['stock.move'].create(moves) + stock_moves._action_confirm() + stock_moves._action_assign() + stock_moves._set_quantity_done(stock_moves.mapped('product_uom_qty')) # Simplified for Odoo 17 + # In Odoo 17, _set_quantity_done is different, using move_line_ids or quantity field. + # Using loop for safety across sub-versions + for move in stock_moves: + move.quantity = move.product_uom_qty # Odoo 17 uses 'quantity' field + stock_moves._action_done() + + _logger.info(f"Dine360 Recipe: Successfully deducted ingredients for {self.name}") diff --git a/addons/dine360_recipe/models/product.py b/addons/dine360_recipe/models/product.py new file mode 100644 index 0000000..1ef4317 --- /dev/null +++ b/addons/dine360_recipe/models/product.py @@ -0,0 +1,78 @@ +from odoo import models, fields, api + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + recipe_id = fields.One2many( + 'dine360.recipe', + 'product_tmpl_id', + string='Recipe' + ) + + has_recipe = fields.Boolean( + string='Has Recipe', + compute='_compute_has_recipe', + store=True + ) + + recipe_cost = fields.Float( + string='Ingredient Cost', + related='recipe_id.total_cost', + readonly=True + ) + + profit_margin = fields.Float( + string='Profit Margin (%)', + compute='_compute_profit_margin' + ) + + min_stock_level = fields.Float( + string='Low Stock Threshold', + default=0.0, + help="Alert will be triggered when stock falls below this level." + ) + + is_low_stock = fields.Boolean( + string='Is Low Stock', + compute='_compute_low_stock' + ) + + def _compute_has_recipe(self): + for product in self: + product.has_recipe = bool(product.recipe_id) + + def _compute_profit_margin(self): + for product in self: + if product.list_price > 0: + cost = product.recipe_cost or product.standard_price + product.profit_margin = ((product.list_price - cost) / product.list_price) * 100 + else: + product.profit_margin = 0 + + @api.depends('qty_available', 'min_stock_level') + def _compute_low_stock(self): + for product in self: + # Only check for physical products (ingredients) + if product.type == 'product' and product.min_stock_level > 0: + product.is_low_stock = product.qty_available < product.min_stock_level + else: + product.is_low_stock = False + + + @api.model + def get_low_stock_products(self, limit=5): + """ + Helper method for the dashboard to find low stock products without triggering recursion. + """ + # Find all physical products or consumables that have a threshold set + # Using both 'type' and 'detailed_type' for Odoo version compatibility + domain = ['|', ('type', 'in', ['product', 'consu']), ('detailed_type', 'in', ['product', 'consu']), ('min_stock_level', '>', 0)] + products = self.search(domain) + + # Filter in memory since qty_available is computed + # For consumables, qty_available is 0, so it will always trigger if min_stock > 0 + low_stock = products.filtered(lambda p: p.qty_available < p.min_stock_level) + return low_stock[:limit] + + + diff --git a/addons/dine360_recipe/models/recipe.py b/addons/dine360_recipe/models/recipe.py new file mode 100644 index 0000000..f11d979 --- /dev/null +++ b/addons/dine360_recipe/models/recipe.py @@ -0,0 +1,54 @@ +from odoo import models, fields, api + +class DineRecipe(models.Model): + _name = 'dine360.recipe' + _description = 'Restaurant Dish Recipe' + + product_tmpl_id = fields.Many2one( + 'product.template', + string='Dish', + required=True, + ondelete='cascade', + domain=[('type', '=', 'consu')], # Usually dishes are consumables or service + help="The finished dish that is sold to the customer." + ) + + ingredient_ids = fields.One2many( + 'dine360.recipe.line', + 'recipe_id', + string='Ingredients' + ) + + total_cost = fields.Float( + string='Total Ingredient Cost', + compute='_compute_total_cost', + store=True + ) + + @api.depends('ingredient_ids.subtotal_cost') + def _compute_total_cost(self): + for recipe in self: + recipe.total_cost = sum(recipe.ingredient_ids.mapped('subtotal_cost')) + +class DineRecipeLine(models.Model): + _name = 'dine360.recipe.line' + _description = 'Recipe Ingredient Line' + + recipe_id = fields.Many2one('dine360.recipe', string='Recipe', ondelete='cascade') + product_id = fields.Many2one( + 'product.product', + string='Ingredient', + required=True, + domain=[('type', '=', 'product')], # Ingredients must be storable products + help="Raw materials like Rice, Chicken, Oil, etc." + ) + quantity = fields.Float(string='Quantity', default=1.0, required=True) + uom_id = fields.Many2one('uom.uom', string='Unit of Measure', related='product_id.uom_id', readonly=True) + + cost_price = fields.Float(string='Unit Cost', related='product_id.standard_price', readonly=True) + subtotal_cost = fields.Float(string='Subtotal Cost', compute='_compute_subtotal_cost', store=True) + + @api.depends('quantity', 'product_id.standard_price') + def _compute_subtotal_cost(self): + for line in self: + line.subtotal_cost = line.quantity * line.product_id.standard_price diff --git a/addons/dine360_recipe/models/sale_order.py b/addons/dine360_recipe/models/sale_order.py new file mode 100644 index 0000000..e8ca771 --- /dev/null +++ b/addons/dine360_recipe/models/sale_order.py @@ -0,0 +1,56 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def action_confirm(self): + """ + Deduct ingredients when a Website Sale Order is confirmed. + """ + res = super(SaleOrder, self).action_confirm() + for order in self: + # We only deduct for website orders here, as POS orders are handled by action_pos_order_paid + if order.website_id: + order._deduct_recipe_ingredients_sale() + return res + + def _deduct_recipe_ingredients_sale(self): + self.ensure_one() + + # Deduct from Stock to Customers + location_src_id = self.warehouse_id.lot_stock_id.id + location_dest_id = self.env.ref('stock.stock_location_customers').id + + moves = [] + for line in self.order_line: + if not line.product_id: + continue + + recipe = self.env['dine360.recipe'].search([('product_tmpl_id', '=', line.product_id.product_tmpl_id.id)], limit=1) + + if recipe: + for ingredient in recipe.ingredient_ids: + total_qty = line.product_uom_qty * ingredient.quantity + + moves.append({ + 'name': f"Website Recipe Deduction: {line.product_id.name} ({self.name})", + 'product_id': ingredient.product_id.id, + 'product_uom': ingredient.uom_id.id, + 'product_uom_qty': total_qty, + 'location_id': location_src_id, + 'location_dest_id': location_dest_id, + 'origin': self.name, + 'state': 'draft', + }) + + if moves: + _logger.info(f"Dine360 Recipe (Sale): Creating {len(moves)} stock moves for order {self.name}") + stock_moves = self.env['stock.move'].create(moves) + stock_moves._action_confirm() + stock_moves._action_assign() + for move in stock_moves: + move.quantity = move.product_uom_qty + stock_moves._action_done() diff --git a/addons/dine360_recipe/security/ir.model.access.csv b/addons/dine360_recipe/security/ir.model.access.csv new file mode 100644 index 0000000..0e7f71f --- /dev/null +++ b/addons/dine360_recipe/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dine360_recipe,access_dine360_recipe,model_dine360_recipe,base.group_user,1,1,1,1 +access_dine360_recipe_line,access_dine360_recipe_line,model_dine360_recipe_line,base.group_user,1,1,1,1 diff --git a/addons/dine360_recipe/views/product_views.xml b/addons/dine360_recipe/views/product_views.xml new file mode 100644 index 0000000..b61cb6e --- /dev/null +++ b/addons/dine360_recipe/views/product_views.xml @@ -0,0 +1,28 @@ + + + product.template.form.inherit.recipe + product.template + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_recipe/views/recipe_views.xml b/addons/dine360_recipe/views/recipe_views.xml new file mode 100644 index 0000000..b0c4c63 --- /dev/null +++ b/addons/dine360_recipe/views/recipe_views.xml @@ -0,0 +1,74 @@ + + + + dine360.recipe.form + dine360.recipe + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + dine360.recipe.tree + dine360.recipe + + + + + + + + + + + Dish Recipes + dine360.recipe + tree,form + +

+ Create your first Recipe! +

+

+ Define the ingredients used for each dish to track inventory and margins. +

+
+
+ + + + + + +