From 98486e284efd4cf837e5e88a7c5bc537e3494bc8 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 28 May 2026 10:07:53 +0700 Subject: [PATCH] first commit --- .gitignore | 33 ++++ README.md | 100 +++++++++++ __init__.py | 4 + __manifest__.py | 36 ++++ data/ir_sequence_data.xml | 14 ++ models/__init__.py | 8 + models/loyalty_program.py | 70 ++++++++ models/loyalty_reward.py | 35 ++++ models/loyalty_voucher_generation_request.py | 163 ++++++++++++++++++ models/pos_config.py | 60 +++++++ models/sale_order.py | 14 ++ security/ir.model.access.csv | 13 ++ security/pos_loyalty_marketing_security.xml | 24 +++ views/loyalty_program_views.xml | 61 +++++++ ...yalty_voucher_generation_request_views.xml | 121 +++++++++++++ views/menu_views.xml | 82 +++++++++ views/pos_config_views.xml | 22 +++ views/res_company_views.xml | 22 +++ wizards/__init__.py | 3 + wizards/loyalty_generate_wizard.py | 59 +++++++ 20 files changed, 944 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 data/ir_sequence_data.xml create mode 100644 models/__init__.py create mode 100644 models/loyalty_program.py create mode 100644 models/loyalty_reward.py create mode 100644 models/loyalty_voucher_generation_request.py create mode 100644 models/pos_config.py create mode 100644 models/sale_order.py create mode 100644 security/ir.model.access.csv create mode 100644 security/pos_loyalty_marketing_security.xml create mode 100644 views/loyalty_program_views.xml create mode 100644 views/loyalty_voucher_generation_request_views.xml create mode 100644 views/menu_views.xml create mode 100644 views/pos_config_views.xml create mode 100644 views/res_company_views.xml create mode 100644 wizards/__init__.py create mode 100644 wizards/loyalty_generate_wizard.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d62f615 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Editor & System Files +.vscode/ +.idea/ +*.swp +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dad962 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# POS Loyalty Marketing Access & Approvals + +A custom Odoo 19 module introducing comprehensive security permissions, double-approval validation states, hierarchy-aware routing, and automated accounting mapping for Point of Sale Loyalty, Promotions, and Vouchers. + +--- + +## 🌟 Key Features + +### 1. Granular Security Groups +- **Marketing / User**: + - Full **Read / Create / Write** access to loyalty programs, discount programs, gift cards, and eWallets. + - Restricted from **deleting** marketing programs. + - Restricted from **generating vouchers** (coupons) directly. + - Strict **Read-only** access to Point of Sale Orders, Order Lines, Payments, POS Reports, Loyalty History, and Loyalty Mail. +- **Marketing / Manager**: + - Full user capabilities. + - Exclusive access to **Generate Vouchers** directly via the standard generation wizard. + +### 2. Double-Approval validation Flow +- All newly created `loyalty.program` records (Loyalty, Discount, Promotion, Promo Code, Gift Card, eWallet) start in a **Draft** state. +- Marketing Users can edit draft programs and click **Submit for Approval** to put them into the **Pending Approval** state. +- The designated company-level approver (or any user in the **Marketing / Manager** group if no specific approver is configured) can click **Approve** to move the program into the **Active** state. +- When an active program is modified, its state automatically resets to **Draft** to ensure all edits are explicitly reviewed and re-approved before they can be used at the POS register. + +### 3. Staged Voucher Generation Queues +- Restricts immediate voucher code generation for non-managers. +- When a user submits a request to generate coupon/voucher codes, the module automatically intercepts it and creates a **Voucher Generation Request** (`loyalty.voucher.generation.request`). +- This request stores the desired program, code count, prefix, point values, and expiration dates. +- Managers review the request queues and approve them to execute code generation safely in the background. + +### 4. Hierarchy-Aware Company-Level Approvers +- Designated approvers are configured directly on each **Company** record via the new **Marketing Approvals** notebook tab (*Settings > Users & Companies > Companies*). +- Form fields available: + - **Marketing Program Approver** + - **Voucher Generation Approver** +- **Upward Tree Walking**: When a marketing program set on the parent company **OT** is verified: + - The module walks upward in the company hierarchy tree (`parent_id`) starting from the active/program company. + - Branch companies automatically inherit and share parent-level (**OT**) approvers. + - Local overrides can be set on any child branch company if different personnel are desired. + - Fallback logic authorizes any **Marketing / Manager** user if no designated approver exists in the tree. + +### 5. Automated Discount Product Configuration +- When a marketing program is saved, Odoo standard generates an underlying service product used to represent the discount item in sales order lines. +- This module automatically intercepts that product's creation and: + - Assigns it to the product category **`OT / Saleable / PoS / Discounts`** (with fallback to any category named **`Discounts`**). + - Assigns both the **Income Account** and **Expense Account** to code **`412201`** for the corresponding active company. + +--- + +## 📂 Technical Layout & Structure + +```text +pos_loyalty_marketing_access/ +├── __init__.py +├── __manifest__.py +├── data/ +│ └── ir_sequence_data.xml +├── models/ +│ ├── __init__.py +│ ├── loyalty_program.py +│ ├── loyalty_reward.py +│ ├── loyalty_voucher_generation_request.py +│ ├── pos_config.py +│ └── sale_order.py +├── security/ +│ ├── ir.model.access.csv +│ └── pos_loyalty_marketing_security.xml +├── views/ +│ ├── loyalty_program_views.xml +│ ├── loyalty_voucher_generation_request_views.xml +│ ├── menu_views.xml +│ ├── pos_config_views.xml +│ └── res_company_views.xml +├── wizards/ +│ ├── __init__.py +│ └── loyalty_generate_wizard.py +└── README.md +``` + +--- + +## 🛠️ Installation & Upgrades + +1. Place the `pos_loyalty_marketing_access` folder inside your custom addons directory. +2. Restart the Odoo server. +3. Update the module list in Odoo developer settings. +4. Search for `POS Loyalty Marketing Access & Approvals` and click **Install** or **Upgrade**. + +Alternatively, upgrade the database schema directly via the command line: +```bash +python3 odoo-bin -c odoo.conf -d mapangroup_o19_7 -u pos_loyalty_marketing_access --stop-after-init +``` + +--- + +## 📝 License & Copyright + +- **Author**: Suherdy Yacob +- **Version**: 1.0 +- **Compatibility**: Odoo 19.0 (Community & Enterprise Editions) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f553d8f --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import wizards diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..2bc1411 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'POS Loyalty Marketing Access & Approvals', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Custom Marketing security groups, access restrictions, and approval flows for loyalty programs and voucher generation.', + 'description': """ +POS Loyalty Marketing Access & Approvals +======================================== +This module introduces: +1. Two new security groups: Marketing / User and Marketing / Manager. +2. Read-only POS orders and member transaction views for Marketing Users. +3. Marketing Program draft/approved states and an approval flow. +4. Voucher generation approval flow using a new Voucher Generation Requests model. + """, + 'author': 'Suherdy Yacob', + 'depends': [ + 'point_of_sale', + 'loyalty', + 'pos_loyalty', + 'sale_loyalty', + ], + 'data': [ + 'security/pos_loyalty_marketing_security.xml', + 'security/ir.model.access.csv', + 'data/ir_sequence_data.xml', + 'views/pos_config_views.xml', + 'views/loyalty_program_views.xml', + 'views/loyalty_voucher_generation_request_views.xml', + 'views/res_company_views.xml', + 'views/menu_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/data/ir_sequence_data.xml b/data/ir_sequence_data.xml new file mode 100644 index 0000000..f212ac9 --- /dev/null +++ b/data/ir_sequence_data.xml @@ -0,0 +1,14 @@ + + + + + Voucher Generation Request Sequence + loyalty.voucher.generation.request + VGR/ + 5 + 1 + 1 + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..6bf837b --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from . import pos_config +from . import loyalty_program +from . import sale_order +from . import loyalty_voucher_generation_request +from . import loyalty_reward + diff --git a/models/loyalty_program.py b/models/loyalty_program.py new file mode 100644 index 0000000..3113a69 --- /dev/null +++ b/models/loyalty_program.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +class LoyaltyProgram(models.Model): + _name = 'loyalty.program' + _inherit = ['loyalty.program', 'mail.thread', 'mail.activity.mixin'] + + state = fields.Selection([ + ('draft', 'Draft'), + ('pending', 'Pending Approval'), + ('approved', 'Approved') + ], default='draft', string='Status', tracking=True) + + def action_request_approval(self): + for program in self: + if program.state != 'draft': + continue + program.write({'state': 'pending'}) + program.message_post(body=_("Approval requested for this marketing program.")) + + def action_approve(self): + for program in self: + if program.state != 'pending': + continue + + # Check authorization + target_company = program.company_id or self.env.company + approver = target_company._get_marketing_program_approver() + + if approver: + if self.env.user.id != approver.id: + raise UserError(_("Only the designated Marketing Program Approver (%s) can approve this program.") % approver.name) + else: + is_manager = self.env.user.has_group('pos_loyalty_marketing_access.group_marketing_manager') + if not is_manager: + raise UserError(_("Only a Marketing Manager can approve this program as no designated approver is configured.")) + + program.write({'state': 'approved'}) + program.message_post(body=_("Marketing program has been approved and is now active.")) + + def action_reset_draft(self): + for program in self: + program.write({'state': 'draft'}) + program.message_post(body=_("Marketing program reset to Draft.")) + + def write(self, vals): + # If we are only modifying state, skip the write reset checks + if len(vals) == 1 and 'state' in vals: + return super().write(vals) + + # For modifications, check if they are in pending/approved state + for program in self: + if program.state in ['pending', 'approved']: + target_company = program.company_id or self.env.company + approver = target_company._get_marketing_program_approver() + + if approver: + if self.env.user.id != approver.id: + vals['state'] = 'draft' + program.message_post(body=_("The approved marketing program was modified by a standard user and has been reset to Draft status for re-approval.")) + else: + is_manager = self.env.user.has_group('pos_loyalty_marketing_access.group_marketing_manager') + if not is_manager: + vals['state'] = 'draft' + program.message_post(body=_("The approved marketing program was modified by a standard user and has been reset to Draft status for re-approval.")) + + return super().write(vals) + diff --git a/models/loyalty_reward.py b/models/loyalty_reward.py new file mode 100644 index 0000000..94c5bcd --- /dev/null +++ b/models/loyalty_reward.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + +class LoyaltyReward(models.Model): + _inherit = 'loyalty.reward' + + def _get_discount_product_values(self): + res = super()._get_discount_product_values() + + # Search for the dedicated category "OT / Saleable / PoS / Discounts" + category = self.env['product.category'].sudo().search([ + ('complete_name', '=', 'OT / Saleable / PoS / Discounts') + ], limit=1) + if not category: + # Fallback to any category named "Discounts" if not found + category = self.env['product.category'].sudo().search([ + ('name', '=', 'Discounts') + ], limit=1) + + for reward, vals in zip(self, res): + if category: + vals['categ_id'] = category.id + + # Search for account "412201" matching the company of the reward + company = reward.company_id or self.env.company + account = self.env['account.account'].sudo().search([ + ('code', '=', '412201'), + ('company_id', '=', company.id) + ], limit=1) + if account: + vals['property_account_income_id'] = account.id + vals['property_account_expense_id'] = account.id + + return res diff --git a/models/loyalty_voucher_generation_request.py b/models/loyalty_voucher_generation_request.py new file mode 100644 index 0000000..ecc474d --- /dev/null +++ b/models/loyalty_voucher_generation_request.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.fields import Domain + +class LoyaltyVoucherGenerationRequest(models.Model): + _name = 'loyalty.voucher.generation.request' + _description = 'Voucher Generation Request' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + name = fields.Char( + string='Reference', + required=True, + copy=False, + readonly=True, + default=lambda self: _('New') + ) + program_id = fields.Many2one( + 'loyalty.program', + string='Program', + required=True, + domain="[('state', '=', 'approved')]" + ) + mode = fields.Selection([ + ('anonymous', 'Anonymous Customers'), + ('selected', 'Selected Customers') + ], string='For', required=True, default='anonymous') + + customer_ids = fields.Many2many('res.partner', string='Customers') + customer_tag_ids = fields.Many2many('res.partner.category', string='Customer Tags') + + coupon_qty = fields.Integer('Quantity', default=1) + points_granted = fields.Float('Grant', default=1.0) + valid_until = fields.Date('Valid Until') + description = fields.Text('Description') + + state = fields.Selection([ + ('draft', 'Draft'), + ('pending', 'Pending Approval'), + ('approved', 'Approved'), + ('rejected', 'Rejected') + ], default='draft', string='Status', tracking=True) + + requester_id = fields.Many2one( + 'res.users', + string='Requested By', + default=lambda self: self.env.user, + readonly=True + ) + approver_id = fields.Many2one( + 'res.users', + string='Approved By', + readonly=True + ) + company_id = fields.Many2one( + 'res.company', + string='Company', + default=lambda self: self.env.company + ) + generated_card_ids = fields.Many2many( + 'loyalty.card', + string='Generated Vouchers', + readonly=True + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('loyalty.voucher.generation.request') or _('New') + return super().create(vals_list) + + def _get_partners(self): + self.ensure_one() + if self.mode != 'selected': + return self.env['res.partner'] + domains = [] + if self.customer_ids: + domains.append([('id', 'in', self.customer_ids.ids)]) + if self.customer_tag_ids: + domains.append([('category_id', 'in', self.customer_tag_ids.ids)]) + return self.env['res.partner'].search(Domain.OR(domains) if domains else Domain.TRUE) + + def _get_coupon_values(self, partner): + self.ensure_one() + return { + 'program_id': self.program_id.id, + 'points': self.points_granted, + 'expiration_date': self.valid_until, + 'partner_id': partner.id if self.mode == 'selected' else False, + } + + def action_request_approval(self): + for req in self: + if req.state != 'draft': + continue + req.write({'state': 'pending'}) + req.message_post(body=_("Voucher generation approval requested.")) + + def action_approve(self): + for req in self: + if req.state != 'pending': + continue + + # Check authorization + target_company = req.company_id or self.env.company + approver = target_company._get_voucher_generation_approver() + + if approver: + if self.env.user.id != approver.id: + raise UserError(_("Only the designated Voucher Generation Approver (%s) can approve this request.") % approver.name) + else: + is_manager = self.env.user.has_group('pos_loyalty_marketing_access.group_marketing_manager') + if not is_manager: + raise UserError(_("Only a Marketing Manager can approve this request as no designated approver is configured.")) + + if req.coupon_qty <= 0: + raise ValidationError(_("Invalid quantity.")) + + # Execute voucher generation + coupon_create_vals = [] + customers = req._get_partners() or range(req.coupon_qty) + for partner in customers: + coupon_create_vals.append(req._get_coupon_values(partner)) + + coupons = self.env['loyalty.card'].create(coupon_create_vals) + self.env['loyalty.history'].create([ + { + 'description': req.description or _("Gift For Customer"), + 'card_id': coupon.id, + 'issued': req.points_granted, + } for coupon in coupons + ]) + + req.write({ + 'state': 'approved', + 'approver_id': self.env.user.id, + 'generated_card_ids': [(6, 0, coupons.ids)], + }) + req.message_post(body=_("Voucher generation request approved. %s vouchers have been generated.") % len(coupons)) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Vouchers generated successfully.'), + 'type': 'success', + 'sticky': False, + } + } + + def action_reject(self): + for req in self: + if req.state != 'pending': + continue + req.write({ + 'state': 'rejected', + 'approver_id': self.env.user.id, + }) + req.message_post(body=_("Voucher generation request rejected.")) diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..010f6af --- /dev/null +++ b/models/pos_config.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + +class ResCompany(models.Model): + _inherit = 'res.company' + + marketing_program_approver_id = fields.Many2one( + 'res.users', + string='Marketing Program Approver', + help='User authorized to approve marketing programs for this company.' + ) + voucher_generation_approver_id = fields.Many2one( + 'res.users', + string='Voucher Generation Approver', + help='User authorized to approve voucher generation requests for this company.' + ) + + def _get_marketing_program_approver(self): + self.ensure_one() + comp = self + while comp: + if comp.marketing_program_approver_id: + return comp.marketing_program_approver_id + comp = comp.parent_id + return self.env['res.users'] + + def _get_voucher_generation_approver(self): + self.ensure_one() + comp = self + while comp: + if comp.voucher_generation_approver_id: + return comp.voucher_generation_approver_id + comp = comp.parent_id + return self.env['res.users'] + +class PosConfig(models.Model): + _inherit = 'pos.config' + + marketing_program_approver_id = fields.Many2one( + 'res.users', + related='company_id.marketing_program_approver_id', + readonly=False, + string='Marketing Program Approver', + help='User authorized to approve marketing programs.' + ) + voucher_generation_approver_id = fields.Many2one( + 'res.users', + related='company_id.voucher_generation_approver_id', + readonly=False, + string='Voucher Generation Approver', + help='User authorized to approve voucher generation requests.' + ) + + def _get_program_ids(self): + res = super()._get_program_ids() + if not res: + return res + return res.filtered(lambda p: p.state == 'approved') + diff --git a/models/sale_order.py b/models/sale_order.py new file mode 100644 index 0000000..b79beea --- /dev/null +++ b/models/sale_order.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def _get_program_domain(self): + domain = super()._get_program_domain() + return domain + [('state', '=', 'approved')] + + def _get_trigger_domain(self): + domain = super()._get_trigger_domain() + return domain + [('state', '=', 'approved')] diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..c65ed3f --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_marketing_user_loyalty_program,Access Loyalty Program Marketing User,loyalty.model_loyalty_program,group_marketing_user,1,1,1,0 +access_marketing_user_loyalty_rule,Access Loyalty Rule Marketing User,loyalty.model_loyalty_rule,group_marketing_user,1,1,1,0 +access_marketing_user_loyalty_reward,Access Loyalty Reward Marketing User,loyalty.model_loyalty_reward,group_marketing_user,1,1,1,0 +access_marketing_user_loyalty_mail,Access Loyalty Mail Marketing User,loyalty.model_loyalty_mail,group_marketing_user,1,1,1,0 +access_marketing_user_loyalty_card,Access Loyalty Card Marketing User,loyalty.model_loyalty_card,group_marketing_user,1,1,1,0 +access_marketing_user_loyalty_history,Access Loyalty History Marketing User,loyalty.model_loyalty_history,group_marketing_user,1,0,0,0 +access_marketing_user_pos_order,Access POS Order Marketing User,point_of_sale.model_pos_order,group_marketing_user,1,0,0,0 +access_marketing_user_pos_order_line,Access POS Order Line Marketing User,point_of_sale.model_pos_order_line,group_marketing_user,1,0,0,0 +access_marketing_user_pos_payment,Access POS Payment Marketing User,point_of_sale.model_pos_payment,group_marketing_user,1,0,0,0 +access_marketing_user_report_pos_order,Access POS Report Marketing User,point_of_sale.model_report_pos_order,group_marketing_user,1,0,0,0 +access_marketing_manager_loyalty_generate_wizard,Access Coupon Generation Wizard Marketing Manager,loyalty.model_loyalty_generate_wizard,group_marketing_manager,1,1,1,0 +access_marketing_user_voucher_request,Access Voucher Request Marketing User,model_loyalty_voucher_generation_request,group_marketing_user,1,1,1,1 diff --git a/security/pos_loyalty_marketing_security.xml b/security/pos_loyalty_marketing_security.xml new file mode 100644 index 0000000..e772f0d --- /dev/null +++ b/security/pos_loyalty_marketing_security.xml @@ -0,0 +1,24 @@ + + + + + + Marketing Loyalty Access + + + + + + User + + + + + + + Manager + + + + + diff --git a/views/loyalty_program_views.xml b/views/loyalty_program_views.xml new file mode 100644 index 0000000..7b88efe --- /dev/null +++ b/views/loyalty_program_views.xml @@ -0,0 +1,61 @@ + + + + + loyalty.program.view.form.inherit.approval + loyalty.program + + + + +