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 @@
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This product does not have a recipe. Go to Recipes menu to define one.
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+