From 25c864ef28b1508b0a9cebdb8707c39fa3ace41a Mon Sep 17 00:00:00 2001 From: mtpc4s9 Date: Wed, 3 Dec 2025 23:59:20 +0700 Subject: [PATCH] =?UTF-8?q?T=E1=BB=91i=20=C6=B0u=20code,=20th=C3=AAm=20com?= =?UTF-8?q?ments=20=C4=91=C3=A2y=20=C4=91=E1=BB=A7=20Them=20epr=5Fsequence?= =?UTF-8?q?=5Fdata.xml=20trong=20folder=20data=20=C4=91=E1=BB=83=20t?= =?UTF-8?q?=E1=BB=B1=20t=E1=BA=A1o=20t=C3=AAn=20PR=20S=E1=BB=ADa=20th?= =?UTF-8?q?=C3=AAm=20m=E1=BB=99t=20v=C3=A0i=20logic:=20=20=20=20+=20N?= =?UTF-8?q?=C3=BAt=20Reset=20to=20draft=20cho=20ph=C3=A9p=20reset=20l?= =?UTF-8?q?=E1=BA=A1i=20phi=E1=BA=BFu=20n=E1=BA=BFu=20mu=E1=BB=91n=20s?= =?UTF-8?q?=E1=BB=ADa=20l=E1=BA=A1i=20sau=20khi=20submit=20=20=20=20+=20B?= =?UTF-8?q?=E1=BB=95=20sung=20Group:=20User=20-->=20Manager=20-->=20Purcha?= =?UTF-8?q?sing=20Officer=20-->=20Admin=20=20=20=20+=20Th=C3=AAm=20Record?= =?UTF-8?q?=20rules=20ch=E1=BA=B7t=20ch=E1=BA=BD=20cho=20m=E1=BB=97i=20nh?= =?UTF-8?q?=C3=B3m=20...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/epr/Implementation Plan.md | 430 +++++++++++++++ addons/epr/__init__.py | 2 + addons/epr/__manifest__.py | 47 ++ .../epr/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 195 bytes addons/epr/data/epr_sequence_data.xml | 29 + addons/epr/models/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 185 bytes .../epr_purchase_request.cpython-312.pyc | Bin 0 -> 13420 bytes addons/epr/models/epr_purchase_request.py | 500 ++++++++++++++++++ addons/epr/security/epr_record_rules.xml | 67 +++ addons/epr/security/epr_security.xml | 79 +++ addons/epr/security/ir.model.access.csv | 12 + addons/epr/static/description/icon.png | Bin 0 -> 6947 bytes addons/epr/views/epr_menus.xml | 71 +++ .../epr/views/epr_purchase_request_views.xml | 230 ++++++++ addons/epr/wizards/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 183 bytes .../epr_reject_wizard.cpython-312.pyc | Bin 0 -> 2993 bytes addons/epr/wizards/epr_reject_wizard.py | 81 +++ .../epr/wizards/epr_reject_wizard_views.xml | 96 ++++ addons/zoo/__manifest__.py | 13 +- .../__pycache__/zoo_animal.cpython-312.pyc | Bin 7655 -> 7641 bytes .../zoo_health_record.cpython-312.pyc | Bin 5323 -> 5254 bytes .../zoo_husbandry_task.cpython-312.pyc | Bin 7796 -> 10801 bytes addons/zoo/models/zoo_animal.py | 24 +- addons/zoo/models/zoo_health_record.py | 5 +- addons/zoo/models/zoo_husbandry_task.py | 156 ++++-- addons/zoo/report/zoo_report_action.xml | 16 + addons/zoo/report/zoo_report_template.xml | 80 +++ addons/zoo/security/zoo_security.xml | 39 ++ addons/zoo/views/zoo_husbandry_task_views.xml | 39 +- addons/zoo/views/zoo_keeper_views.xml | 9 + 32 files changed, 1971 insertions(+), 56 deletions(-) create mode 100644 addons/epr/Implementation Plan.md create mode 100644 addons/epr/__init__.py create mode 100644 addons/epr/__manifest__.py create mode 100644 addons/epr/__pycache__/__init__.cpython-312.pyc create mode 100644 addons/epr/data/epr_sequence_data.xml create mode 100644 addons/epr/models/__init__.py create mode 100644 addons/epr/models/__pycache__/__init__.cpython-312.pyc create mode 100644 addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc create mode 100644 addons/epr/models/epr_purchase_request.py create mode 100644 addons/epr/security/epr_record_rules.xml create mode 100644 addons/epr/security/epr_security.xml create mode 100644 addons/epr/security/ir.model.access.csv create mode 100644 addons/epr/static/description/icon.png create mode 100644 addons/epr/views/epr_menus.xml create mode 100644 addons/epr/views/epr_purchase_request_views.xml create mode 100644 addons/epr/wizards/__init__.py create mode 100644 addons/epr/wizards/__pycache__/__init__.cpython-312.pyc create mode 100644 addons/epr/wizards/__pycache__/epr_reject_wizard.cpython-312.pyc create mode 100644 addons/epr/wizards/epr_reject_wizard.py create mode 100644 addons/epr/wizards/epr_reject_wizard_views.xml create mode 100644 addons/zoo/report/zoo_report_action.xml create mode 100644 addons/zoo/report/zoo_report_template.xml create mode 100644 addons/zoo/security/zoo_security.xml 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..46ba70e --- /dev/null +++ b/addons/epr/__manifest__.py @@ -0,0 +1,47 @@ +{ + '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_sequence_data.xml', + # 'data/epr_approval_default_data.xml', + # 'data/mail_template_data.xml', + 'views/epr_purchase_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_wizard_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 0000000000000000000000000000000000000000..12890c6d89a551c4b8b6b455656ca5a300a855fc GIT binary patch literal 195 zcmX@j%ge<81by4}GPQv8V-N=hn4pZ$GC;<3h7^Vr#vF!R#wbQc5St0eW{P5BWT<4; zWO>O5RHDgvi!C=lB{ioQ$YL+gtV%3ODfZK3zQs_)43sWn0TL@2J_AXHUyAy$P}fGjBH1`;2b85tSx NGKkz`5G`T{@&PJAELs2n literal 0 HcmV?d00001 diff --git a/addons/epr/data/epr_sequence_data.xml b/addons/epr/data/epr_sequence_data.xml new file mode 100644 index 0000000..52dd77c --- /dev/null +++ b/addons/epr/data/epr_sequence_data.xml @@ -0,0 +1,29 @@ + + + + + + + Purchase Request + + epr.purchase.request + + + PR/%(year)s/ + + + 5 + + + 1 + 1 + + + + + + + diff --git a/addons/epr/models/__init__.py b/addons/epr/models/__init__.py new file mode 100644 index 0000000..4c375e4 --- /dev/null +++ b/addons/epr/models/__init__.py @@ -0,0 +1 @@ +from . import epr_purchase_request 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 0000000000000000000000000000000000000000..c2ec60e0c06831cfcb8ec270d2308dbb57b873bc GIT binary patch literal 185 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 T(}S0uyYkZImwwApogw{i4080# literal 0 HcmV?d00001 diff --git a/addons/epr/models/epr_purchase_request.py b/addons/epr/models/epr_purchase_request.py new file mode 100644 index 0000000..67ca755 --- /dev/null +++ b/addons/epr/models/epr_purchase_request.py @@ -0,0 +1,500 @@ +# -*- 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 + ) + + 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 + ) + + # approver_ids = fields.Many2many( + # 'res.users', + # string='Approvers', + # compute='_compute_approvers', + # store=True + # ) + + # 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( + 'res.currency', + string='Currency', + default=lambda self: self.env.company.currency_id, + required=True + ) + line_ids = fields.One2many( + 'epr.purchase.request.line', + 'request_id', + string='Products' + ) + estimated_total = fields.Monetary( + string='Estimated Total', + compute='_compute_estimated_total', + store=True, + currency_field='currency_id' + ) + + # ========================================================================== + # 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_submitted = fields.Datetime( + string='Submitted Date', + readonly=True, + copy=False + ) + + # ========================================================================== + # MODEL METHODS + # ========================================================================== + + @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) + + # 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 + + # 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 + + # ========================================================================== + # 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 + }) + + # Mark activities as done + # NOTE: Commented out for testing without mail server + # self.activity_ids.filtered( + # lambda a: a.user_id == self.env.user + # ).action_feedback(feedback='Approved') + + # Post message to chatter + # self.message_post( + # body=_('Purchase Request approved by %s') % ( + # self.env.user.name + # ) + # ) + + # 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': 'draft', + 'rejection_reason': reason, + 'approver_ids': [(5, 0, 0)] # QUAN TRỌNG: Xóa sạch người duyệt để clear danh sách chờ + }) + + # Mark activities as done + # NOTE: Commented out for testing without mail server + # self.activity_ids.filtered( + # lambda a: a.user_id == self.env.user + # ).action_feedback(feedback='Rejected') + + # Post message to chatter + # self.message_post( + # body=_('Purchase Request rejected by %s\nReason: %s') % ( + # self.env.user.name, + # reason + # ) + # ) + + # 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() + + +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..." + ) + + # Dùng Char hoặc Text cho tên nhà cung cấp gợi ý (Staff không cần chọn trong danh bạ đối tác) + vendor_name = fields.Char( + string='Vendor Name', + help="Tên nhà cung cấp được đề xuất bởi người yêu cầu (tham khảo)." + ) + + 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 + + # === logic Onchange === + # Chỉ chạy khi Purchasing Staff chọn product_id (lúc xử lý phiếu) + + @api.onchange('product_id') + def _onchange_product_id(self): + if not self.product_id: + return + + # Đơn vị tính (UoM): NÊN ghi đè theo chuẩn hệ thống + # Lý do: Staff có thể nhập "Cái", nhưng hệ thống kho quản lý là "Unit(s)". + # Để tạo PO chính xác sau này, ta cần lấy UoM chuẩn của sản phẩm. + self.uom_name = self.product_id.uom_po_id.name or self.product_id.uom_id.name + + # Giá dự kiến: Chỉ điền nếu Staff để bằng 0 + if self.estimated_price == 0.0: + self.estimated_price = self.product_id.standard_price + + # Tên sản phẩm: KHÔNG ghi đè (Giữ nguyên mô tả của Staff) + # Vì Staff mô tả nhu cầu thực tế (VD: "Máy tính Dell cho kế toán"), + # còn tên Product hệ thống có thể chung chung (VD: "Laptop Dell Latitude"). + # Ta chỉ điền nếu dòng này do Purchasing tạo mới hoàn toàn (name đang rỗng). + if not self.name: + self.name = self.product_id.display_name diff --git a/addons/epr/security/epr_record_rules.xml b/addons/epr/security/epr_record_rules.xml new file mode 100644 index 0000000..4dbe231 --- /dev/null +++ b/addons/epr/security/epr_record_rules.xml @@ -0,0 +1,67 @@ + + + + + + + + + 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) + ] + + + + + + + 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..1cea9e9 --- /dev/null +++ b/addons/epr/security/ir.model.access.csv @@ -0,0 +1,12 @@ +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 Line User,model_epr_purchase_request_line,group_epr_user,1,1,1,0 +access_epr_reject_wizard_user,ePR Reject Wizard User,model_epr_reject_wizard,group_epr_user,1,0,0,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 Line Manager,model_epr_purchase_request_line,group_epr_manager,1,1,1,0 +access_epr_reject_wizard_manager,ePR Reject Wizard Manager,model_epr_reject_wizard,group_epr_manager,1,1,1,1 +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 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 Line Admin,model_epr_purchase_request_line,group_epr_admin,1,1,1,1 +access_epr_reject_wizard_admin,ePR Reject Wizard Admin,model_epr_reject_wizard,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 0000000000000000000000000000000000000000..626ce1ad9158310a400cfec9628b608fa19ceeac GIT binary patch literal 6947 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 literal 0 HcmV?d00001 diff --git a/addons/epr/views/epr_menus.xml b/addons/epr/views/epr_menus.xml new file mode 100644 index 0000000..db2e426 --- /dev/null +++ b/addons/epr/views/epr_menus.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..25058f2 --- /dev/null +++ b/addons/epr/views/epr_purchase_request_views.xml @@ -0,0 +1,230 @@ + + + + + + + epr.purchase.request.search + 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 + list,form,search + + {'search_default_my_requests': 1} + + +

+ Create your first Purchase Request! +

+
+
+ + + + To Approve + ir.actions.act_window + epr.purchase.request + 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/wizards/__init__.py b/addons/epr/wizards/__init__.py new file mode 100644 index 0000000..a798f2d --- /dev/null +++ b/addons/epr/wizards/__init__.py @@ -0,0 +1 @@ +from . import epr_reject_wizard diff --git a/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc b/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b86063fac7683a5b165bae1da9ce20dde809b1c6 GIT binary patch literal 183 zcmX@j%ge<81c6)hG8KUIV-N=hn4pZ$0zk%eh7^Vr#vF!R#wbQchDs()=9i2>VNJ$c zf~f^X@kOawsmUer<(XB9MJaxoOt%<{n1RwO89svy`K76!n^&TrT2WGzsGFFQlAl+s z57ea(QC_ScAD@|*SrQ+wS5Wzj!zMRBr8Fniu80Gu6J$p*i1C4$k&*EpgM1MikOKfw CuPkE# literal 0 HcmV?d00001 diff --git a/addons/epr/wizards/__pycache__/epr_reject_wizard.cpython-312.pyc b/addons/epr/wizards/__pycache__/epr_reject_wizard.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ff7adeab76cf58ff28cc87c65ada7f5a3d7d4fa GIT binary patch literal 2993 zcmbtWUuYc18K1qqTiu=Rk1U_8x=Q15u__%(&#{X`AfahVT)WLbS5{kcd04hPBi%}S zyO)_gTRN+t)CMap!4@PafkI`1u@lAA#d*?^r!$7q7pn?LEDhx7Ln!p&g4`1P)Nf|@ z?i5)Mg|4l+Z)d*w{@w5QefQUNI*H)<=;(Cz&=)rmbxd#AtksNN!)R7m<#d^J2O zrb*@!DrOeZtWwOLMft3sJy~JnbefvpudJHENcAtEo5b)0@7QUq%;CFB_ii`qADEao z>$fmz)~`ELu-U9%#7?QXcB_Kz#;2IL*lX5Tu~}-?mn`^jak*K4-@|9B%q$t4;&J*$ zm2$5j9lX?73C|~(;c$yO-piuuPY4Hp!nW0{U#^13wNJbL!Tz76NKvW`s%XPw>ALhiZN*FX;mqFM1 zc3+(EdrX6y2BEbR6D9gcnvjAsLkaevE+vcOAP(WjknRe3%}<%GgqrRcW$J6(W0o`J z$AwReQ6eORxQ;#N$6W`CLp|z8OVqB^`nq9{Q-ixs?aUb)1d1!nJ!cV$4NRzKST=+J zRzL$!x@<=jT*O6TL*_>ru7U@~J;Na!pD)c7@+wOM9GIXD+Xssofu;Qj;)uU(AC`BO zW#qL_kk`?hM{oBcG_R~G!QpQr^JqE*ct;(C={A9OY)-??-4E$nQvXj&kxop}d6gyS zfub{`kZ5dPosW>{v*3C@0wjr5dm(rUS;31_B?~~;xQQ#Jd$;dh0~~sQ_YX}d_M}m@ zJ$;IL5|tMP8@gz7i^;h9q76Yy67 z8h;=-+pNFsV2Jgah0l!3)NWu?sB}&FiB3FzDikG%ndsbpG6a+eSMy`MTA_?3#7Xat z68S21&H;gfOneop2HH_h?Mc5k=(@UXanDy7 z>w_bkgJbK1V>e&f7<^_i^_7NF1MfX{KmXj{4nDVq^NY`4O|7TC^F_9QF}V%CkQp*X z$Vd!4$+1sBSVn;Oou@(ro_2dz#CeB#!I@Zj&T9`&#Fnj1JMkJ9A|UU}pgqtpJo_s9bjdt7{$}3Hsa!_E_Vi zc8l(U@mK-B2*|!zhI43m-O15aIbOi0r2SH3rHp6A2ovMVcBd>ev?}2X=x^MD=tlQy z|M{-H9~6L1jXMy32&PVUwPTO8;5|DpOay4sCZ(j{N4>cUtqobM5KILwJ^`iU8{g@#CAatP|WpVW2uLaH_e zIi>4wR1I5f;J#DamD(h21zvnpbIvnUwy<1ZV|+;J#WP?qR~=n6;Rgb5P( zv7l24^0V)Wjda*hOlsRY{K8|8!>+8`k7V6s>ynG8HLt+(^(FLGM2*EoE_mVO*8Zm! zUTURK|KY0#mJcl*+8P+!${ya@Hz5Cidn^6IgLF^7w(zr7Kgt|j{N|l^R?|rUu^%jinMG?NdI|aAQn_ehVV*0;YVHgeZxdokO|AQBo6$n zI4RIslgfH<2@yZ@&7W7&&WA`=T_*T2{%la~AF>l*R3L_b7nYW)D9V>;WCM+Sj)os- WFDgp%>g48uvGoIE|3>0X+W0Trf?#w2 literal 0 HcmV?d00001 diff --git a/addons/epr/wizards/epr_reject_wizard.py b/addons/epr/wizards/epr_reject_wizard.py new file mode 100644 index 0000000..9ce8152 --- /dev/null +++ b/addons/epr/wizards/epr_reject_wizard.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class EprRejectWizard(models.TransientModel): + """ + Wizard này được sử dụng để nhập lý do từ chối cho một Purchase Request. + Là một TransientModel, dữ liệu sẽ được tự động xóa định kỳ bởi Odoo. + """ + _name = 'epr.reject.wizard' + _description = 'Purchase Request Rejection Wizard' + + # ========================================================================== + # FIELDS + # ========================================================================== + + request_id = fields.Many2one( + comodel_name='epr.purchase.request', + string='Purchase Request', + required=True, + readonly=True, + ondelete='cascade', + help="The Purchase Request linked to this rejection action." + ) + + reason = fields.Text( + string='Rejection Reason', + required=True, + help='Please provide a detailed reason for rejection so the requester understands why.' + ) + + # ========================================================================== + # COMPUTE & ONCHANGE & DEFAULTS + # ========================================================================== + + @api.model + def default_get(self, fields_list): + """ + Ghi đè phương thức default_get để tự động lấy ID của Purchase Request + đang kích hoạt (active_id) từ context và điền vào field request_id. + Điều này giúp User không phải chọn lại PR thủ công. + """ + res = super(EprRejectWizard, self).default_get(fields_list) + + # Lấy ID của bản ghi đang đứng từ context ('active_id') + active_id = self.env.context.get('active_id') + active_model = self.env.context.get('active_model') + + # Kiểm tra xem có phải đang mở từ đúng model không + if active_id and active_model == 'epr.purchase.request': + res['request_id'] = active_id + + return res + + # ========================================================================== + # BUSINESS LOGIC (ACTIONS) + # ========================================================================== + + def action_confirm_reject(self): + """ + Hành động được gọi khi User nhấn nút 'Reject' trên Wizard. + 1. Kiểm tra dữ liệu. + 2. Gọi hàm xử lý logic trên model chính (epr.purchase.request). + 3. Đóng wizard. + """ + self.ensure_one() + + # Mặc dù field required=True đã chặn ở UI, nhưng kiểm tra ở backend vẫn an toàn hơn + if not self.reason: + raise UserError(_('Please provide a reason for rejection to proceed.')) + + # Gọi phương thức nghiệp vụ trên model chính để xử lý logic chuyển trạng thái + # Việc tách logic này giúp code gọn gàng và dễ bảo trì. + self.request_id.action_reject(self.reason) + + # Đóng cửa sổ wizard và (tùy chọn) reload lại giao diện phía sau + return { + 'type': 'ir.actions.act_window_close', + # 'tag': 'reload', # Uncomment nếu muốn reload lại trang phía sau ngay lập tức + } diff --git a/addons/epr/wizards/epr_reject_wizard_views.xml b/addons/epr/wizards/epr_reject_wizard_views.xml new file mode 100644 index 0000000..6ca0464 --- /dev/null +++ b/addons/epr/wizards/epr_reject_wizard_views.xml @@ -0,0 +1,96 @@ + + + + + + epr.reject.wizard.form + epr.reject.wizard + +
+ + + + + + + + + + + + +
+ +
+ +
+
+ + + + Reject Purchase Request + epr.reject.wizard + form + + new + + +
+
\ No newline at end of file diff --git a/addons/zoo/__manifest__.py b/addons/zoo/__manifest__.py index 342d020..f384884 100644 --- a/addons/zoo/__manifest__.py +++ b/addons/zoo/__manifest__.py @@ -9,7 +9,7 @@ 'website': 'https://minhng.info', 'category': 'Uncategorized', # https://github.com/odoo/odoo/blob/18.0/odoo/addons/base/data/ir_module_category_data.xml 'version': '0.1', - + # Dependencies 'depends': [ 'product', @@ -19,6 +19,7 @@ # Data files declaration 'data': [ + 'security/zoo_security.xml', 'security/ir.model.access.csv', 'views/zoo_animal_views.xml', 'views/zoo_creature_views.xml', @@ -26,16 +27,18 @@ 'views/zoo_health_records.xml', 'views/zoo_animal_meal_views.xml', 'views/zoo_diet_plans.xml', - 'wizard/toy_add_views.xml', - 'wizard/cage_update_views.xml', - 'wizard/animal_feeding_views.xml', 'views/zoo_husbandry_task_views.xml', 'views/zoo_keeper_views.xml', 'views/zoo_keeper_certificate_views.xml', 'views/zoo_keeper_speciality_views.xml', + 'wizard/toy_add_views.xml', + 'wizard/cage_update_views.xml', + 'wizard/animal_feeding_views.xml', + 'report/zoo_report_action.xml', + 'report/zoo_report_template.xml', ], 'installable': True, 'auto_install': False, 'application': True, -} \ No newline at end of file +} diff --git a/addons/zoo/models/__pycache__/zoo_animal.cpython-312.pyc b/addons/zoo/models/__pycache__/zoo_animal.cpython-312.pyc index 53ae7673257054f99fc35b73eb5de20c9247d6b9..f53f7aae01a0ed0415f41386d4171fbd8d0d8323 100644 GIT binary patch delta 1476 zcmY*ZT})eL7(U(6N^r9l7aEISnFDzL5- zvm0%c?p@%^CWs?dFk=g3P{?k@N`}WaY{z1`0X{*hDs_4dZhq9 zQho}C1yu^-_VQ6kQA@=g)cQaOIYPLz+*R*%wDg}>>c-*nF}N*+qzHbx+yf>tB=xd1 zh>EJzhmX_ftCWl46Eym+s46jh5$vmNfAI$J5r;@Z>M}c0JZ(<;`bRn4`VUr;iNiMBNp zDr2a-COUCQxx?!x8PQLn$c`=;)Ca+751};}u#cl5xMY7jGDZVNleU_Fo#+dN^g?PW zo5MQM^pPiJ)Yb#g(QpjC1iMm7u%<4 zieY<`WHU=E3(JL74ey}Z?nU;yEqXbhB{7v#P{ci8zCH=<)hJwPn}| z8A>|2vP6%!cHv{N=pC8&Gv1miX!Q9dvszIfWx@c12?pm_++5I7OGzz5^ye7&0)-t; zuWc~M2+IMOsZo3p_Pm~fs-vyyYMU^Gyh$>xXC{5) zlq3S`N?o*6Rb$;n011HvDnzl=MI^+6O(ocXgoqGom`y8nQ4mOEQ8skLdkzg`7RUFV zb3b$Md!Ao1Pcy;qgF!z>pOxJ^46Ph&xOR~`iyY=KDsU#!kjZPjDQJQzYN9D=5@H%( zkWG)~F}<3%>I(&*=Ht0x4vRN9Ea}3f2rIhvSr#^YO_>wY@^8#ZORkWgHe7ka&(CkuouQqD(J)hNggXWfW=A> zt|L{d!QM&;mZ&yk9v(8Q~-o%f>{1W^Nu!z8bwdbmy5_jo^i z#V4c$tr3QYl-~x=c~xq}$;x(61yyRo&6OQ+ids9dN-al7NiXA6r3F5sTq|y?q~Tl2 zwd0P;F8GsLyQ^x0s7jrgCnO6tb1Q1!B?Dm}Ot@4vvJjG@a8^nv1Dnbq%t@?r`!Ww#q&oOgN`wyK!+7Au=J64D zB&C$2n;FL-B&W6wZ~Dh?aLXHQIBnnDiS$VrlMf&SWjPM_<*tJvc4mSrJ4IqxldcEr z6Zx4!kpw6|?>naJxcRJM>S^SPG=*t;8)LOxsk?`2%Ptx zjWE*<7}iytIC{>?n>rO9c;Vle70vf4N_Mk&9|I2y7xI&O+T)6jUYH=gkWdn+8}=$^ zl1Zx0$28j>vJ5j{=**4V=JZTa&(VbZ%4bSHYO9vov!&h=Px=_KpMt|vXl^DM@CQ?f zKzDGE?)pmbddDCY+*)hKyr`3$J&_xqG76aJ7U}#z&O2K^y+04;+z-Y92Txlw43pG=NW zGtHAx#;_7(jKXG0Io{iJpAqbDUg93d5)WgotFhKE+E!ydH^n;&$-w8@< z=;KAhw#YENS${~b)*FR;^)vl@X#hFQfGx*WOuINm*UPSf>m9T79y4#vk?QDdg|0G@ z-wYswaK0gXjh(scAGa-fDf5QqkO5Y%iNP@jCzcQdy#3-7Fjqt65sh?kD6%Us>|g_fO*9p#Sq z&7W#_l}sk$(WAlzjXE diff --git a/addons/zoo/models/__pycache__/zoo_health_record.cpython-312.pyc b/addons/zoo/models/__pycache__/zoo_health_record.cpython-312.pyc index 3cc01b4dc5d77597a2fa00f2d5ae6e3b153d7297..d96a04f527302ea8b69336c4ed88936afbf34aee 100644 GIT binary patch delta 873 zcmZuvO=uHA7@f)fb(3t8R&yv4iTy#gm9+LR)z%t{SP!;{zhyy6%uXd`cPGqlBvGgc z;>kn9AU$di9_mec^&)sH9(wVxP_I(xK|wE`oNr^KLIZicecyZE4Bt1q<_puh+e&! zC)(DWy4H6ZWG&Qv;*d&ErQT5o*iQ0owXf5BPyNuLTzi*o9~rp-&H@bW-0T{}xWF5F z8OM33n|OIgj=#YdrlG6}2o*nI)f!U;HzhU!`XuPdfHZ=Y3iSfw1->Mtd_K>n_@*R4+eFFYv=;L50h+JkJ|hV_Z-_79~Vr-k8CZuN$LB0_oJbTpj{v1T3*R zp#P_qQ$<|o8>x2cWgi$>e+Q0@(DZrygl`G#+|0>E3Aoy^iF#%PKjUvRaLAX;oy?dp zhFyzguQ^6N!bbT__t3=xi2AM0PQnBu70T?DpjjjAG8E<8VpO4SSIZAQ>R0_xz81I+ zo93^(Zf-QG3RpC^VzHrSP5Vr;0nN10o#ZbflmSG9nf}0dcNYPQ3b>$Gaky+ zrj)@-m<*r@2-6@INTWh(@O1oWqhUN!14Cwwz#badN5lJQ@Ta;i9@WO`rqgnMA>dJ$Kh77+4gdfE delta 913 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 Vdo#P~%7*J(u5S4~1CMkT{{tv*>EQqX delta 3066 zcmZWrZA=^I9e>U*=QDlY8r6W-zwNJ`w zY?k%A=lQ?g|Nd{!^YfwgKKCD7t_lLbx0hPQH*53mTINQ+J$xX;##n~%gg2#a87{_U z>@mBsH>WEyj+nz(Thh*$(?pID-a1QoRxrO-m%kD6xDApGNL-oJ=9#oa?SIGjL|NUO z7#DSRL=@7zqO%#97t)GuRb@H<$)Vt>BuZ`kY~{t{ho*ZwnNJuWZ{u6vZM$PF(PIIj z_O)&;9}ZNv&vJLl!m&CN0eX*C3|EJ@^HF}s9dn7rcg&Jasd_^fWIFfqJ7<|qdIR4% zOJa?D7hss*1sLIX12!2}`_XE{Gk2J>#qKQ@n+=IUlxW(%DY0jZMC%;6$i&*tkmx>b z*7AFX(@t9-Ftu8gjkC32kp{Kzvo#K5Szp@UWmR7_EERlXg!++M1EBrb8rs#43^zcI zTpPN1_WIeI!`Fv%ga&{Vwa_Mb(5=QU+1_WO+Fou?5LvoYlqLkK2yrQq5lVtVEywKx zkAA`JWGvd}+%B!#9_iEBGipN3Dzq2X+;QRJctYaiqg2k0EAXNx?`Ssc8jP}(_L=>Q znJOSpIYio}2w%?%Qc|Gkb=0PtM+KE~u%`Gex>FJ^s__ex@g$UmZdJ150;MkGut_Q; zR3U1nKHOR+5>Pevcs!X-C`vr8V2UG=Tq!_FTmVnqbaE)6jCGZ4oX{>f zcqVY~V~3q-dJ#lvKf(zBol{ggqKXg>4{YJneA*-D4%P^Jqt@i=W`?vOSDXoGAGmmk zP@_B8Y&gU!5FtUK+t7+traT1J0_zC;s9%e@qyAyoD%fo~vc?i`?;`gs4QM+&d+H7X z@uv)YFWR76)2%&p@9x5$Q0#_c!M|zx39ZQ1GiI#96AaW| zS?F8LG(&xAxpN3nH+l$H-3{4h*R31YR`1E!9HrDL3rpkQHk@i zBt#jVN&jzoPIYOggSEZL@9Im-NyyeASR^tR_{5xIR^nUiPDxBfwMk0p!SELgAHA=TfM1CDgM7|NXX)2gPQG%dD_EN>fKu1YOBQw4vhln=<`fy!4v|LOE8o^D?TO>d;HVN-lh z4R#a#e;t!|_rUm8o%+p`>y9x&fS!m;vMM~4e@vE<|F>arl}wXd>_UMwzL z%0^ULT=;He`&jD34?y6f{BM6&TzDr5i{iq!4Q3w5DYfMY~l#fxswTLvKuO_WVQuquB0rfIO$ZGPfV63k1eY}UPWHF zpApi6QKJ+;S?5kmLU#s?&{uJ58B{arQs}VgDJEUF8pkL_Z*?0ljL!%Xuh2&1A3->W zfMbLT2%`Xc1vmj=Pl%F2(@5ZCDVa)0qcFK}>nab|q>xU><57mb092#T50|Da&Shgb zbIAB744N-yNx>P(9W7|SmWIH!u|lQ4;Hmc~4*ywN3Z6lJU?X8`m$;%S^4xh&7iw+|LP z^$)x|kZCg;1}e1QwR8?IKJ!As-&pX}l{o%D?&N9(scyW|cC{nlvDn&MaJ2xd;0qS2 z0`TtYVD3eDx2s`pV8vO#h@Ar-bBKT7(SYQK?&yIHcQ>2_>1}g{Mw;EKSTF z+@u6m&>j7Pl7x1`F$XiTbg=*t`XzvY4GP{bbna_#9*Zzar+SW$M3UOW_I+D!A3a*z z_Gf1d{T`4$6?IeWM4CmIK==m2MFb1NG{PQ)Zz5n5=(gu&IV~h4qq#~a2TO%af{xLb zP{`;UoXm7?1AYVx^jBTcVB@h-UHrj^pX z<9Ime@tUDqPvQmGXrod9o({^txjty^Y+5)(e+cIAFjw{iteP2y`5W24LiYce^n6BI qKPR4b%V`s{^UB24>HPFMIUR(}I&bkaz1Mnf_FeD$7lEbm75@b_>msWF diff --git a/addons/zoo/models/zoo_animal.py b/addons/zoo/models/zoo_animal.py index 5a8ffb4..89d7de0 100644 --- a/addons/zoo/models/zoo_animal.py +++ b/addons/zoo/models/zoo_animal.py @@ -1,21 +1,24 @@ # -*- coding: utf-8 -*- -from odoo import api, fields, models, tools, _ -from odoo.exceptions import UserError, ValidationError +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError import datetime + class ZooAnimal(models.Model): _name = "zoo.animal" _description = "Animal in the zoo" - name = fields.Char('Animal Name', + name = fields.Char( + string='Animal Name', required=True) - - description = fields.Text('Description') - - dob = fields.Date('DOB', + + description = fields.Text(string='Description') + + dob = fields.Date( + string='DOB', required=False) - + gender = fields.Selection([ ('male', 'Male'), ('female', 'Female') @@ -23,8 +26,9 @@ class ZooAnimal(models.Model): string='Gender', default='male', required=True) - - feed_time = fields.Datetime('Feed Time', + + feed_time = fields.Datetime( + string='Feed Time', copy=False) is_alive = fields.Boolean('Is Alive', diff --git a/addons/zoo/models/zoo_health_record.py b/addons/zoo/models/zoo_health_record.py index 402840e..86c1c93 100644 --- a/addons/zoo/models/zoo_health_record.py +++ b/addons/zoo/models/zoo_health_record.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -from odoo import api, fields, models, tools, _ -from odoo.exceptions import UserError, ValidationError +from odoo import api, fields, models +from odoo.exceptions import ValidationError from odoo.tools import html2plaintext -import datetime class ZooHealthRecord(models.Model): _name = "zoo.health.record" diff --git a/addons/zoo/models/zoo_husbandry_task.py b/addons/zoo/models/zoo_husbandry_task.py index 25336ce..96d3776 100644 --- a/addons/zoo/models/zoo_husbandry_task.py +++ b/addons/zoo/models/zoo_husbandry_task.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -from odoo import api, fields, models, tools, Command, _ +from odoo import api, fields, models, Command, _ from odoo.exceptions import UserError, ValidationError from odoo.tools import html2plaintext -import datetime class ZooHusbandryTask(models.Model): _name = 'zoo.husbandry.task' @@ -11,12 +10,13 @@ class ZooHusbandryTask(models.Model): _inherit = ['mail.thread', 'mail.activity.mixin'] _rec_name = 'display_name_custom' - name = fields.Char(string='Reference', + name = fields.Char( + string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New')) - + # Ví dụ: "Lion Cage - 25/11/2025" display_name_custom = fields.Char( string='Subject', @@ -24,21 +24,24 @@ class ZooHusbandryTask(models.Model): store=True ) - cage_id = fields.Many2one(comodel_name='zoo.cage', + cage_id = fields.Many2one( + comodel_name='zoo.cage', domain="[('active', '=', True)]", string='Cage/Enclosure', required=True, tracking=True) - - date = fields.Date(string='Date', + + date = fields.Date( + string='Date', default=fields.Date.context_today, required=True) - - user_id = fields.Many2one(comodel_name='res.users', + + user_id = fields.Many2one( + comodel_name='res.users', string='Assigned To', default=lambda self: self.env.user, required=True) - + task_type = fields.Selection([ ('routine', 'Daily Routine'), ('enrichment', 'Enrichment'), @@ -51,12 +54,14 @@ class ZooHusbandryTask(models.Model): default='routine', required=True) - task_line_ids = fields.One2many(comodel_name='zoo.husbandry.task.line', + task_line_ids = fields.One2many( + comodel_name='zoo.husbandry.task.line', inverse_name='task_id', string='Checklist', required=True) - - keeper_note = fields.Html(string='Observations/Issues', + + keeper_note = fields.Html( + string='Observations/Issues', help='Ghi chú của Keeper', sanitize=True, strip_style=False, @@ -65,13 +70,23 @@ class ZooHusbandryTask(models.Model): state = fields.Selection([ ('draft', 'To Do'), ('in_progress', 'In Progress'), + ('to_approve', 'Waiting Approval'), ('done', 'Done'), ('cancel', 'Cancelled') - ], string='Status', + ], + ondelete={'to_approve': 'set default'}, + string='Status', default='draft', tracking=True, group_expand='_expand_groups') + # Xác định Approver + approver_id = fields.Many2one( + comodel_name='res.users', + string='Approver', + compute='_compute_approver', + store=True) + # --- Logic đặt tên: Mã phiếu tự động @api.model def create(self, vals): @@ -79,7 +94,7 @@ class ZooHusbandryTask(models.Model): # HUSB là mã sequence chúng ta sẽ định nghĩa trong XML data vals['name'] = self.env['ir.sequence'].next_by_code('zoo.husbandry.task') or _('New') return super(ZooHusbandryTask, self).create(vals) - + # --- Logic đặt tên cho Display name @api.depends('cage_id', 'date', 'task_type') def _compute_display_name_custom(self): @@ -93,19 +108,12 @@ class ZooHusbandryTask(models.Model): @api.model def _expand_groups(self, states, domain, order=None): """Force display all state columns in Kanban, even if empty""" - return ['draft', 'in_progress', 'done', 'cancel'] + return ['draft', 'in_progress', 'to_approve', 'done', 'cancel'] # --- ACTIONS --- def action_start(self): self.state = 'in_progress' - def action_done(self): - # Validation: Không cho Done nếu chưa check hết các mục bắt buộc - unfinished_lines = self.task_line_ids.filtered(lambda l: l.required and not l.is_done) - if unfinished_lines: - raise ValidationError(_("You must complete all required checklist items before finishing!")) - self.state = 'done' - def action_cancel(self): self.state = 'cancel' @@ -117,10 +125,9 @@ class ZooHusbandryTask(models.Model): def _check_keeper_note_content(self): for record in self: if record.keeper_note: - # Chuyển HTML sang text thuần để kiểm tra độ dài thực text_content = html2plaintext(record.keeper_note).strip() if not text_content: - raise ValidationError("Vui lòng nhập nội dung chi tiết điều trị (không được để trống hoặc chỉ nhập khoảng trắng).") + raise ValidationError(_("Please enter meaningful content (not just spaces).")) # Load template task mỗi khi chọn Chuồng @api.onchange('cage_id') @@ -144,32 +151,109 @@ class ZooHusbandryTask(models.Model): lines_commands.append(Command.create({ 'name': template.name, 'required': template.required, - 'is_done': False, # Mặc định chưa làm + 'is_done': False, # Mặc định chưa làm })) - + # Bước 3: Gán vào field One2many của Task self.task_line_ids = lines_commands + # --- Compute Approver --- + @api.depends('user_id') + def _compute_approver(self): + for record in self: + # Logic: Tìm Employee ứng với User -> Lấy Parent (Manager) -> Lấy User của Manager + if record.user_id and record.user_id.employee_id and record.user_id.employee_id.parent_id: + record.approver_id = record.user_id.employee_id.parent_id.user_id + else: + record.approver_id = False + + # --- Approval Workflow --- + # --- 1. Keepers gửi yêu cầu phê duyệt --- + def action_request_approval(self): + """Keeper bấm nút này để xin duyệt""" + for record in self: + if not record.approver_id: + raise UserError(_("You don't have a direct manager defined in HR Settings to approve this task.")) + + # THÊM LOGIC KIỂM TRA CHECKLIST + unfinished_lines = record.task_line_ids.filtered(lambda l: l.required and not l.is_done) + if unfinished_lines: + raise ValidationError(_("You must complete all required checklist items before finishing!")) + + # Chuyển trạng thái + record.state = 'to_approve' + + # Tạo Activity cho Sếp + # record.activity_schedule( + # user_id=record.approver_id.id, + # summary=f"Approval Request: {record.name}", + # note=f"Please approve husbandry task for {record.cage_id.name}" + # ) + + + # --- 2. Sếp phê duyệt hoặc từ chối --- + # --- 2.1. Sếp phê duyệt --- + def action_approve(self): + """Approver bấm nút này để phê duyệt""" + for record in self: + if self.env.user != record.approver_id: + raise UserError(_("Only the assigned approver can approve this task.")) + + record.state = 'done' + + # Tự động đánh dấu hoàn thành Activity đã giao cho sếp + activities = record.activity_ids.filtered( + lambda a: a.activity_type_id == self.env.ref('mail.mail_activity_data_todo') + ) + + if activities: + activities.action_feedback(feedback="Approved") + + # --- 2.2. Sếp từ chối --- + def action_reject(self): + """Approver bấm nút này để từ chối""" + for record in self: + if self.env.user != record.approver_id: + raise UserError(_("Only the assigned approver can reject this task.")) + + record.state = 'draft' # ← GIẢM INDENT (chuyển ra ngoài khối if) + + # Gửi tin nhắn lý do từ chối + activities = record.activity_ids.filtered( + lambda a: a.activity_type_id == self.env.ref('mail.mail_activity_data_todo') + ) + + if activities: + activities.action_feedback(feedback="Rejected") + + # Gửi tin nhắn lý do từ chối + record.message_post(body="Task was refused by Manager. Please check and submit again.") + + # --- CHECKLIST --- class ZooHusbandryTaskLine(models.Model): _name = 'zoo.husbandry.task.line' _description = 'Task Checklist' - - task_id = fields.Many2one(comodel_name='zoo.husbandry.task', + task_id = fields.Many2one( + comodel_name='zoo.husbandry.task', string='Task', required=True, ondelete='cascade') - - name = fields.Char(string='Description', + + name = fields.Char( + string='Description', required=True) - is_done = fields.Boolean(string='Done', + is_done = fields.Boolean( + string='Done', default=False) - - required = fields.Boolean(string='Required', + + required = fields.Boolean( + string='Required', default=True, help="If checked, this item must be done to finish the task.") - - remark = fields.Char(string='Remark', + + remark = fields.Char( + string='Remark', help="Quick note issues here (e.g. Broken lock)") \ No newline at end of file diff --git a/addons/zoo/report/zoo_report_action.xml b/addons/zoo/report/zoo_report_action.xml new file mode 100644 index 0000000..b32fa09 --- /dev/null +++ b/addons/zoo/report/zoo_report_action.xml @@ -0,0 +1,16 @@ + + + + + Zoo Animals (PDF) + zoo.animal + qweb-pdf + zoo.report_animal_template + zoo.report_animal_template + 'Animal - %s' % (object.name) + + + report + + + \ No newline at end of file diff --git a/addons/zoo/report/zoo_report_template.xml b/addons/zoo/report/zoo_report_template.xml new file mode 100644 index 0000000..7a2475e --- /dev/null +++ b/addons/zoo/report/zoo_report_template.xml @@ -0,0 +1,80 @@ + + + + + + + + \ No newline at end of file diff --git a/addons/zoo/security/zoo_security.xml b/addons/zoo/security/zoo_security.xml new file mode 100644 index 0000000..ecdb6f1 --- /dev/null +++ b/addons/zoo/security/zoo_security.xml @@ -0,0 +1,39 @@ + + + + + Zoo Management + 10 + + + + Keeper + + + + + + Manager + + + + + + + + + Zoo Task: Personal + + + [('user_id', '=', user.id)] + + + + Zoo Task: Manager Hierarchy + + + ['|', ('user_id', '=', user.id), ('approver_id', '=', user.id)] + + + + \ No newline at end of file diff --git a/addons/zoo/views/zoo_husbandry_task_views.xml b/addons/zoo/views/zoo_husbandry_task_views.xml index 615ff8d..30b964a 100644 --- a/addons/zoo/views/zoo_husbandry_task_views.xml +++ b/addons/zoo/views/zoo_husbandry_task_views.xml @@ -41,16 +41,34 @@