forked from alaguraj/odoo-testing-addons
Introduce product recipe management, enabling ingredient cost tracking, profit margin display, and low stock notifications on the dashboard.
This commit is contained in:
parent
8191f7d58c
commit
0da194db19
Binary file not shown.
@ -46,12 +46,27 @@ class ImageHome(Website):
|
|||||||
|
|
||||||
filtered_menus.append(menu)
|
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', {
|
return request.render('dine360_dashboard.image_home_template', {
|
||||||
'menus': filtered_menus,
|
'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)
|
@http.route('/home', type='http', auth="public", website=True, sitemap=True)
|
||||||
def website_home(self, **kw):
|
def website_home(self, **kw):
|
||||||
# Explicit route for standard Website Homepage
|
# Explicit route for standard Website Homepage
|
||||||
|
|||||||
@ -52,7 +52,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container" style="padding-top: 100px;">
|
<div class="container" style="padding-top: 100px;">
|
||||||
|
<!-- Low Stock Alert Section -->
|
||||||
|
<t t-if="low_stock_products">
|
||||||
|
<div class="o_low_stock_alert mb-5" style="background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.2); border-radius: 12px; padding: 15px 25px;">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fa fa-exclamation-triangle me-2" style="color: #dc3545; font-size: 1.2rem;"></i>
|
||||||
|
<h5 class="mb-0 fw-bold" style="color: #dc3545;">Low Stock Alert!</h5>
|
||||||
|
<a href="/web#action=stock.product_template_action_product" class="ms-auto text-decoration-none small fw-bold" style="color: #dc3545;">
|
||||||
|
Manage Inventory <i class="fa fa-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<t t-foreach="low_stock_products" t-as="product">
|
||||||
|
<div class="badge rounded-pill bg-white border px-3 py-2 shadow-sm" style="color: #333;">
|
||||||
|
<span class="fw-bold" t-esc="product.name"/>:
|
||||||
|
<span class="text-danger fw-bold" t-esc="product.qty_available"/>
|
||||||
|
<span class="text-muted" t-esc="product.uom_id.name"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
<div class="o_apps">
|
<div class="o_apps">
|
||||||
|
|
||||||
<t t-foreach="menus" t-as="menu">
|
<t t-foreach="menus" t-as="menu">
|
||||||
<t t-set="app_url" t-value="'/web#menu_id=' + str(menu.id)"/>
|
<t t-set="app_url" t-value="'/web#menu_id=' + str(menu.id)"/>
|
||||||
<!-- Check if it is the Website app by checking the icon module or name -->
|
<!-- Check if it is the Website app by checking the icon module or name -->
|
||||||
|
|||||||
1
addons/dine360_recipe/__init__.py
Normal file
1
addons/dine360_recipe/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
16
addons/dine360_recipe/__manifest__.py
Normal file
16
addons/dine360_recipe/__manifest__.py
Normal file
@ -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',
|
||||||
|
}
|
||||||
4
addons/dine360_recipe/models/__init__.py
Normal file
4
addons/dine360_recipe/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from . import recipe
|
||||||
|
from . import product
|
||||||
|
from . import pos_order
|
||||||
|
from . import sale_order
|
||||||
66
addons/dine360_recipe/models/pos_order.py
Normal file
66
addons/dine360_recipe/models/pos_order.py
Normal file
@ -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}")
|
||||||
78
addons/dine360_recipe/models/product.py
Normal file
78
addons/dine360_recipe/models/product.py
Normal file
@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
54
addons/dine360_recipe/models/recipe.py
Normal file
54
addons/dine360_recipe/models/recipe.py
Normal file
@ -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
|
||||||
56
addons/dine360_recipe/models/sale_order.py
Normal file
56
addons/dine360_recipe/models/sale_order.py
Normal file
@ -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()
|
||||||
3
addons/dine360_recipe/security/ir.model.access.csv
Normal file
3
addons/dine360_recipe/security/ir.model.access.csv
Normal file
@ -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
|
||||||
|
28
addons/dine360_recipe/views/product_views.xml
Normal file
28
addons/dine360_recipe/views/product_views.xml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="product_template_form_view_inherit_recipe" model="ir.ui.view">
|
||||||
|
<field name="name">product.template.form.inherit.recipe</field>
|
||||||
|
<field name="model">product.template</field>
|
||||||
|
<field name="inherit_id" ref="product.product_template_only_form_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//page[@name='sales']" position="after">
|
||||||
|
<page string="Recipe & Margins" name="recipe_margins">
|
||||||
|
<group>
|
||||||
|
<group string="Recipe Information">
|
||||||
|
<field name="has_recipe"/>
|
||||||
|
<field name="recipe_cost" widget="monetary"/>
|
||||||
|
<field name="min_stock_level"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Profitability">
|
||||||
|
<field name="list_price" string="Selling Price" widget="monetary"/>
|
||||||
|
<field name="profit_margin" widget="progressbar"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info" role="alert" invisible="has_recipe">
|
||||||
|
This product does not have a recipe. Go to <strong>Recipes</strong> menu to define one.
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
74
addons/dine360_recipe/views/recipe_views.xml
Normal file
74
addons/dine360_recipe/views/recipe_views.xml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<odoo>
|
||||||
|
<!-- Recipe Form View -->
|
||||||
|
<record id="view_dine360_recipe_form" model="ir.ui.view">
|
||||||
|
<field name="name">dine360.recipe.form</field>
|
||||||
|
<field name="model">dine360.recipe</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Dish Recipe">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="product_tmpl_id" options="{'no_create': True}"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="total_cost" widget="monetary" options="{'currency_field': 'currency_id'}"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Ingredients" name="ingredients">
|
||||||
|
<field name="ingredient_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="product_id" options="{'no_create': True}"/>
|
||||||
|
<field name="quantity"/>
|
||||||
|
<field name="uom_id"/>
|
||||||
|
<field name="cost_price" widget="monetary"/>
|
||||||
|
<field name="subtotal_cost" widget="monetary"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Recipe Tree View -->
|
||||||
|
<record id="view_dine360_recipe_tree" model="ir.ui.view">
|
||||||
|
<field name="name">dine360.recipe.tree</field>
|
||||||
|
<field name="model">dine360.recipe</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Dish Recipes">
|
||||||
|
<field name="product_tmpl_id"/>
|
||||||
|
<field name="total_cost" widget="monetary"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Recipe Action -->
|
||||||
|
<record id="action_dine360_recipe" model="ir.actions.act_window">
|
||||||
|
<field name="name">Dish Recipes</field>
|
||||||
|
<field name="res_model">dine360.recipe</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first Recipe!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Define the ingredients used for each dish to track inventory and margins.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menuitem (Inside Dine360 Dashboard or Restaurant) -->
|
||||||
|
<menuitem id="menu_dine360_recipe_root"
|
||||||
|
name="Recipes"
|
||||||
|
parent="point_of_sale.menu_point_root"
|
||||||
|
sequence="25"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_dine360_recipe"
|
||||||
|
name="Dish Recipes (BOM)"
|
||||||
|
parent="menu_dine360_recipe_root"
|
||||||
|
action="action_dine360_recipe"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Loading…
x
Reference in New Issue
Block a user