diff --git a/.agent/rules/code-execution.md b/.agent/rules/code-execution.md new file mode 100644 index 0000000..abff3c3 --- /dev/null +++ b/.agent/rules/code-execution.md @@ -0,0 +1,96 @@ +--- +trigger: always_on +--- + +# [ALWAYS_ON] ODOO 18 DEVELOPMENT PROTOCOL +This rule file is GLOBALLY ACTIVE. It governs the generation, structure, and validation of Odoo 18 Addons (Modules). + +# IDENTITY_EXTENSION +You are an **Odoo 18 Technical Architect**. You do not write loose scripts; you build structured, installable Odoo Modules. You prioritize OWL 2.0 for frontend and clean Python composition for backend. + +# ODOO_18_CONFIGURATION (JSON) +You must strictly adhere to these version-specific constraints. + +{ + "module_structure": { + "mandatory_files": ["__manifest__.py", "__init__.py"], + "directory_layout": { + "models": "Python business logic", + "views": "XML definitions (Actions, Menus, Views)", + "security": "ir.model.access.csv & rules.xml", + "static/src/components": "OWL Components (JS + XML templates)", + "static/src/scss": "Styles", + "controllers": "HTTP routes" + } + }, + "syntax_enforcement": { + "xml_views": { + "list_view_tag": "", + "forbidden_tags": [""], + "modifiers": "Use Python expressions in 'invisible', 'readonly', 'required'. NO 'attrs'.", + "smart_buttons": "Use type='object' inside div[name='button_box']" + }, + "javascript_owl": { + "framework": "OWL 2.0", + "module_type": "ES6 Modules (/** @odoo-module */)", + "legacy_widgets": "FORBIDDEN" + }, + "manifest_assets": { + "web.assets_backend": ["Includes .js and .scss files for internal UI"], + "web.assets_frontend": ["Includes .js and .scss files for Website"] + } + }, + "execution_safety": { + "scaffolding": "Always create folders before files", + "hot_reload": "Advise user to restart odoo-bin with '-u module_name' after python changes" + } +} + +# OPERATIONAL_RULES +1. Python Logic (.py) - The Backend Core + Init Files: Never create a .py file without ensuring it is imported in the corresponding __init__.py. + Decorators: Use @api.depends, @api.onchange, and @api.constrains appropriately. + Model Definitions: + Always define _name, _description. + If inheriting, clarify _inherit (extension) vs _inherits (delegation). + +2. XML Views (.xml) - The Odoo 18 Interface + The Mandate: When defining a list view, you MUST use the tag. + Incorrect: + Correct: + No attrs Dictionary: + Incorrect: attrs="{'invisible': [('state', '=', 'draft')]}" + Correct: invisible="state == 'draft'" + QWeb & OWL: When writing templates for JS components, place them in static/src/components/.... Do NOT mix them with Backend Views unless strictly necessary. + +3. Security & Access (.csv) + Zero-Trust Default: Every new model MUST have a corresponding entry in ir.model.access.csv. + CSV Structure: id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink. + Naming Convention: Use access_model_name_group_name. + +4. Javascript & OWL Framework (.js) + Header: All JS files must start with /** @odoo-module */. + Component Structure: + Import Component, useState from @odoo/owl. + Define template linking to the XML ID or inline template. + Use registry.category("...").add(...) to register the component. + +5. Styling (.scss) + Scope: Use specific CSS classes (BEM naming preferred) to avoid breaking Odoo's core UI. + Registration: Ensure the .scss file is listed in the __manifest__.py under assets -> web.assets_backend. + +# EXECUTION_AND_SCAFFOLDING_LOGIC +When the user asks to "Create a module for [Idea]": + 1. Draft the Manifest: Define the module name and dependencies (e.g., base, sale, web). + 2. Create Directory Tree: Generate the standard Odoo folder structure. + 3. Generate Core Models: Write the .py files and register them in __init__.py. + 4. Generate Security: Create ir.model.access.csv immediately. + 5. Generate Views: Write .xml files using Odoo 18 syntax (, invisible=expr). + 6. Register Assets: Update __manifest__.py to include Views and Static Assets. + +# SELF_CORRECTION_CHECKLIST +Before outputting any code block: + Did I use for a view? -> Change to . + Did I use attrs? -> Convert to Python expression. + Is the JS file strictly ES6? -> Add /** @odoo-module */. + Is the model listed in __init__.py? -> Add it. \ No newline at end of file diff --git a/.agent/rules/code-style.md b/.agent/rules/code-style.md new file mode 100644 index 0000000..941391e --- /dev/null +++ b/.agent/rules/code-style.md @@ -0,0 +1,110 @@ +--- +trigger: always_on +--- + +# [ALWAYS_ON] ODOO 18 CODE STYLE & QUALITY STANDARDS +This rule file governs the stylistic and syntactic quality of Odoo 18 Addons. It enforces consistency across Python, XML, JavaScript (OWL), and SCSS. + +# IDENTITY_EXTENSION +You are the **Odoo Code Quality Gatekeeper**. You reject deprecated syntax, enforce strict linting, and prioritize readability according to OCA (Odoo Community Association) standards. + +# STYLE_CONFIGURATION (JSON) +Adhere strictly to these formatter/linter configurations: + +{ + "python_engine": { + "style_guide": "PEP 8 + OCA Guidelines", + "linter": "Flake8", + "formatter": "Black or Odoo-Bin Formatter", + "line_length": 88, + "import_sorting": "isort (Groups: Stdlib -> Odoo -> Local)", + "docstrings": "Google Style or ReST" + }, + "xml_engine": { + "indentation": "4 spaces", + "attribute_ordering": "name -> string -> class -> attrs (invisible/readonly)", + "self_closing_tags": "Always use self-closing for empty elements ()" + }, + "javascript_owl": { + "syntax": "ES6 Modules", + "linter": "ESLint", + "formatter": "Prettier", + "naming": { + "components": "PascalCase", + "hooks": "useCamelCase" + } + }, + "scss_engine": { + "methodology": "BEM (Block Element Modifier)", + "nesting_limit": 3 + } +} + +# FILE_SPECIFIC_RULES +1. Python (.py) - Backend Logic + Imports Order (Strict): + Standard Libraries (os, json, logging) + Odoo Core (from odoo import models, fields, api, Command) + Local Addon Imports + + Naming Conventions: + Models: _name = "module.model_name" (Use dots). + Variables: snake_case. Avoid single-letter variables except i, x in short loops. + + Odoo 18 Specifics: + x2many Writes: ALWAYS use Command objects. + Bad: (0, 0, {...}) + Good: [Command.create({...})] + Docstrings: Every method public API (def action_...) must have a docstring. + Empty Strings: Use False for empty text fields in logic comparison, but "" when writing to DB if required. + +2. XML Views (.xml) - Interface Definition + The V18 Paradigm Shift: + Tag: NEVER use for list views. Always use . + Python Expressions: Do NOT use attrs="{...}". Use direct attributes: + invisible="state == 'done'" + readonly="user_id != False" + required="type == 'goods'" + + Structure: + Root tag must wrap all content. + Records must have unique IDs formatted as view_model_name_type. + + Readability: Break long attribute lines (e.g., inside ) into multiple lines if they exceed column 100. + +3. JavaScript (.js) - OWL 2.0 Frontend + Module Header: Every JS file MUST start with /** @odoo-module */. + + Class Definition: + Extend Component from @odoo/owl. + Define static template and static props. + + Reactivity: + Use useState for internal component state. + Do NOT manipulate DOM directly (no jQuery unless absolutely unavoidable). + + Code Style: + Prefer const over let. + Use Arrow functions for callbacks to preserve this context (though OWL binds automatically in templates). + +4. SCSS (.scss) - Styling + Bootstrap Utility: Prioritize Odoo's internal Bootstrap 5 utility classes (e.g., mb-2, d-flex) over writing custom CSS. + Scoping: Wrap your styles in a unique class related to your module to prevent global pollution. + SCSS + + .o_my_module_container { + // Styles here + } + +5. Security (.csv) - Access Rights + Naming: id should follow access_model_name_group_role. + Completeness: Do not leave permission columns empty. Use 1 or 0. + Header: strictly id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink. + +# QUALITY_ASSURANCE_CHECK +Before finalizing the code output, run this mental linter: + Did I use Command instead of Magic Tuples? (Python) + Did I replace with ? (XML) + Did I remove all attrs dictionaries? (XML) + Does the JS file have the Odoo Module header? (JS) +If any answer is "No", correct it immediately. \ No newline at end of file diff --git a/addons/epr/Implementation Plan.md b/addons/epr/Implementation Plan.md new file mode 100644 index 0000000..78166ba --- /dev/null +++ b/addons/epr/Implementation Plan.md @@ -0,0 +1,430 @@ +I. Phạm vi Triển khai + 1. Hệ thống ePR sẽ bao phủ toàn bộ vòng đời của một yêu cầu mua sắm: + + 2. Khởi tạo (Drafting): Nhân viên tạo yêu cầu với chi tiết sản phẩm, số lượng, và ngày cần hàng. + + 3. Định tuyến Phê duyệt (Routing): Hệ thống tự động xác định danh sách người phê duyệt dựa trên ma trận phân quyền (Phòng ban, Ngân sách, Loại sản phẩm). + + 4. Phê duyệt Đa cấp (Multi-level Approval): Hỗ trợ phê duyệt tuần tự hoặc song song. + + 5. Chuyển đổi (Conversion): Tự động gom nhóm các yêu cầu đã duyệt để tạo ra các RFQ tương ứng, phân loại theo nhà cung cấp định trước. + + 6. Kiểm soát và Báo cáo: Theo dõi trạng thái của từng dòng yêu cầu (đã đặt hàng, đã nhận hàng, đã hủy). + +II. Định nghĩa Manifest (__manifest__.py) +{ + 'name': 'Electronic Purchase Request (ePR) Enterprise', + 'version': '18.0.1.0.0', + 'category': 'Procurement/Inventory', + 'summary': 'Hệ thống quản lý yêu cầu mua sắm nội bộ với quy trình phê duyệt động', + 'description': """ + Module ePR được thiết kế chuyên biệt cho Odoo 18. + Các tính năng cốt lõi: + - Tách biệt quy trình Yêu cầu (Internal) và Mua hàng (External). + - Sử dụng cú pháp View mới nhất của Odoo 18. + - Tích hợp ORM Command Interface. + - Ma trận phê duyệt động (Approval Matrix) dựa trên ngưỡng tiền tệ và phòng ban. + """, + 'author': 'Google Antigravity Implementation Team', + 'website': 'https://google-antigravity.dev', + 'depends': [ + 'base', + 'purchase', # Để kết nối với purchase.order [8] + 'hr', # Để lấy thông tin phòng ban và quản lý + 'product', # Quản lý danh mục sản phẩm + 'stock', # Quản lý kho và địa điểm nhận hàng + 'mail', # Tích hợp Chatter và Activity + 'uom', # Đơn vị tính + 'analytic', # Kế toán quản trị (nếu cần phân bổ chi phí) + ], + 'data': [ + 'security/epr_security.xml', + 'security/ir.model.access.csv', + 'data/epr_sequence_data.xml', + 'data/epr_approval_default_data.xml', + 'data/mail_template_data.xml', + 'views/epr_request_views.xml', + 'views/epr_request_line_views.xml', + 'views/epr_approval_matrix_views.xml', + 'views/res_config_settings_views.xml', + 'views/epr_menus.xml', + 'wizards/epr_reject_reason_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'epr_management/static/src/scss/epr_status_widget.scss', + ], + }, + 'installable': True, + 'application': True, + 'license': 'OEEL-1', +} + +Phân tích sâu: Việc phụ thuộc vào hr là bắt buộc vì Odoo 18 quản lý phân cấp nhân sự rất chặt chẽ thông qua trường parent_id (Người quản lý) trong model hr.employee. Hệ thống ePR sẽ sử dụng cấu trúc này để tự động định tuyến phê duyệt cấp 1 (Direct Manager) trước khi chuyển đến các cấp phê duyệt chuyên môn (như Giám đốc Tài chính hay Giám đốc Mua hàng). + +III. Cấu trúc Thư mục Chuẩn Odoo 18 + +epr_management/ +├── __init__.py +├── manifest.py +├── models/ +│ ├── __init__.py +│ ├── epr_request.py # Model chính (Header) +│ ├── epr_request_line.py # Chi tiết yêu cầu (Lines) +│ ├── epr_approval_matrix.py # Logic ma trận phê duyệt +│ ├── purchase_order.py # Kế thừa để liên kết ngược +│ └── hr_employee.py # Mở rộng logic tìm quản lý đặc thù +├── views/ +│ ├── epr_request_views.xml # Form, List, Kanban, Search +│ ├── epr_request_line_views.xml +│ ├── epr_approval_matrix_views.xml +│ ├── epr_menus.xml # Cấu trúc Menu +│ └── res_config_settings_views.xml +├── security/ +│ ├── epr_security.xml # Groups và Record Rules +│ └── ir.model.access.csv # Phân quyền CRUD (ACL) +├── data/ +│ ├── epr_sequence_data.xml # Sequence PR (PR/2024/0001) +│ └── epr_approval_data.xml +├── wizards/ +│ ├── __init__.py +│ ├── epr_reject_reason.py # Xử lý logic từ chối +│ └── epr_reject_reason_views.xml +├── report/ +│ ├── epr_report.xml +│ └── epr_report_template.xml +└── static/ + ├── description/ + │ ├── icon.png + │ └── index.html + └── src/ + └── js/ # Tùy biến OWL Components (nếu có) + +IV. Thiết kế Mô hình Dữ liệu (Data Modeling) + + 4.1 Model Yêu cầu Mua sắm (epr.request) +Đây là đối tượng chứa thông tin chung của phiếu yêu cầu. + +Tên Trường (Field Name) Loại Dữ liệu (Type) Thuộc tính (Attributes) Mô tả Chi tiết & Logic Nghiệp vụ +name Char required=True, readonly=True, copy=False, default='New' Mã định danh duy nhất, được sinh tự động từ ir.sequence khi bản ghi được tạo. +employee_id Many2one comodel='hr.employee', required=True, tracking=True Người yêu cầu. Mặc định lấy env.user.employee_id. +department_id Many2one comodel='hr.department', related='employee_id.department_id', store=True Phòng ban của người yêu cầu. Quan trọng để định tuyến phê duyệt theo ngân sách phòng ban. store=True để hỗ trợ tìm kiếm và nhóm. +date_required Date required=True, tracking=True Ngày cần hàng. Dữ liệu này sẽ được đẩy sang trường date_planned của RFQ. +priority Selection [('0', 'Normal'), ('1', 'Urgent')] Mức độ ưu tiên. Ảnh hưởng đến màu sắc trên giao diện List/Kanban. +state Selection tracking=True, index=True Các trạng thái: draft (Nháp), to_approve (Chờ duyệt), approved (Đã duyệt), in progress (đang xử lý), done (Đã xử lý), rejected (Từ chối), cancel (Hủy). +line_ids One2many comodel='epr.request.line', inverse='request_id' Danh sách các sản phẩm cần mua. +company_id Many2one comodel='res.company', default=lambda self: self.env.company Hỗ trợ môi trường đa công ty (Multi-company). +approver_ids Many2many comodel='res.users', compute='_compute_approvers', store=True Trường tính toán lưu danh sách những người cần phê duyệt tại thời điểm hiện tại. +rejection_reason Text readonly=True Lý do từ chối (nếu có), được điền từ Wizard. + +Chi tiết Kỹ thuật Python (Odoo 18): Trong Odoo 18, việc sử dụng tracking=True (thay thế cho track_visibility='onchange' cũ) giúp tích hợp tự động với Chatter, ghi lại mọi thay đổi quan trọng. + +from odoo import models, fields, api, _ + +class EprRequest(models.Model): + _name = 'epr.request' + _description = 'Yêu cầu Mua sắm' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + # Định nghĩa các trường như bảng trên... + + @api.model_create_multi + def create(self, vals_list): + """ Override hàm create để sinh mã Sequence """ + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('epr.request') or _('New') + return super().create(vals_list) + + 4.2 Model Chi tiết Yêu cầu (epr.request.line) +Model này chứa thông tin chi tiết từng dòng sản phẩm. Việc thiết kế model này cần tính đến khả năng liên kết N-N với Purchase Order Line, vì một dòng yêu cầu có thể được tách ra mua từ nhiều nhà cung cấp khác nhau hoặc mua làm nhiều lần. + +Tên Trường Loại Dữ liệu Thuộc tính Mô tả & Logic +product_id Many2one comodel='product.product', domain= Sản phẩm cần mua. Chỉ lọc các sản phẩm được phép mua. +name Char required=True Mô tả sản phẩm (mặc định lấy tên sản phẩm, cho phép sửa đổi). +quantity Float digits='Product Unit of Measure', required=True Số lượng yêu cầu. +uom_id Many2one comodel='uom.uom' Đơn vị tính. Tự động điền từ sản phẩm nhưng cho phép đổi nếu cùng nhóm ĐVT. +estimated_cost Monetary currency_field='currency_id' Đơn giá dự kiến. Có thể lấy từ giá vốn (standard_price) hoặc bảng giá nhà cung cấp. +total_cost Monetary compute='_compute_total', store=True Thành tiền dự kiến (Số lượng * Đơn giá). Dùng để so sánh với hạn mức phê duyệt. +supplier_id Many2one comodel='res.partner', domain=[('supplier_rank', '>', 0)] Nhà cung cấp đề xuất (tùy chọn). +purchase_line_ids Many2many comodel='purchase.order.line' Liên kết với các dòng PO đã tạo. Giúp truy vết trạng thái mua hàng. +request_state Selection related='request_id.state', store=True Trạng thái dòng, dùng để lọc trong các báo cáo chi tiết. +is_rfq_created Boolean compute Cờ đánh dấu dòng này đã được xử lý tạo RFQ hay chưa. + +Logic Tính toán Giá (Compute Method): + +@api.depends('quantity', 'estimated_cost') + def _compute_total(self): + for line in self: + line.total_cost = line.quantity * line.estimated_cost + + 4.3 Model Ma trận Phê duyệt (epr.approval.matrix) +Để đáp ứng yêu cầu về "sự tinh tế" và "chi tiết", chúng ta không thể sử dụng logic phê duyệt cứng nhắc. Model này cho phép định nghĩa các quy tắc linh hoạt. + +Tên Trường Mô tả +name Tên quy tắc (VD: Phòng IT - Trên 50 triệu). +department_ids Áp dụng cho danh sách phòng ban nào (Many2many). Để trống = Áp dụng tất cả. +min_amount Giá trị tối thiểu của tổng PR để kích hoạt quy tắc này. +max_amount Giá trị tối đa. +approver_type Loại người duyệt: manager (Quản lý trực tiếp), specific_user (Người cụ thể), role (Nhóm người dùng). +user_ids Danh sách người duyệt cụ thể (nếu loại là specific_user). +group_ids Nhóm người dùng (nếu loại là role). +sequence Thứ tự ưu tiên kiểm tra. + +V. Logic Nghiệp vụ và Luồng Quy trình (Business Logic & Workflows) + + 5.1 Thuật toán Phê duyệt (Approval Engine) + +Khi người dùng nhấn nút "Gửi duyệt" (action_submit), hệ thống sẽ thực hiện các bước sau: + + 1. Kiểm tra tính hợp lệ: Đảm bảo PR không rỗng (line_ids > 0). + + 2. Tính tổng giá trị: Cộng dồn total_cost của tất cả các dòng. + + 3. Quét Ma trận: Tìm kiếm các bản ghi trong epr.approval.matrix thỏa mãn điều kiện: + + . department_id của PR nằm trong department_ids của quy tắc (hoặc quy tắc áp dụng toàn cục). + + . min_amount <= Tổng giá trị PR <= max_amount. + + 4. Xác định Người duyệt: + + . Nếu quy tắc yêu cầu manager: Truy xuất employee_id.parent_id.user_id. Nếu không có quản lý, truy xuất department_id.manager_id. + + . Nếu quy tắc yêu cầu specific_user: Lấy danh sách user_ids. + + 5. Tạo Hoạt động (Activity): Sử dụng mail.activity.schedule để tạo task "To Do" cho người duyệt xác định được. + + 6. Cập nhật Trạng thái: Chuyển state sang to_approve. + +Snippet Logic Python (Sử dụng ORM Command): + +def action_submit(self): + self.ensure_one() + # Tìm quy tắc phù hợp + matrix_rules = self.env['epr.approval.matrix'].search([ + ('min_amount', '<=', self.total_amount), + ('max_amount', '>=', self.total_amount), + '|', ('department_ids', '=', False), ('department_ids', 'in', self.department_id.id) + ], order='sequence asc') + + if not matrix_rules: + # Nếu không có quy tắc nào khớp -> Tự động duyệt (hoặc báo lỗi tùy cấu hình) + self.state = 'approved' + return + + # Giả sử quy trình duyệt tuần tự theo sequence + next_approvers = self._get_approvers_from_rule(matrix_rules) + self.approver_ids = [Command.set(next_approvers.ids)] + self.state = 'to_approve' + + # Gửi thông báo + for user in next_approvers: + self.activity_schedule( + 'epr_management.mail_activity_data_epr_approval', + user_id=user.id, + note=_("Yêu cầu mua sắm %s cần bạn phê duyệt.") % self.name + ) + + 5.2 Wizard tạo RFQ Tự động (RFQ Generation) + +Sau khi PR được duyệt (state = approved), nhân viên mua hàng sẽ nhấn nút "Tạo RFQ". Hệ thống cần thông minh để gom nhóm các dòng sản phẩm. + +Thuật toán: + + 1. Gom nhóm (Grouping): Duyệt qua các dòng line_ids và nhóm chúng theo supplier_id. + + . Các dòng có cùng supplier_id sẽ vào cùng một RFQ. + + . Các dòng không có supplier_id sẽ được gom vào một RFQ nháp không có nhà cung cấp (hoặc tách riêng để xử lý thủ công). + + 2. Khởi tạo RFQ: + + . Tạo header purchase.order. + + . Tạo lines purchase.order.line sử dụng Command.create. + + 3. Liên kết ngược: Cập nhật trường purchase_line_ids trên epr.request.line để biết dòng này đã thuộc về PR nào. + +Sử dụng Command.create (Chuẩn Odoo 18): Odoo 18 loại bỏ dần cách viết cũ (0, 0, values). + +def action_create_rfqs(self): + self.ensure_one() + grouped_lines = {} + # Gom nhóm logic... + + for supplier, lines in grouped_lines.items(): + po_vals = { + 'partner_id': supplier.id, + 'origin': self.name, + 'date_order': fields.Datetime.now(), + 'order_line': [ + Command.create({ + 'product_id': line.product_id.id, + 'product_qty': line.quantity, + 'name': line.name, + 'price_unit': 0.0, # Để trống để lấy giá mặc định từ bảng giá + 'date_planned': self.date_required, + }) for line in lines + ] + } + po = self.env['purchase.order'].create(po_vals) + + # Link back + for line in lines: + # Tìm line tương ứng trong PO mới tạo để link + matching_po_line = po.order_line.filtered(lambda l: l.product_id == line.product_id) + line.purchase_line_ids = [Command.link(matching_po_line.id)] + + self.state = 'done' + + + 5.3 Quy trình Từ chối (Rejection Workflow) + +Khi từ chối, hệ thống bắt buộc người dùng nhập lý do. Điều này được thực hiện thông qua một TransientModel (Wizard). + + 1. Nút "Reject" trên PR gọi action mở Wizard epr.reject.reason. + + 2. Wizard có trường reason (Text, required). + + 3. Khi confirm Wizard: + + . Ghi lý do vào Chatter của PR (sử dụng message_post). + + . Chuyển trạng thái PR sang draft. + + . Gửi email thông báo lại cho người yêu cầu (employee_id). + +VI. Thiết kế Giao diện Người dùng (Views) + + 6.1 View Danh sách (List View) + + + epr.request.list + epr.request + + + + + + + + + + + + + +Phân tích: widget="many2one_avatar_user" hiển thị avatar người dùng, tăng tính thẩm mỹ ("vibe") cho giao diện. Thuộc tính sample="1" cho phép hiển thị dữ liệu mẫu khi view rỗng, giúp người dùng mới dễ hình dung. + + 6.2 View Biểu mẫu (Form View) và Chatter + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + 6.3 View Tìm kiếm và Search Panel (Mobile Optimized) + + + epr.request.search + epr.request + + + + + + + + + + + + + + + + + +VII. Bảo mật và Phân quyền (Security & Access Control) + + 7.1 Định nghĩa Nhóm (Groups) +Chúng ta sẽ tạo 3 nhóm quyền chính trong security/epr_security.xml: + + 1. ePR / User (Người dùng): Chỉ có quyền tạo và xem PR của chính mình. + + 2. ePR / Approver (Người duyệt): Có quyền xem và duyệt PR của các phòng ban mà mình quản lý. + + 3. ePR / Administrator (Quản trị): Có quyền cấu hình ma trận phê duyệt và can thiệp mọi PR. + + 7.2 Record Rules +Để đảm bảo tính riêng tư dữ liệu (Row-level security): + + - Rule User: [('employee_id.user_id', '=', user.id)] -> Chỉ thấy PR do mình tạo. + + - Rule Approver: + +['|', + ('employee_id.user_id', '=', user.id), + '|', + ('department_id.manager_id.user_id', '=', user.id), + ('approver_ids', 'in', [user.id]) +] + +-> Thấy PR của mình HOẶC PR của phòng mình quản lý HOẶC PR mà mình được chỉ định duyệt. + + 7.3 Danh sách Quyền Truy cập (ACL - CSV) +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_epr_request_user,epr.request.user,model_epr_request,group_epr_user,1,1,1,0 +access_epr_request_approver,epr.request.approver,model_epr_request,group_epr_approver,1,1,0,0 +access_epr_request_manager,epr.request.manager,model_epr_request,group_epr_manager,1,1,1,1 +access_epr_matrix_user,epr.matrix.user,model_epr_approval_matrix,group_epr_user,1,0,0,0 +access_epr_matrix_manager,epr.matrix.manager,model_epr_approval_matrix,group_epr_manager,1,1,1 + +VIII. Tích hợp với Module Kho (Inventory) + +Khi tạo RFQ từ PR, cần xác định chính xác picking_type_id (Loại giao nhận) trên PO. + + - Logic: PR sẽ có trường destination_warehouse_id. + + - Khi tạo PO, hệ thống sẽ tìm picking_type_id ứng với kho đó (thường là "Receipts" - Nhận hàng). + + - Điều này đảm bảo hàng về đúng kho yêu cầu, tránh việc hàng về kho tổng rồi phải điều chuyển nội bộ thủ công. \ No newline at end of file diff --git a/addons/epr/__init__.py b/addons/epr/__init__.py new file mode 100644 index 0000000..6ed2c21 --- /dev/null +++ b/addons/epr/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards \ No newline at end of file diff --git a/addons/epr/__manifest__.py b/addons/epr/__manifest__.py new file mode 100644 index 0000000..58be4a8 --- /dev/null +++ b/addons/epr/__manifest__.py @@ -0,0 +1,48 @@ +{ + 'name': 'Electronic Purchase Request (ePR) Enterprise', + 'version': '18.0.1.0.0', + 'category': 'Procurement/Inventory', + 'summary': 'Hệ thống quản lý yêu cầu mua sắm nội bộ với quy trình phê duyệt đa cấp động', + 'description': """ + Module ePR được thiết kế chuyên biệt cho Odoo 18. + Các tính năng cốt lõi: + - Tách biệt quy trình Yêu cầu (Internal) và Mua hàng (External). + - Cho phép gom nhiều PRs vào một RFQs và có thể truy xuất theo từng dòng trên PO. + - Ma trận phê duyệt động (Approval Matrix) dựa trên ngưỡng tiền tệ và phòng ban. + """, + 'author': 'Trường Phan', + 'depends': [ + 'base', + 'purchase', # Để kết nối với purchase.order [8] + 'hr', # Để lấy thông tin phòng ban và quản lý + 'product', # Quản lý danh mục sản phẩm + # 'stock', # Quản lý kho và địa điểm nhận hàng + 'mail', # Tích hợp Chatter và Activity + 'uom', # Đơn vị tính + # 'analytic', # Kế toán quản trị (nếu cần phân bổ chi phí) + ], + + 'data': [ + 'security/epr_security.xml', + 'security/ir.model.access.csv', + 'security/epr_record_rules.xml', + 'data/epr_pr_sequence_data.xml', + 'data/epr_rfq_sequence_data.xml', + 'views/epr_purchase_request_views.xml', + 'views/epr_rfq_views.xml', + 'views/epr_approval_views.xml', + 'views/epr_po_views.xml', + 'views/epr_menus.xml', + 'wizards/epr_reject_wizard_views.xml', + 'wizards/epr_create_rfq_views.xml', + 'wizards/epr_create_po_views.xml', + ], + 'assets': { + # 'web.assets_backend': [ + # 'epr_management/static/src/scss/epr_status_widget.scss', + # ], + }, + 'installable': True, + 'application': True, + 'license': 'OEEL-1', +} diff --git a/addons/epr/__pycache__/__init__.cpython-312.pyc b/addons/epr/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..12890c6 Binary files /dev/null and b/addons/epr/__pycache__/__init__.cpython-312.pyc differ diff --git a/addons/epr/data/epr_pr_sequence_data.xml b/addons/epr/data/epr_pr_sequence_data.xml new file mode 100644 index 0000000..52dd77c --- /dev/null +++ b/addons/epr/data/epr_pr_sequence_data.xml @@ -0,0 +1,29 @@ + + + + + + + Purchase Request + + epr.purchase.request + + + PR/%(year)s/ + + + 5 + + + 1 + 1 + + + + + + + diff --git a/addons/epr/data/epr_rfq_sequence_data.xml b/addons/epr/data/epr_rfq_sequence_data.xml new file mode 100644 index 0000000..2e0a695 --- /dev/null +++ b/addons/epr/data/epr_rfq_sequence_data.xml @@ -0,0 +1,39 @@ + + + + + + + ePR Request for Quotation + + epr.rfq + + + RFQ/%(year)s/ + + + 5 + + + 1 + 1 + + + + + + standard + + + + \ No newline at end of file diff --git a/addons/epr/data/import_partners.csv b/addons/epr/data/import_partners.csv new file mode 100644 index 0000000..b240352 --- /dev/null +++ b/addons/epr/data/import_partners.csv @@ -0,0 +1,11 @@ +id,name,email,phone,is_company,supplier_rank +partner_vendor_1,CÔNG TY TNHH THIẾT BỊ VĂN PHÒNG ABC,contact@abc-office.vn,028 1234 5678,True,1 +partner_vendor_2,CÔNG TY CP CÔNG NGHỆ DELTA,sales@deltatech.vn,024 9876 5432,True,1 +partner_vendor_3,NHÀ PHÂN PHỐI PHẦN MỀM SIGMA,info@sigmasoft.vn,028 5555 6666,True,1 +partner_vendor_4,CÔNG TY VẬT TƯ XÂY DỰNG OMEGA,order@omega-building.vn,028 7777 8888,True,1 +partner_vendor_5,ĐẠI LÝ VĂN PHÒNG PHẨM BETA,sales@beta-stationery.vn,028 2222 3333,True,1 +partner_vendor_6,CÔNG TY TNHH ĐIỆN TỬ GAMMA,contact@gamma-electronics.vn,024 4444 5555,True,1 +partner_vendor_7,NHÀ CUNG CẤP NỘI THẤT EPSILON,info@epsilon-furniture.vn,028 6666 7777,True,1 +partner_vendor_8,CÔNG TY DỊCH VỤ IT ZETA,support@zeta-it.vn,1900 1234,True,1 +partner_vendor_9,ĐẠI LÝ PHÂN PHỐI LAMBDA,order@lambda-dist.vn,028 8888 9999,True,1 +partner_vendor_10,CÔNG TY THƯƠNG MẠI THETA,sales@theta-trading.vn,024 1111 2222,True,1 diff --git a/addons/epr/data/import_purchase_request_lines.csv b/addons/epr/data/import_purchase_request_lines.csv new file mode 100644 index 0000000..79c6b45 --- /dev/null +++ b/addons/epr/data/import_purchase_request_lines.csv @@ -0,0 +1,21 @@ +id,request_id/id,name,product_description,quantity,estimated_price,uom_name,suggested_vendor_name +pr_line_1,pr_demo_1,Máy in HP LaserJet Pro M404dn,Máy in laser đen trắng - in 2 mặt tự động - kết nối mạng LAN,2,8500000,Cái,CÔNG TY TNHH THIẾT BỊ VĂN PHÒNG ABC +pr_line_2,pr_demo_2,Laptop Dell Latitude 5540,Laptop văn phòng - Intel Core i5 - RAM 16GB - SSD 512GB,5,25000000,Bộ,CÔNG TY CP CÔNG NGHỆ DELTA +pr_line_3,pr_demo_3,Bàn làm việc 1m4,Bàn làm việc chữ L - kích thước 1m4 x 0.6m - màu gỗ sồi,10,3500000,Cái,NHÀ CUNG CẤP NỘI THẤT EPSILON +pr_line_4,pr_demo_4,Ghế công thái học Ergohuman,Ghế văn phòng cao cấp - hỗ trợ lưng - tay vịn điều chỉnh,5,12000000,Cái,NHÀ CUNG CẤP NỘI THẤT EPSILON +pr_line_5,pr_demo_5,Máy chiếu Epson EB-X51,Máy chiếu 3800 lumens - độ phân giải XGA - kết nối HDMI,2,15000000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA +pr_line_6,pr_demo_6,Microsoft 365 Business Premium (1 năm),Bản quyền phần mềm Office 365 - bao gồm Teams - OneDrive 1TB,50,3000000,License,NHÀ PHÂN PHỐI PHẦN MỀM SIGMA +pr_line_7,pr_demo_7,Ổ cứng SSD Samsung 870 EVO 1TB,Ổ cứng SSD SATA 2.5 inch - tốc độ đọc 560MB/s,10,2500000,Cái,CÔNG TY CP CÔNG NGHỆ DELTA +pr_line_8,pr_demo_8,Dịch vụ bảo trì Server (6 tháng),Gói bảo trì hệ thống server - bao gồm backup và monitoring 24/7,1,50000000,Gói,CÔNG TY DỊCH VỤ IT ZETA +pr_line_9,pr_demo_9,Router Wifi TP-Link Archer AX73,Router Wifi 6 - băng tần kép - tốc độ 5400Mbps,5,3200000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA +pr_line_10,pr_demo_10,Webcam Logitech C920 HD Pro,Webcam Full HD 1080p - tự động lấy nét - micro kép,10,2800000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA +pr_line_11,pr_demo_11,Giấy A4 Double A 80gsm,Giấy in A4 chất lượng cao - độ trắng 96%,100,85000,Ream,ĐẠI LÝ VĂN PHÒNG PHẨM BETA +pr_line_12,pr_demo_12,Bút bi Thiên Long TL-027,Bút bi mực xanh - nét thanh đẹp,200,5000,Cây,ĐẠI LÝ VĂN PHÒNG PHẨM BETA +pr_line_13,pr_demo_13,Kẹp giấy loại lớn,Kẹp giấy kim loại 51mm - hộp 12 cái,50,25000,Hộp,ĐẠI LÝ VĂN PHÒNG PHẨM BETA +pr_line_14,pr_demo_14,Sổ tay bìa cứng A5,Sổ tay bìa da cao cấp - 200 trang giấy kẻ ngang,30,65000,Quyển,ĐẠI LÝ VĂN PHÒNG PHẨM BETA +pr_line_15,pr_demo_15,Mực in HP 76A Black,Hộp mực chính hãng cho máy in HP LaserJet Pro M404,10,2200000,Hộp,CÔNG TY TNHH THIẾT BỊ VĂN PHÒNG ABC +pr_line_16,pr_demo_16,Điều hòa Daikin Inverter 1HP,Điều hòa tiết kiệm điện - công suất 9000BTU,3,12000000,Bộ,CÔNG TY TNHH ĐIỆN TỬ GAMMA +pr_line_17,pr_demo_17,Bình nước nóng Ariston 30L,Bình nước nóng điện - dung tích 30 lít - có chống giật,2,4500000,Cái,CÔNG TY VẬT TƯ XÂY DỰNG OMEGA +pr_line_18,pr_demo_18,Camera IP Hikvision DS-2CD1047G2,Camera IP 4MP - hồng ngoại 30m - chuẩn IP67,8,1800000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA +pr_line_19,pr_demo_19,Tủ hồ sơ sắt 3 ngăn,Tủ hồ sơ kim loại - có khóa - kích thước 1.2m x 0.5m x 0.4m,5,2800000,Cái,NHÀ CUNG CẤP NỘI THẤT EPSILON +pr_line_20,pr_demo_20,Nước uống đóng chai Aquafina 500ml,Nước tinh khiết chai 500ml - thùng 24 chai,50,95000,Thùng,CÔNG TY THƯƠNG MẠI THETA diff --git a/addons/epr/data/import_purchase_requests.csv b/addons/epr/data/import_purchase_requests.csv new file mode 100644 index 0000000..4f52d9a --- /dev/null +++ b/addons/epr/data/import_purchase_requests.csv @@ -0,0 +1,21 @@ +id,date_required,priority,state +pr_demo_1,2024-12-23,2,draft +pr_demo_2,2024-12-21,3,draft +pr_demo_3,2024-12-26,1,draft +pr_demo_4,2024-12-19,4,draft +pr_demo_5,2024-12-30,2,draft +pr_demo_6,2025-01-15,2,draft +pr_demo_7,2024-12-23,3,draft +pr_demo_8,2024-12-21,4,draft +pr_demo_9,2025-01-06,1,draft +pr_demo_10,2024-12-26,2,draft +pr_demo_11,2024-12-19,1,draft +pr_demo_12,2024-12-21,1,draft +pr_demo_13,2024-12-23,2,draft +pr_demo_14,2024-12-26,1,draft +pr_demo_15,2024-12-21,2,draft +pr_demo_16,2024-12-30,3,draft +pr_demo_17,2025-01-06,2,draft +pr_demo_18,2024-12-23,4,draft +pr_demo_19,2024-12-26,2,draft +pr_demo_20,2025-01-15,1,draft diff --git a/addons/epr/models/__init__.py b/addons/epr/models/__init__.py new file mode 100644 index 0000000..16e1afe --- /dev/null +++ b/addons/epr/models/__init__.py @@ -0,0 +1,5 @@ +from . import epr_purchase_request +from . import epr_rfq +from . import epr_approval_rule +from . import epr_approval_entry +from . import epr_po \ No newline at end of file diff --git a/addons/epr/models/__pycache__/__init__.cpython-312.pyc b/addons/epr/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c943052 Binary files /dev/null and b/addons/epr/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_approval.cpython-312.pyc b/addons/epr/models/__pycache__/epr_approval.cpython-312.pyc new file mode 100644 index 0000000..ce411ff Binary files /dev/null and b/addons/epr/models/__pycache__/epr_approval.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_approval_entry.cpython-312.pyc b/addons/epr/models/__pycache__/epr_approval_entry.cpython-312.pyc new file mode 100644 index 0000000..ec5d722 Binary files /dev/null and b/addons/epr/models/__pycache__/epr_approval_entry.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_approval_rule.cpython-312.pyc b/addons/epr/models/__pycache__/epr_approval_rule.cpython-312.pyc new file mode 100644 index 0000000..e17313d Binary files /dev/null and b/addons/epr/models/__pycache__/epr_approval_rule.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_po.cpython-312.pyc b/addons/epr/models/__pycache__/epr_po.cpython-312.pyc new file mode 100644 index 0000000..da691c1 Binary files /dev/null and b/addons/epr/models/__pycache__/epr_po.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc b/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc new file mode 100644 index 0000000..156411f Binary files /dev/null and b/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc b/addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc new file mode 100644 index 0000000..5776652 Binary files /dev/null and b/addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc differ diff --git a/addons/epr/models/epr_approval_entry.py b/addons/epr/models/epr_approval_entry.py new file mode 100644 index 0000000..f3bcee0 --- /dev/null +++ b/addons/epr/models/epr_approval_entry.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class EprApprovalEntry(models.Model): + _name = 'epr.approval.entry' + _description = 'Approval Request Entry' + _order = 'sequence, id' + + # Link về RFQ + rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='RFQ Reference', + ondelete='cascade', + required=True + ) + + currency_id = fields.Many2one( + related='rfq_id.currency_id', + string='Currency', + readonly=True + ) + amount_total = fields.Monetary( + related='rfq_id.amount_total', + string='Total Amount', + currency_field='currency_id', + readonly=True + ) + # Thông tin snapshot từ Rule (để truy vết nếu rule gốc bị sửa) + rule_line_id = fields.Many2one( + comodel_name='epr.approval.rule.line', + string='Original Rule Line' + ) + + name = fields.Char( + string='Step Name', + required=True + ) + + sequence = fields.Integer( + string='Step Sequence', + required=True + ) + + # Trạng thái của bước này + status = fields.Selection( + selection=[ + ('new', 'To Approve'), + ('pending', 'Pending Previous Step'), # Chờ bước trước + ('approved', 'Approved'), + ('refused', 'Refused') + ], + string='Status', + default='new', + required=True, + readonly=True + ) + + # Ai cần duyệt (Copy từ Rule sang) + required_user_ids = fields.Many2many( + comodel_name='res.users', + string='Required Approvers', + readonly=True + ) + + # Ai đã duyệt thực tế (Audit Log) + actual_user_id = fields.Many2one( + comodel_name='res.users', + string='Approved By', + readonly=True + ) + + approval_date = fields.Datetime( + string='Date', + readonly=True + ) + + rejection_reason = fields.Text( + string='Reason' + ) + + # Logic UI: Cho phép nút Duyệt hiện hay ẩn + can_approve = fields.Boolean( + compute='_compute_can_approve' + ) + + @api.depends('status', 'required_user_ids') + @api.depends_context('uid') + def _compute_can_approve(self): + current_user = self.env.user + for entry in self: + # 1. Phải ở trạng thái 'new' + # 2. User hiện tại phải nằm trong danh sách được phép + if entry.status == 'new' and current_user in entry.required_user_ids: + entry.can_approve = True + else: + entry.can_approve = False + + # ========================================================================= + # ACTIONS + # ========================================================================= + def action_approve_line(self): + """User bấm nút Approve trên dòng""" + self.ensure_one() + if not self.can_approve: + raise UserError(_("You are not authorized to approve this step or it is not ready.")) + + self.write({ + 'status': 'approved', + 'actual_user_id': self.env.user.id, + 'approval_date': fields.Datetime.now() + }) + + # Trigger kiểm tra xem RFQ đã được duyệt hoàn toàn chưa + self.rfq_id._check_approval_completion() + + def action_refuse_line(self): + """User bấm nút Refuse""" + # return { + # 'type': 'ir.actions.act_window', + # 'name': _('Refuse Reason'), + # 'res_model': 'epr.approval.refuse.wizard', # Viết sau + # 'target': 'new', + # 'context': {'default_entry_id': self.id} + # } + self.write({ + 'status': 'refused' + }) diff --git a/addons/epr/models/epr_approval_rule.py b/addons/epr/models/epr_approval_rule.py new file mode 100644 index 0000000..4c1891c --- /dev/null +++ b/addons/epr/models/epr_approval_rule.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + + +class EprApprovalRule(models.Model): + _name = 'epr.approval.rule' + _description = 'Approval Rule Header' + _inherit = ['mail.thread'] + _order = 'sequence, id' + + name = fields.Char( + string='Rule Name', + required=True, + tracking=True + ) + + active = fields.Boolean( + string='Active', + default=True + ) + + company_id = fields.Many2one( + comodel_name='res.company', + string='Company', + default=lambda self: self.env.company + ) + + # Thứ tự ưu tiên của Rule (nếu có nhiều rule khớp điều kiện) + sequence = fields.Integer( + string='Sequence', + default=10 + ) + + # Điều kiện áp dụng Rule (VD: Áp dụng cho phòng ban nào?) + department_id = fields.Many2one( + comodel_name='hr.department', + string='Apply for Department' + ) + + # Chi tiết các bước duyệt + line_ids = fields.One2many( + comodel_name='epr.approval.rule.line', + inverse_name='rule_id', + string='Approval Steps' + ) + + +class EprApprovalRuleLine(models.Model): + _name = 'epr.approval.rule.line' + _description = 'Approval Rule Steps' + _order = 'sequence, id' + + rule_id = fields.Many2one( + comodel_name='epr.approval.rule', + ondelete='cascade' + ) + + sequence = fields.Integer( + string='Step', + default=1 + ) + + # Tên bước (VD: Quản lý duyệt, Giám đốc duyệt) + name = fields.Char( + string='Step Name', + required=True + ) + + # Điều kiện kích hoạt bước này + min_amount = fields.Monetary( + string='Minimum Amount', + currency_field='currency_id' + ) + + currency_id = fields.Many2one( + related='rule_id.company_id.currency_id' + ) + + # Ai được duyệt bước này? + user_ids = fields.Many2many( + 'res.users', + string='Approvers', + required=True + ) + + # Loại duyệt: 1 người bất kỳ trong list hay bắt buộc tất cả? + approval_type = fields.Selection([ + ('any', 'Any User'), + ('all', 'All Users') + ], string='Approval Type', default='any', required=True) diff --git a/addons/epr/models/epr_po.py b/addons/epr/models/epr_po.py new file mode 100644 index 0000000..d08846e --- /dev/null +++ b/addons/epr/models/epr_po.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + # === HEADER-LEVEL LINKING === + epr_source_rfq_ids = fields.Many2many( + comodel_name='epr.rfq', + relation='epr_rfq_purchase_order_rel', # Tên bảng trung gian rõ ràng + column1='purchase_id', + column2='epr_rfq_id', + string='Source ePR RFQs', + copy=False, + readonly=True, + help="Danh sách các phiếu yêu cầu báo giá (EPR RFQ) nguồn tạo nên PO này." + ) + + # === COMPUTED FIELDS CHO SMART BUTTON (Line-Level Linking) === + epr_rfq_count = fields.Integer( + string='RFQ Count', + compute='_compute_epr_rfq_count' + ) + + @api.depends('epr_source_rfq_ids') + def _compute_epr_rfq_count(self): + for po in self: + # Đếm trực tiếp từ field Many2many đã lưu trữ + po.epr_rfq_count = len(po.epr_source_rfq_ids) + + # === ACTION SMART BUTTON === + def action_view_epr_rfqs(self): + """Mở danh sách các EPR RFQ nguồn""" + self.ensure_one() + rfq_ids = self.epr_source_rfq_ids.ids + + # Nếu chỉ có 1 RFQ nguồn, mở form view trực tiếp cho tiện + if len(rfq_ids) == 1: + return { + 'type': 'ir.actions.act_window', + 'res_model': 'epr.rfq', + 'view_mode': 'form', + 'res_id': rfq_ids[0], + 'target': 'current', + } + + # Nếu có nhiều RFQs nguồn, mở list view + return { + 'name': _('Source RFQs'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.rfq', + 'view_mode': 'list,form', + 'domain': [('id', 'in', rfq_ids)], + 'context': {'create': False}, + } + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + # === FIELD LIÊN KẾT QUAN TRỌNG NHẤT === + epr_rfq_line_id = fields.Many2one( + comodel_name='epr.rfq.line', + string='EPR RFQ Line Ref', + readonly=True, + copy=False, + ondelete='set null', + help="Dòng chi tiết tương ứng trên phiếu yêu cầu báo giá." + ) diff --git a/addons/epr/models/epr_purchase_request.py b/addons/epr/models/epr_purchase_request.py new file mode 100644 index 0000000..40490d2 --- /dev/null +++ b/addons/epr/models/epr_purchase_request.py @@ -0,0 +1,574 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError + + +class EprPurchaseRequest(models.Model): + _name = 'epr.purchase.request' + _description = 'Electronic Purchase Request' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + name = fields.Char( + string='Request Reference', + required=True, + readonly=True, + copy=False, + default=lambda self: _('New') + ) + + active = fields.Boolean( + string='Active', + default=True + ) + + employee_id = fields.Many2one( + comodel_name='hr.employee', + string='Employee', + required=True, + tracking=True, + default=lambda self: self.env.user.employee_id + ) + + department_id = fields.Many2one( + comodel_name='hr.department', + string='Department', + related='employee_id.department_id', + store=True, + readonly=True + ) + + # Xác định người tạo PR + is_owner = fields.Boolean( + compute='_compute_is_owner', + store=False + ) + + date_required = fields.Date( + string='Date Required', + required=True, + tracking=True, + default=fields.Date.context_today + ) + priority = fields.Selection( + [('1', 'Low'), ('2', 'Medium'), ('3', 'High'), ('4', 'Very High')], + string='Priority', + default='1', + tracking=True + ) + state = fields.Selection( + [ + ('draft', 'Draft'), + ('to_approve', 'To Approve'), + ('approved', 'Approved'), + ('in_progress', 'In Progress'), + ('done', 'Done'), + ('rejected', 'Rejected'), + ('cancel', 'Cancelled') + ], + string='Status', + default='draft', + tracking=True, + index=True, + copy=False, + group_expand='_expand_groups' + ) + + # Approvers: Nên là field thường (không compute) để lưu cố định người duyệt lúc Submit + approver_ids = fields.Many2many( + comodel_name='res.users', + string='Approvers', + copy=False + ) + + rejection_reason = fields.Text( + string='Rejection Reason', + readonly=True, + tracking=True + ) + + currency_id = fields.Many2one( + comodel_name='res.currency', + string='Currency', + default=lambda self: self.env.company.currency_id, + required=True + ) + + line_ids = fields.One2many( + comodel_name='epr.purchase.request.line', + inverse_name='request_id', + string='Products' + ) + + estimated_total = fields.Monetary( + string='Estimated Total', + compute='_compute_estimated_total', + store=True, + currency_field='currency_id' + ) + + # Link sang RFQs + rfq_ids = fields.Many2many( + comodel_name='epr.rfq', + relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian + column1='request_id', + column2='rfq_id', + string='RFQs', + readonly=True + ) + + # Số lượng RFQ + rfq_count = fields.Integer( + compute='_compute_rfq_count', + string='RFQ Count' + ) + + # ========================================================================== + # LOG FIELDS + # ========================================================================== + date_approved = fields.Datetime( + string='Approved Date', + readonly=True, + copy=False, + help="Ngày được phê duyệt." + ) + + approved_by_id = fields.Many2one( + comodel_name='res.users', + string='Approved By', + readonly=True, + copy=False, + help="Người đã phê duyệt." + ) + + date_rejected = fields.Datetime( + string='Rejected Date', + readonly=True, + copy=False, + help="Ngày bị từ chối." + ) + + rejected_by_id = fields.Many2one( + comodel_name='res.users', + string='Rejected By', + readonly=True, + copy=False, + help="Người đã từ chối." + ) + + date_submitted = fields.Datetime( + string='Submitted Date', + readonly=True, + copy=False + ) + + # ========================================================================== + # MODEL METHODS + # ========================================================================== + # Hàm tạo sequence cho Request Reference + @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('epr.purchase.request') or _('New') + return super().create(vals_list) + + # --- Kanban Grouping (Để Kanban hiển thị đủ cột Draft/Done dù không có data) --- + @api.model + def _expand_groups(self, states, domain, order=None): + """Force display all state columns in Kanban, even if empty""" + return ['draft', 'to_approve', 'approved', 'rejected', 'in_progress', 'done', 'cancel'] + + # Compute estimated total + @api.depends('line_ids.subtotal_estimated', 'currency_id') + def _compute_estimated_total(self): + """Tính tổng tiền dự kiến từ các dòng chi tiết""" + for request in self: + # Sử dụng sum() trên danh sách mapped là đúng, nhưng cần cẩn thận nếu line khác tiền tệ + # Ở đây giả định line dùng chung currency với header + total = sum(line.subtotal_estimated for line in request.line_ids) + request.estimated_total = total + + # Xác định người tạo PR + @api.depends_context('uid') + def _compute_is_owner(self): + for record in self: + record.is_owner = record.employee_id.user_id.id == self.env.uid + + # Compute approvers + # @api.depends('employee_id', 'department_id', 'estimated_total') + # def _compute_approvers(self): + # # Placeholder for approval matrix logic + # for request in self: + # # For now, set line manager as approver + # if request.employee_id and request.employee_id.parent_id: + # request.approver_ids = [ + # (6, 0, [request.employee_id.parent_id.user_id.id]) + # ] + # else: + # request.approver_ids = False + + # Compute RFQ count + @api.depends('rfq_ids') + def _compute_rfq_count(self): + for record in self: + record.rfq_count = len(record.rfq_ids) + + # ========================================================================== + # HELPER METHODS (Tách logic tìm người duyệt ra riêng) + # ========================================================================== + + def _get_applicable_approvers(self): + """ + Hàm logic xác định ai là người duyệt. + Tách ra để dễ tái sử dụng hoặc override sau này + (ví dụ thêm Matrix duyệt theo số tiền). + """ + self.ensure_one() + approvers = self.env['res.users'] + + # Logic 1: Line Manager (Trưởng bộ phận) + if self.employee_id and self.employee_id.parent_id and self.employee_id.parent_id.user_id: + approvers |= self.employee_id.parent_id.user_id + + # Logic 2 (Ví dụ): Nếu tiền > 50tr thì cần thêm Giám đốc (Code ví dụ) + # if self.estimated_total > 50000000: + # director = ... + # approvers |= director + + return approvers + + # ========================================================================== + # BUSINESS ACTIONS + # ========================================================================== + + # Submit PR for approval + def action_submit(self): + """Submit PR for approval""" + self.ensure_one() + + # 1. Validate dữ liệu đầu vào + if not self.line_ids: + raise ValidationError(_('Cannot submit an empty purchase request. Please add at least one product line.')) + + # 2. Tính toán và Gán người duyệt (Freeze approvers list) + # Thay vì dùng compute field, ta gán trực tiếp lúc submit để "chốt" người duyệt + required_approvers = self._get_applicable_approvers() + + if not required_approvers: + raise ValidationError(_('No approver found (Line Manager). Please contact HR or Admin to update your employee profile.')) + + self.write({ + 'approver_ids': [(6, 0, required_approvers.ids)], + 'state': 'to_approve', + 'date_submitted': fields.Datetime.now() + }) + + # # 3. Tạo Activity (To-Do) cho người duyệt để họ nhận thông báo + # NOTE: Commented out for testing without mail server + # for approver in self.approver_ids: + # self.activity_schedule( + # 'mail.mail_activity_data_todo', + # user_id=approver.id, + # summary=_('Purchase Request Approval Required'), + # note=_( + # 'Purchase Request %s requires your approval.\n' + # 'Total Amount: %s %s' + # ) % ( + # self.name, + # self.estimated_total, + # self.currency_id.symbol + # ) + # ) + + # Post message to chatter + # self.message_post( + # body=_('Purchase Request submitted for approval to: %s') % ( + # ', '.join(self.approver_ids.mapped('name')) + # ) + # ) + + # Approver action: Approve PR + def action_approve(self): + """Approve PR""" + self.ensure_one() + + # 1. Check quyền: User hiện tại có nằm trong danh sách được duyệt không? + # Cho phép Administrator bypass + if not self.env.is_superuser() and self.env.user not in self.approver_ids: + raise UserError(_('You are not authorized to approve this request.')) + + # 2. Cập nhật trạng thái + self.write({ + 'state': 'approved', + # Lưu lại ngày và người duyệt để audit sau này + 'date_approved': fields.Datetime.now(), + 'approved_by_id': self.env.user.id + }) + + # Hàm mở Wizard + def action_reject_wizard(self): + """Open rejection wizard""" + self.ensure_one() + return { + 'name': _('Reject Purchase Request'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.reject.wizard', + 'view_mode': 'form', + 'target': 'new', + # Truyền ID hiện tại vào context để wizard tự động nhận diện + 'context': {'default_request_id': self.id} + } + + # Approver action: Reject PR + def action_reject(self, reason): + """ + Reject PR with reason (called from wizard). + Chuyển trạng thái sang 'draft' và ghi log. + """ + self.ensure_one() + + # Check quyền: User hiện tại có nằm trong danh sách được duyệt không? + # Lưu ý: Nên cho phép cả Administrator bypass check này để xử lý sự cố + if not self.env.is_superuser() and self.env.user not in self.approver_ids: + raise UserError( + _('You are not authorized to reject this request.') + ) + + # Thực hiện ghi dữ liệu + self.write({ + 'state': 'rejected', + 'rejection_reason': reason, + 'approver_ids': [(5, 0, 0)], # QUAN TRỌNG: Xóa sạch người duyệt để clear danh sách chờ + # LOG: Ghi nhận thông tin từ chối + 'date_rejected': fields.Datetime.now(), + 'rejected_by_id': self.env.user.id, + }) + + # User action: Reset to draft when accidentally submitted + def action_reset_to_draft(self): + """ + Reset PR back to draft state. + Cho phép User sửa lại phiếu khi submit nhầm hoặc sau khi bị reject. + Chỉ owner mới được reset (không phải Manager). + """ + self.ensure_one() + + # Validation 1: Check state + if self.state not in ['to_approve', 'rejected']: + raise UserError(_( + 'You can only reset PR when it is in "To Approve" ' + 'or "Rejected" state.' + )) + + # Validation 2: Check permission - Only owner can reset + # Admin có thể bypass + if (not self.env.is_superuser() and + self.employee_id.user_id != self.env.user): + raise UserError(_( + 'Only the requester (%s) can reset this PR to draft.' + ) % self.employee_id.name) + + # Reset state và clear data + self.write({ + 'state': 'draft', + 'approver_ids': [(5, 0, 0)], # Clear approvers list + 'rejection_reason': False, # Clear rejection reason + 'date_submitted': False, # Clear submission date + }) + + # Post message for audit trail + # NOTE: Commented out for testing without mail server + # self.message_post( + # body=_( + # 'Purchase Request reset to Draft by %s for editing.' + # ) % self.env.user.name + # ) + + # Cancel pending activities + # NOTE: Commented out for testing without mail server + # self.activity_ids.unlink() + + # === ACTION SMART BUTTON === + def action_view_rfqs(self): + """Mở danh sách các RFQ liên quan đến PR này""" + self.ensure_one() + return { + 'name': _('Request for Quotations'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.rfq', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.rfq_ids.ids)], + 'context': { + 'default_request_ids': [(6, 0, [self.id])], # Tự động link ngược lại PR này nếu tạo mới RFQ + 'create': True, + }, + } + +# ============================================================================== +# CLASS CON: epr.purchase.request.line (Chi tiết hàng hóa trong PR) +# ============================================================================== + + +class EprPurchaseRequestLine(models.Model): + """ + Chi tiết dòng yêu cầu mua sắm. + Thiết kế theo hướng Free-text để thuận tiện cho người yêu cầu (Staff). + """ + _name = 'epr.purchase.request.line' + _description = 'Chi tiết yêu cầu mua sắm' + + # ========================================================================== + # RELATIONAL FIELDS + # ========================================================================== + + request_id = fields.Many2one( + comodel_name='epr.purchase.request', + string='Purchase Request', + required=True, + ondelete='cascade', # Xóa PR cha sẽ xóa luôn các dòng con + index=True, + help="Liên kết đến phiếu yêu cầu mua sắm gốc." + ) + + # Lấy tiền tệ từ phiếu cha để tính toán giá trị. + # store=True để hỗ trợ tìm kiếm và báo cáo nhanh hơn. + currency_id = fields.Many2one( + related='request_id.currency_id', + string='Currency', + store=True, + readonly=True, + help="Tiền tệ được kế thừa từ phiếu yêu cầu chính." + ) + + # Trường này để Purchasing Staff map sau khi duyệt. Staff không cần thấy. + product_id = fields.Many2one( + comodel_name='product.product', + string='Product', + domain=[('purchase_ok', '=', True)], # Chỉ chọn các sản phẩm được phép mua + help="Trường dành cho bộ phận thu mua map với kho." + ) + + # product_uom_id = fields.Many2one( + # comodel_name='uom.uom', + # string='Unit of Measure', + # domain="[('category_id', '=', product_uom_category_id)]", + # help="Đơn vị tính của sản phẩm. Tự động điền nếu chọn sản phẩm." + # ) + + # Đơn vị tính dạng text tự nhập (VD: Cái, Hộp, Giờ, Lô...) + # Tránh bắt Staff phải chọn UoM phức tạp của hệ thống + uom_name = fields.Char( + string='Unit of Measure', + default='Unit', + required=True + ) + + # ========================================================================== + # DATA FIELDS + # ========================================================================== + + # Dùng trường này làm tên hiển thị (rec_name) + # Staff sẽ nhập ngắn gọn: VD "Laptop Dell XPS 13" + name = fields.Char( + string='Product Name', + required=True, + help="Nhập tên ngắn gọn của hàng hóa cần mua." + ) + + # Dùng HTML để Staff có thể copy/paste hình ảnh, link, format màu sắc từ web + product_description = fields.Html( + string='Product Description', + required=True, + help="Mô tả chi tiết, có thể dán link, hình ảnh minh họa, thông số kỹ thuật..." + ) + + # === 1. USER INPUT FIELDS === + # User chọn từ danh bạ (Không cho tạo mới ở View) + user_vendor_id = fields.Many2one( + comodel_name='res.partner', + string='Approved Vendor', + domain=lambda self: [ + ('supplier_rank', '>', 0), + '|', ('company_id', '=', False), + ('company_id', '=', self.env.company.id) + ], + help="Chọn nhà cung cấp có sẵn trong hệ thống." + ) + + # User nhập text tự do (Dùng khi không tìm thấy hoặc đề xuất mới) + suggested_vendor_name = fields.Char( + string='Suggested Vendor Name', + help="Tên nhà cung cấp được đề xuất bởi người yêu cầu (tham khảo)." + ) + + # === 2. PURCHASING ONLY FIELDS === + # Purchasing chốt Vendor cuối cùng để làm RFQ + final_vendor_id = fields.Many2one( + comodel_name='res.partner', + string='Final Vendor', + # domain="[('supplier_rank', '>', 0)]", + help="Nhà cung cấp chính thức được bộ phận Mua hàng chốt." + ) + + quantity = fields.Float( + string='Quantity', + default=1.0, + digits='Product Unit of Measure', # Sử dụng độ chính xác cấu hình trong hệ thống + required=True, + help="Số lượng cần mua." + ) + + estimated_price = fields.Monetary( + string='Estimated Unit Price', + currency_field='currency_id', + help="Đơn giá ước tính tại thời điểm yêu cầu." + ) + + subtotal_estimated = fields.Monetary( + string='Estimated Subtotal', + compute='_compute_subtotal_estimated', + store=True, + currency_field='currency_id', + help="Tổng tiền ước tính (Số lượng * Đơn giá)." + ) + + # ========================================================================== + # COMPUTE FIELDS + # ========================================================================== + + @api.depends('quantity', 'estimated_price') + def _compute_subtotal_estimated(self): + for line in self: + # Đảm bảo giá trị là số (0.0) nếu người dùng chưa nhập + qty = line.quantity or 0.0 + price = line.estimated_price or 0.0 + line.subtotal_estimated = qty * price + + # ========================================================================== + # ONCHANGE FIELDS + # ========================================================================== + @api.onchange('user_vendor_id') + def _onchange_user_vendor_id(self): + """ + UX Logic: + 1. Nếu User chọn Vendor ID -> Tự động điền text & set Final Vendor. + 2. Nếu User bỏ chọn Vendor ID -> Xóa Final Vendor để Purchasing xử lý lại. + """ + if self.user_vendor_id: + # User đã chọn vendor từ danh bạ + self.suggested_vendor_name = self.user_vendor_id.name + self.final_vendor_id = self.user_vendor_id + else: + # User bỏ chọn vendor + self.final_vendor_id = False + # suggested_vendor_name giữ nguyên để User có thể nhập thủ công + + @api.constrains('user_vendor_id', 'suggested_vendor_name') + def _check_vendor_presence(self): + """ + Data Integrity: Bắt buộc phải có ít nhất 1 thông tin về nhà cung cấp. + """ + for line in self: + if not line.user_vendor_id and not line.suggested_vendor_name: + raise ValidationError(_("Dòng sản phẩm '%s': Vui lòng chọn Nhà cung cấp hoặc nhập tên đề xuất.", line.name)) diff --git a/addons/epr/models/epr_rfq.py b/addons/epr/models/epr_rfq.py new file mode 100644 index 0000000..ce1a8d8 --- /dev/null +++ b/addons/epr/models/epr_rfq.py @@ -0,0 +1,546 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, Command, _ +from odoo.exceptions import UserError, ValidationError + + +class EprRfq(models.Model): + _name = 'epr.rfq' + _description = 'EPR Request for Quotation' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + # === 1. IDENTIFICATION === + name = fields.Char( + string='Reference', + required=True, + copy=False, + readonly=True, + default=lambda self: _('New') + ) + + active = fields.Boolean(default=True) + + state = fields.Selection( + selection=[ + ('draft', 'Draft'), + ('sent', 'Sent'), + ('received', 'Received'), # Nhà cung cấp đã báo giá + ('to_approve', 'To Approve'), # Trình sếp duyệt giá + ('approved', 'Approved'), # Đã duyệt xong, chờ PO + ('confirmed', 'Confirmed'), # Đã chốt -> Đang tạo/Có PO + ('cancel', 'Cancelled') + ], + string='Status', + readonly=True, + index=True, + copy=False, + default='draft', + tracking=True + ) + + # Tích hợp với Approval Entry + approval_state = fields.Selection( + selection=[ + ('draft', 'Not Required'), + ('pending', 'Pending'), + ('approved', 'Approved'), + ('refused', 'Refused') + ], + string='Approval Matrix Status', + compute='_compute_approval_state', + store=True + ) + + approval_entry_ids = fields.One2many( + comodel_name='epr.approval.entry', + inverse_name='rfq_id', + string='Approvals' + ) + + # Tính tổng tiền trên RFQ để so sánh trong approval process + amount_total = fields.Monetary( + compute='_compute_amount_total', + string='Total', + currency_field='currency_id' + ) + + department_id = fields.Many2one( + comodel_name='hr.department', + compute='_compute_department_id', + string='Department', + store=True, + help="Phòng ban của người yêu cầu (lấy từ PR đầu tiên)." + ) + + @api.depends('request_ids.department_id') + def _compute_department_id(self): + for rfq in self: + # Lấy phòng ban từ PR đầu tiên gắn với RFQ này + rfq.department_id = rfq.request_ids[:1].department_id or False + + # === 2. RELATIONS === + # Link ngược lại PR gốc (01 RFQ có thể gom nhiều PR) + request_ids = fields.Many2many( + comodel_name='epr.purchase.request', + relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian + column1='rfq_id', + column2='request_id', + string='Source Requests', + # Chỉ lấy các PR đã duyệt để tạo RFQ + domain="[('state', '=', 'approved')]" + ) + + partner_id = fields.Many2one( + comodel_name='res.partner', + string='Vendor', + required=True, + tracking=True + # domain="[('supplier_rank', '>', 0)]", # Chỉ chọn đã từng được chọn qua ít nhất 01 lần + ) + + company_id = fields.Many2one( + comodel_name='res.company', + string='Company', + required=True, + default=lambda self: self.env.company + ) + + currency_id = fields.Many2one( + comodel_name='res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + readonly=True + ) + + # === 3. DATES === + date_order = fields.Datetime( + string='Order Date', + default=fields.Datetime.now, + readonly=True + ) + + date_deadline = fields.Date( + string='Bid Deadline', + help="Ngày hạn chót Vendor phải gửi báo giá", + readonly=True + ) + + # === 4. LINES & PURCHASES === + line_ids = fields.One2many( + comodel_name='epr.rfq.line', + inverse_name='rfq_id', + string='Products', + copy=True + ) + + # Link sang Purchase Order gốc của Odoo (Many2many) + purchase_ids = fields.Many2many( + comodel_name='purchase.order', + relation='epr_rfq_purchase_order_rel', + column1='epr_rfq_id', + column2='purchase_id', + string='Purchase Orders' + ) + + purchase_count = fields.Integer( + compute='_compute_purchase_count', + string='PO Count' + ) + + request_count = fields.Integer( + compute='_compute_request_count', + string='PR Count' + ) + + # ------------------------------------------------------------------------- + # 5. COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends('purchase_ids') + def _compute_purchase_count(self): + for rfq in self: + rfq.purchase_count = len(rfq.purchase_ids) + + # Link ngược về PR gốc + @api.depends('request_ids') + def _compute_request_count(self): + for rfq in self: + rfq.request_count = len(rfq.request_ids) + + # Compute Approval State + @api.depends('approval_entry_ids.status') + def _compute_approval_state(self): + for rfq in self: + if not rfq.approval_entry_ids: + rfq.approval_state = 'draft' + continue + + # Nếu có bất kỳ dòng nào bị từ chối -> Toàn bộ bị từ chối + if any(e.status == 'refused' for e in rfq.approval_entry_ids): + rfq.approval_state = 'refused' + # Nếu tất cả đã duyệt -> Approved + elif all(e.status == 'approved' for e in rfq.approval_entry_ids): + rfq.approval_state = 'approved' + # Còn lại là đang chờ + else: + rfq.approval_state = 'pending' + + # === 6. CRUD OVERRIDES === + @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('epr.rfq') or _('New') + return super().create(vals_list) + + @api.depends('line_ids.subtotal') + def _compute_amount_total(self): + for rfq in self: + rfq.amount_total = sum(rfq.line_ids.mapped('subtotal')) + + # ------------------------------------------------------------------------- + # 7. ACTIONS + # ------------------------------------------------------------------------- + def action_send_email(self): + """Gửi email RFQ cho Vendor""" + self.ensure_one() + if self.state != 'draft': + raise UserError(_("Chỉ có thể gửi RFQ khi ở trạng thái Draft.")) + self.write({'state': 'sent'}) + return True + + def action_mark_received(self): + """Chuyển trạng thái sang Received khi nhận được phản hồi từ NCC""" + for rfq in self: + if rfq.state != 'sent': + # Tư vấn: Nên chặn nếu nhảy cóc trạng thái để đảm bảo quy trình + raise UserError(_("Chỉ có thể đánh dấu 'Đã nhận' khi RFQ đang ở trạng thái 'Đã gửi'.")) + rfq.write({'state': 'received'}) + + def action_confirm(self): + """Chốt RFQ, chuyển sang Confirmed""" + for rfq in self: + if rfq.state != 'approved': + raise UserError(_("Chỉ có thể xác nhận khi RFQ đã được duyệt (Trạng thái 'Approved').")) + rfq.write({ + 'state': 'confirmed', + }) + + def action_cancel_rfq(self): + """Hủy RFQ ở bất kỳ trạng thái nào (trừ khi đã hủy rồi)""" + for rfq in self: + if rfq.state == 'cancel': + continue + + # Nếu đã có PO liên kết, nên cảnh báo hoặc hủy luôn PO con + if rfq.purchase_ids and any(po.state not in ['cancel'] for po in rfq.purchase_ids): + raise UserError(_("Không thể hủy RFQ này vì đã có Đơn mua hàng (PO) được tạo. Vui lòng hủy PO trước.")) + + rfq.write({'state': 'cancel'}) + + def action_create_po(self): + """Chuyển đổi RFQ này thành Purchase Order""" + self.ensure_one() + if not self.line_ids: + raise ValidationError(_("Vui lòng thêm sản phẩm trước khi tạo PO.")) + + # Logic tạo PO + po_vals = { + 'partner_id': self.partner_id.id, + 'date_order': fields.Datetime.now(), + 'epr_source_rfq_ids': [Command.link(self.id)], # Link ngược lại RFQ này (Many2many) + 'origin': self.name, + 'company_id': self.company_id.id, + 'currency_id': self.currency_id.id, + 'order_line': [] + } + + for line in self.line_ids: + po_vals['order_line'].append((0, 0, { + 'product_id': line.product_id.id, + 'name': line.description or line.product_id.name, + 'product_qty': line.quantity, + 'price_unit': line.price_unit, + 'product_uom': line.uom_id.id, + 'date_planned': fields.Datetime.now(), + })) + + new_po = self.env['purchase.order'].create(po_vals) + + self.write({'state': 'confirmed'}) + + # Mở view PO vừa tạo + return { + 'type': 'ir.actions.act_window', + 'res_model': 'purchase.order', + 'res_id': new_po.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_reset_draft(self): + """Cho phép quay lại Draft nếu cần sửa""" + for rfq in self: + if rfq.state not in ['sent', 'to_approve', 'cancel']: + raise UserError(_("Chỉ có thể reset khi ở trạng thái Sent, To Approve hoặc Cancel.")) + rfq.write({'state': 'draft'}) + + # Mở danh sách các PO được tạo từ RFQ này + def action_view_purchase_orders(self): + """Mở danh sách các Purchase Orders (PO) được tạo từ RFQ này""" + self.ensure_one() + return { + 'name': _('Purchase Orders'), + 'type': 'ir.actions.act_window', + 'res_model': 'purchase.order', + 'view_mode': 'list,form', + # Filter các PO có field epr_source_rfq_ids chứa ID hiện tại + 'domain': [('epr_source_rfq_ids', 'in', self.ids)], + 'context': {'default_epr_source_rfq_ids': [Command.link(self.id)]}, + 'target': 'current', + } + + # Mở danh sách các PR gốc của RFQ này + def action_view_source_requests(self): + """Mở danh sách các PR gốc của RFQ này""" + self.ensure_one() + return { + 'name': _('Source Purchase Requests'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.purchase.request', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.request_ids.ids)], + 'target': 'current', + } + # ------------------------------------------------------------------------- + # RFQ APPROVAL PROCESS + # ------------------------------------------------------------------------- + + def action_submit_approval(self): + """Nút bấm Submit for Approval - Odoo 18 Optimized""" + self.ensure_one() + + if not self.line_ids: + raise UserError(_("Vui lòng nhập chi tiết sản phẩm trước khi trình duyệt.")) + + # 1. Quy đổi tiền tệ + currency_company = self.company_id.currency_id + amount_company = self.amount_total + if self.currency_id and self.currency_id != currency_company: + amount_company = self.currency_id._convert( + self.amount_total, currency_company, self.company_id, + self.date_order or fields.Date.context_today(self) + ) + + # 2. Tìm Rule phù hợp + rule = self.env['epr.approval.rule'].search([ + ('active', '=', True), + ('company_id', '=', self.company_id.id), + '|', ('department_id', '=', False), ('department_id', '=', self.department_id.id) + ], order='department_id desc, sequence asc, id desc', limit=1) + + if not rule: + self.write({'state': 'approved', 'approval_state': 'approved'}) + return + # 3. Lọc các bước duyệt dựa trên giá trị đơn hàng + applicable_lines = rule.line_ids.filtered( + lambda l: (not l.min_amount or l.min_amount <= amount_company) + ).sorted('sequence') + + if not applicable_lines: + self.write({'state': 'approved', 'approval_state': 'approved'}) + return + + # 3. Lọc và Sắp xếp các bước duyệt + applicable_lines = rule.line_ids.filtered( + lambda l: (not l.min_amount or l.min_amount <= amount_company) + ).sorted('sequence') + if not applicable_lines: + self.write({'state': 'approved', 'approval_state': 'approved'}) + return + + # 4. Hỗ trợ Duyệt song song cùng tầng (Sequence) + self.approval_entry_ids.unlink() + vals_list = [] + min_seq = applicable_lines[0].sequence + for line in applicable_lines: + # Nếu cùng tầng Sequence nhỏ nhất -> 'new' luôn + status = 'new' if line.sequence == min_seq else 'pending' + vals_list.append({ + 'rfq_id': self.id, + 'name': line.name, + 'sequence': line.sequence, + 'status': status, + 'required_user_ids': [Command.set(line.user_ids.ids)], + 'rule_line_id': line.id, + }) + self.env['epr.approval.entry'].create(vals_list) + + self.write({ + 'state': 'to_approve', + 'approval_state': 'pending' + }) + + # Optional: Gửi email thông báo cho người duyệt bước đầu tiên + # self._notify_next_approvers() + + # ------------------------------------------------------------------------- + # APPROVAL LOGIC: LINEARIZATION + # ------------------------------------------------------------------------- + def _check_approval_completion(self): + """ + Hàm này được gọi mỗi khi 1 dòng entry được Approve/Refuse. + Nhiệm vụ: Kích hoạt bước tiếp theo hoặc Confirm RFQ. + """ + self.ensure_one() + + # A. Nếu có bất kỳ dòng nào bị từ chối -> Hủy toàn bộ quy trình + if any(e.status == 'refused' for e in self.approval_entry_ids): + self.write({ + 'state': 'cancel', + 'approval_state': 'refused' + }) + + return + + remaining = self.approval_entry_ids.filtered(lambda e: e.status in ['new', 'pending']).sorted('sequence') + if not remaining: + self.write({'state': 'approved', 'approval_state': 'approved'}) + return + + # Nếu tầng hiện tại đã duyệt xong hết (không còn ai status='new') + if not remaining.filtered(lambda e: e.status == 'new'): + # Kích hoạt tầng tiếp theo (tất cả các dòng có Sequence nhỏ nhất còn lại) + next_min_seq = remaining[0].sequence + remaining.filtered(lambda e: e.sequence == next_min_seq).write({'status': 'new'}) + +# ============================================================================== +# CLASS CON: epr.rfq.line (Chi tiết hàng hóa trong RFQ) +# ============================================================================== + + +class EprRfqLine(models.Model): + _name = 'epr.rfq.line' + _description = 'EPR RFQ Line' + + rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='RFQ Reference', + required=True, + ondelete='cascade', + index=True + ) + + # === LIÊN KẾT VỚI PR (QUAN TRỌNG) === + # Link về dòng chi tiết của PR gốc + pr_line_id = fields.Many2one( + 'epr.purchase.request.line', + string='Source PR Line', + ondelete='set null', + index=True, + help="Dòng yêu cầu mua hàng gốc sinh ra dòng báo giá này." + ) + + # Link về PR Header (Tiện ích để group/filter) + purchase_request_id = fields.Many2one( + related='pr_line_id.request_id', + string='Purchase Request', + store=True, + readonly=True + ) + + # === SẢN PHẨM & CHI TIẾT === + product_id = fields.Many2one( + comodel_name='product.product', + string='Product', + required=True + ) + + description = fields.Text(string='Description') + + quantity = fields.Float( + string='Quantity', + required=True, + default=1.0, + digits='Product Unit of Measure' + ) + + uom_id = fields.Many2one( + comodel_name='uom.uom', + string='UoM', + required=True + ) + + price_unit = fields.Float( + string='Unit Price', + digits='Product Price' + ) + + taxes_id = fields.Many2many( + comodel_name='account.tax', + relation='epr_rfq_line_taxes_rel', + column1='line_id', + column2='tax_id', + string='Taxes', + context={'active_test': False} + ) + + # === TÍNH TOÁN TIỀN TỆ === + currency_id = fields.Many2one( + related='rfq_id.currency_id', + store=True, + string='Currency', + readonly=True + ) + + subtotal = fields.Monetary( + compute='_compute_subtotal', + string='Subtotal', + store=True, + currency_field='currency_id' + ) + + @api.depends('quantity', 'price_unit', 'taxes_id') + def _compute_subtotal(self): + """Tính tổng tiền (chưa bao gồm thuế)""" + for line in self: + taxes = line.taxes_id.compute_all( + line.price_unit, + line.currency_id, + line.quantity, + product=line.product_id, + partner=line.rfq_id.partner_id + ) + # Nếu bạn muốn duyệt dựa trên GIÁ SAU THUẾ, dùng 'total_included' + # Nếu muốn duyệt trên GIÁ TRƯỚC THUẾ, dùng 'total_excluded' + line.subtotal = taxes['total_included'] + + # === ONCHANGE PRODUCT (GỢI Ý) === + @api.onchange('product_id') + def _onchange_product_id(self): + """Tự động điền UoM và tên khi chọn sản phẩm""" + if self.product_id: + self.uom_id = self.product_id.uom_po_id or self.product_id.uom_id + self.description = self.product_id.display_name + # Tự động lấy thuế mua hàng mặc định của sản phẩm + self.taxes_id = self.product_id.supplier_taxes_id + + # ============================================================================== + # LINE-LEVEL LINKING LOGIC (From RFQs to POs) + # ============================================================================== + + # Link tới dòng của Purchase Order chuẩn Odoo + purchase_line_id = fields.Many2one( + 'purchase.order.line', + string='Purchase Order Line', + readonly=True, + copy=False + ) + + # Tiện ích để xem nhanh trạng thái + po_id = fields.Many2one( + related='purchase_line_id.order_id', + string='Purchase Order', + store=True, + readonly=True + ) diff --git a/addons/epr/security/epr_record_rules.xml b/addons/epr/security/epr_record_rules.xml new file mode 100644 index 0000000..1684470 --- /dev/null +++ b/addons/epr/security/epr_record_rules.xml @@ -0,0 +1,68 @@ + + + + + + + + + ePR: User sees own requests + + [('employee_id.user_id','=',user.id)] + + + + + + + ePR: Manager sees department requests + + ['|', '|', '|', + ('employee_id.user_id','=',user.id), + ('approver_ids', 'in', user.id), + ('department_id.manager_id.user_id', '=', user.id), + ('employee_id.parent_id.user_id', '=', user.id) + ] + + + + + + + ePR: Officer sees approved requests + + ['|', + ('employee_id.user_id','=',user.id), + ('state', 'in', ['approved', 'in_progress', 'done']) + ] + + + + + + ePR: Admin sees all + + [(1, '=', 1)] + + + + + \ No newline at end of file diff --git a/addons/epr/security/epr_security.xml b/addons/epr/security/epr_security.xml new file mode 100644 index 0000000..e0d3c5a --- /dev/null +++ b/addons/epr/security/epr_security.xml @@ -0,0 +1,79 @@ + + + + + + + + Purchase Request (ePR) + Quản lý phân quyền cho yêu cầu mua sắm điện tử. + 15 + + + + + + User (Requester) + + + Nhân viên tạo yêu cầu mua sắm. + + + + + + Manager (Approver) + + + Trưởng bộ phận/Ban giám đốc phê duyệt yêu cầu. + + + + + + Purchasing Officer + + + Nhân viên thu mua xử lý các yêu cầu đã duyệt. + + + + + + Administrator + + + + + + + \ No newline at end of file diff --git a/addons/epr/security/ir.model.access.csv b/addons/epr/security/ir.model.access.csv new file mode 100644 index 0000000..a6982f9 --- /dev/null +++ b/addons/epr/security/ir.model.access.csv @@ -0,0 +1,37 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_epr_purchase_request_user,ePR Request User,model_epr_purchase_request,group_epr_user,1,1,1,0 +access_epr_purchase_request_line_user,ePR Request Line User,model_epr_purchase_request_line,group_epr_user,1,1,1,0 +access_epr_purchase_request_manager,ePR Request Manager,model_epr_purchase_request,group_epr_manager,1,1,1,0 +access_epr_purchase_request_line_manager,ePR Request Line Manager,model_epr_purchase_request_line,group_epr_manager,1,1,1,0 +access_epr_purchase_request_officer,ePR Request Officer,model_epr_purchase_request,group_epr_purchasing_officer,1,1,1,0 +access_epr_purchase_request_line_officer,ePR Request Line Officer,model_epr_purchase_request_line,group_epr_purchasing_officer,1,1,1,0 +access_epr_purchase_request_admin,ePR Request Admin,model_epr_purchase_request,group_epr_admin,1,1,1,1 +access_epr_purchase_request_line_admin,ePR Request Line Admin,model_epr_purchase_request_line,group_epr_admin,1,1,1,1 +access_epr_rfq_officer,ePR RFQ Officer,model_epr_rfq,group_epr_purchasing_officer,1,1,1,0 +access_epr_rfq_line_officer,ePR RFQ Line Officer,model_epr_rfq_line,group_epr_purchasing_officer,1,1,1,0 +access_epr_rfq_admin,ePR RFQ Admin,model_epr_rfq,group_epr_admin,1,1,1,1 +access_epr_rfq_line_admin,ePR RFQ Line Admin,model_epr_rfq_line,group_epr_admin,1,1,1,1 +access_epr_rfq_manager,ePR RFQ Manager,model_epr_rfq,group_epr_manager,1,1,0,0 +access_epr_rfq_line_manager,ePR RFQ Line Manager,model_epr_rfq_line,group_epr_manager,1,1,0,0 +access_epr_approval_rule_officer,ePR Approval Rule Officer,model_epr_approval_rule,group_epr_purchasing_officer,1,1,1,1 +access_epr_approval_rule_line_officer,ePR Approval Rule Line Officer,model_epr_approval_rule_line,group_epr_purchasing_officer,1,1,1,1 +access_epr_approval_rule_admin,ePR Approval Rule Admin,model_epr_approval_rule,group_epr_admin,1,1,1,1 +access_epr_approval_rule_line_admin,ePR Approval Rule Line Admin,model_epr_approval_rule_line,group_epr_admin,1,1,1,1 +access_epr_approval_rule_manager,ePR Approval Rule Manager,model_epr_approval_rule,group_epr_manager,1,0,0,0 +access_epr_approval_rule_line_manager,ePR Approval Rule Line Manager,model_epr_approval_rule_line,group_epr_manager,1,0,0,0 +access_epr_approval_entry_user,ePR Approval Entry User,model_epr_approval_entry,group_epr_user,1,0,0,0 +access_epr_approval_entry_manager,ePR Approval Entry Manager,model_epr_approval_entry,group_epr_manager,1,1,0,0 +access_epr_approval_entry_officer,ePR Approval Entry Officer,model_epr_approval_entry,group_epr_purchasing_officer,1,1,1,0 +access_epr_approval_entry_admin,ePR Approval Entry Admin,model_epr_approval_entry,group_epr_admin,1,1,1,1 +access_epr_reject_wizard_user,ePR Reject Wizard User,model_epr_reject_wizard,group_epr_user,1,0,0,0 +access_epr_reject_wizard_manager,ePR Reject Wizard Manager,model_epr_reject_wizard,group_epr_manager,1,1,1,1 +access_epr_reject_wizard_officer,ePR Reject Wizard Officer,model_epr_reject_wizard,group_epr_purchasing_officer,1,1,1,1 +access_epr_reject_wizard_admin,ePR Reject Wizard Admin,model_epr_reject_wizard,group_epr_admin,1,1,1,1 +access_epr_create_po_wizard_officer,ePR Create PO Wizard Officer,model_epr_create_po_wizard,group_epr_purchasing_officer,1,1,1,1 +access_epr_create_po_wizard_admin,ePR Create PO Wizard Admin,model_epr_create_po_wizard,group_epr_admin,1,1,1,1 +access_epr_create_po_line_wizard_officer,ePR Create PO Line Wizard Officer,model_epr_create_po_line_wizard,group_epr_purchasing_officer,1,1,1,1 +access_epr_create_po_line_wizard_admin,ePR Create PO Line Wizard Admin,model_epr_create_po_line_wizard,group_epr_admin,1,1,1,1 +access_epr_create_rfq_wizard_officer,ePR Create RFQ Wizard Officer,model_epr_create_rfq_wizard,group_epr_purchasing_officer,1,1,1,1 +access_epr_create_rfq_wizard_admin,ePR Create RFQ Wizard Admin,model_epr_create_rfq_wizard,group_epr_admin,1,1,1,1 +access_epr_create_rfq_line_officer,ePR Create RFQ Line Officer,model_epr_create_rfq_line,group_epr_purchasing_officer,1,1,1,1 +access_epr_create_rfq_line_admin,ePR Create RFQ Line Admin,model_epr_create_rfq_line,group_epr_admin,1,1,1,1 diff --git a/addons/epr/static/description/icon.png b/addons/epr/static/description/icon.png new file mode 100644 index 0000000..626ce1a Binary files /dev/null and b/addons/epr/static/description/icon.png differ diff --git a/addons/epr/views/epr_approval_views.xml b/addons/epr/views/epr_approval_views.xml new file mode 100644 index 0000000..605141b --- /dev/null +++ b/addons/epr/views/epr_approval_views.xml @@ -0,0 +1,219 @@ + + + + + + + + + epr.approval.rule.list + epr.approval.rule + + + + + + + + + + + + + + epr.approval.rule.form + epr.approval.rule + +
+ +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +

+ * Rules are processed in order. The first rule matching the criteria (Department, Amount) will be used. +

+
+
+
+ + + +
+
+ + + + epr.approval.rule.search + epr.approval.rule + + + + + + + + + + + + + + + + + + + + + epr.approval.entry.list + epr.approval.entry + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + purchase.order.search.inherit.epr + purchase.order + + + + + + + + + + + + My Purchase Orders (From RFQ) + purchase.order + list,form + [('epr_rfq_count', '>', 0)] + {'create': False} + +

+ No Purchase Orders linked to EPR RFQs found. +

+
+
+ + +
\ No newline at end of file diff --git a/addons/epr/views/epr_purchase_request_views.xml b/addons/epr/views/epr_purchase_request_views.xml new file mode 100644 index 0000000..2966bb4 --- /dev/null +++ b/addons/epr/views/epr_purchase_request_views.xml @@ -0,0 +1,370 @@ + + + + + + + epr.purchase.request.search + epr.purchase.request + + + + + + + + + + + + + + + + + + + + + + + + + + + + epr.purchase.request.kanban + epr.purchase.request + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + +
+ + +
+ + +
+
+ + + + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+ + + + epr.purchase.request.list + epr.purchase.request + + + + + + + + + + + + + + + + + epr.purchase.request.form + epr.purchase.request + +
+ +
+ +
+ + + +
+ +
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + + + Purchase Requests + ir.actions.act_window + epr.purchase.request + kanban,list,form,search + + {'search_default_my_requests': 1} + + +

+ Create your first Purchase Request! +

+
+
+ + + + To Approve + ir.actions.act_window + epr.purchase.request + kanban,list,form,search + + {'search_default_to_approve_by_me': 1, 'search_default_to_approve': 1} + [('state', '=', 'to_approve')] + +

+ No requests waiting for your approval! +

+
+
+ +
+
\ No newline at end of file diff --git a/addons/epr/views/epr_rfq_views.xml b/addons/epr/views/epr_rfq_views.xml new file mode 100644 index 0000000..045895c --- /dev/null +++ b/addons/epr/views/epr_rfq_views.xml @@ -0,0 +1,263 @@ + + + + + + + + + epr.rfq.list + epr.rfq + + + + + + + + + + + + + + + + + + + + + epr.rfq.form + epr.rfq + +
+ +
+ +
+ + + + +
+ + + + +
+ + +
+ Request for Quotation +

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +