Tối ưu code, thêm comments đây đủ

Them epr_sequence_data.xml trong folder data để tự tạo tên PR
Sửa thêm một vài logic:
   + Nút Reset to draft cho phép reset lại phiếu nếu muốn sửa lại sau khi submit
   + Bổ sung Group: User --> Manager --> Purchasing Officer --> Admin
   + Thêm Record rules chặt chẽ cho mỗi nhóm
...
This commit is contained in:
mtpc4s9 2025-12-03 23:59:20 +07:00
parent 1925d8dd44
commit 25c864ef28
32 changed files with 1971 additions and 56 deletions

View File

@ -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 <list> 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)
<record id="view_epr_request_list" model="ir.ui.view">
<field name="name">epr.request.list</field>
<field name="model">epr.request</field>
<field name="arch" type="xml">
<list string="Danh sách Yêu cầu" decoration-info="state == 'draft'" decoration-warning="state == 'to_approve'" decoration-success="state == 'approved'" sample="1">
<field name="name"/>
<field name="employee_id" widget="many2one_avatar_user"/>
<field name="department_id" optional="show"/>
<field name="date_required"/>
<field name="amount_total" sum="Tổng giá trị" decoration-bf="1"/>
<field name="state" widget="badge" decoration-success="state == 'approved'" decoration-danger="state == 'rejected'"/>
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
</list>
</field>
</record>
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
<form string="Yêu cầu Mua sắm">
<header>
<button name="action_submit" string="Gửi duyệt" type="object" class="oe_highlight" invisible="state!= 'draft'"/>
<button name="action_approve" string="Phê duyệt" type="object" class="oe_highlight" invisible="state!= 'to_approve'" groups="epr_management.group_epr_approver"/>
<button name="%(action_epr_reject_wizard)d" string="Từ chối" type="action" invisible="state!= 'to_approve'" groups="epr_management.group_epr_approver"/>
<button name="action_create_rfqs" string="Tạo Báo giá" type="object" class="btn-primary" invisible="state!= 'approved'" groups="purchase.group_purchase_user"/>
<field name="state" widget="statusbar" statusbar_visible="draft,to_approve,approved,done"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="employee_id"/>
<field name="department_id"/>
</group>
<group>
<field name="date_required"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Sản phẩm">
<field name="line_ids" widget="section_and_note_one2many">
<list editable="bottom">
<field name="product_id"/>
<field name="name"/>
<field name="quantity"/>
<field name="uom_id"/>
<field name="estimated_cost"/>
<field name="total_cost"/>
<field name="supplier_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
6.3 View Tìm kiếm và Search Panel (Mobile Optimized)
<record id="view_epr_request_search" model="ir.ui.view">
<field name="name">epr.request.search</field>
<field name="model">epr.request</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="employee_id"/>
<field name="product_id" filter_domain="[('line_ids.product_id', 'ilike', self)]"/>
<filter string="Yêu cầu của tôi" name="my_requests" domain="[('employee_id.user_id', '=', uid)]"/>
<filter string="Chờ duyệt" name="to_approve" domain="[('state', '=', 'to_approve')]"/>
<searchpanel>
<field name="state" icon="fa-tasks" enable_counters="1"/>
<field name="department_id" icon="fa-building" enable_counters="1"/>
</searchpanel>
</search>
</field>
</record>
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.

2
addons/epr/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@ -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) Mua hàng (External).
- Cho phép gom nhiều PRs vào một RFQs 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ệ 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',
}

Binary file not shown.

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
noupdate="1": Rất quan trọng.
Nó đảm bảo khi bạn nâng cấp (upgrade) module, số thứ tự không bị reset về 1.
-->
<data noupdate="1">
<record id="seq_epr_purchase_request" model="ir.sequence">
<field name="name">Purchase Request</field>
<!-- CODE này phải khớp tuyệt đối với code trong file Python -->
<field name="code">epr.purchase.request</field>
<!-- Định dạng tiền tố: PR/2023/ -->
<field name="prefix">PR/%(year)s/</field>
<!-- Độ dài phần số: 5 số (00001) -->
<field name="padding">5</field>
<!-- Số bắt đầu -->
<field name="number_next">1</field>
<field name="number_increment">1</field>
<!-- False để dùng chung cho toàn hệ thống, hoặc gán company nếu cần -->
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1 @@
from . import epr_purchase_request

Binary file not shown.

View File

@ -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 người duyệt.
Tách ra để dễ tái sử dụng hoặc override sau này
( 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' 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

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- RULE 1: User chỉ thấy Request của chính mình -->
<record
id="rule_epr_user_own_documents"
model="ir.rule">
<field name="name">ePR: User sees own requests</field>
<field
name="model_id"
ref="model_epr_purchase_request"/>
<field name="domain_force">[('employee_id.user_id','=',user.id)]</field>
<field
name="groups"
eval="[(4, ref('epr.group_epr_user'))]"/>
</record>
<!-- RULE 2: Manager thấy Request của mình VÀ Request cần mình duyệt -->
<!-- Logic: Thấy của mình HOẶC (Mình nằm trong danh sách approver_ids) HOẶC (Mình là manager của phòng ban đó) -->
<record id="rule_epr_manager_approver" model="ir.rule">
<field name="name">ePR: Manager sees department requests</field>
<field name="model_id" ref="model_epr_purchase_request"/>
<field name="domain_force">['|', '|',
('employee_id.user_id','=',user.id),
('approver_ids', 'in', user.id),
('department_id.manager_id.user_id', '=', user.id)
]</field>
<field name="groups" eval="[(4, ref('epr.group_epr_manager'))]"/>
</record>
<!-- RULE 3: Purchasing Officer thấy Request của mình VÀ Request đã được DUYỆT -->
<!-- Purchasing Officer không cần thấy các bản nháp (Draft) của người khác -->
<record
id="rule_epr_officer_all_approved"
model="ir.rule">
<field name="name">ePR: Officer sees approved requests</field>
<field
name="model_id"
ref="model_epr_purchase_request"/>
<field name="domain_force">['|',
('employee_id.user_id','=',user.id),
('state', 'in', ['approved', 'in_progress', 'done'])
]</field>
<field
name="groups"
eval="[(4, ref('epr.group_epr_purchasing_officer'))]"/>
</record>
<!-- RULE 4: Administrator thấy TẤT CẢ -->
<record
id="rule_epr_admin_all"
model="ir.rule">
<field name="name">ePR: Admin sees all</field>
<field
name="model_id"
ref="model_epr_purchase_request"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field
name="groups"
eval="[(4, ref('epr.group_epr_admin'))]"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- 1. Tạo Category cho Module ePR -->
<record
model="ir.module.category"
id="module_category_epr">
<field
name="name">Purchase Request (ePR)</field>
<field name="description">Quản lý phân quyền cho yêu cầu mua sắm điện tử.</field>
<field name="sequence">15</field>
</record>
<!-- 2. Group: Standard User (Requester) -->
<!-- Đây là nhóm cơ bản nhất, ai cũng có thể thuộc nhóm này -->
<record
id="group_epr_user"
model="res.groups">
<field name="name">User (Requester)</field>
<field
name="category_id"
ref="module_category_epr"/>
<field
name="implied_ids"
eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Nhân viên tạo yêu cầu mua sắm.</field>
</record>
<!-- 3. Group: Management (Approver) -->
<!-- Nhóm này kế thừa quyền của User (có thể tự tạo request) -->
<record
id="group_epr_manager"
model="res.groups">
<field name="name">Manager (Approver)</field>
<field
name="category_id"
ref="module_category_epr"/>
<field
name="implied_ids"
eval="[(4, ref('group_epr_user'))]"/>
<field name="comment">Trưởng bộ phận/Ban giám đốc phê duyệt yêu cầu.</field>
</record>
<!-- 4. Group: Purchasing Officer -->
<!-- Nhóm này chuyên biệt cho phòng mua hàng. Kế thừa User để tạo request nội bộ -->
<record
id="group_epr_purchasing_officer"
model="res.groups">
<field name="name">Purchasing Officer</field>
<field
name="category_id"
ref="module_category_epr"/>
<field
name="implied_ids"
eval="[(4, ref('group_epr_user'))]"/>
<field name="comment">Nhân viên thu mua xử lý các yêu cầu đã duyệt.</field>
</record>
<!-- 5. Group: Administrator -->
<!-- Kế thừa tất cả các nhóm trên để có toàn quyền -->
<record
id="group_epr_admin"
model="res.groups">
<field name="name">Administrator</field>
<field
name="category_id"
ref="module_category_epr"/>
<field
name="implied_ids"
eval="[(4, ref('group_epr_manager')), (4, ref('group_epr_purchasing_officer'))]"/>
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</data>
</odoo>

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_epr_purchase_request_user ePR Request User model_epr_purchase_request group_epr_user 1 1 1 0
3 access_epr_purchase_request_line_user ePR Line User model_epr_purchase_request_line group_epr_user 1 1 1 0
4 access_epr_reject_wizard_user ePR Reject Wizard User model_epr_reject_wizard group_epr_user 1 0 0 0
5 access_epr_purchase_request_manager ePR Request Manager model_epr_purchase_request group_epr_manager 1 1 1 0
6 access_epr_purchase_request_line_manager ePR Line Manager model_epr_purchase_request_line group_epr_manager 1 1 1 0
7 access_epr_reject_wizard_manager ePR Reject Wizard Manager model_epr_reject_wizard group_epr_manager 1 1 1 1
8 access_epr_purchase_request_officer ePR Request Officer model_epr_purchase_request group_epr_purchasing_officer 1 1 1 0
9 access_epr_purchase_request_line_officer ePR Line Officer model_epr_purchase_request_line group_epr_purchasing_officer 1 1 1 0
10 access_epr_purchase_request_admin ePR Request Admin model_epr_purchase_request group_epr_admin 1 1 1 1
11 access_epr_purchase_request_line_admin ePR Line Admin model_epr_purchase_request_line group_epr_admin 1 1 1 1
12 access_epr_reject_wizard_admin ePR Reject Wizard Admin model_epr_reject_wizard group_epr_admin 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!--
===================================================================
TOP LEVEL MENU
Menu cấp cao nhất, hiển thị trên thanh ứng dụng chính (App Switcher)
===================================================================
-->
<menuitem
id="menu_epr_root"
name="eProcurement"
sequence="10"
web_icon="epr,static/description/icon.png"
groups="epr.group_epr_user"/>
<!-- Chỉ hiện cho nhóm User (và Manager vì Manager kế thừa User) -->
<!--
===================================================================
CATEGORY MENU
Menu cấp 2, dùng để gom nhóm các chức năng (Requests, Configuration...)
===================================================================
-->
<menuitem
id="menu_epr_purchase_request_category"
name="Purchase Requests"
parent="menu_epr_root"
sequence="10"/>
<!--
===================================================================
ACTION MENUS
Menu cấp 3, bấm vào sẽ mở ra view (Action)
===================================================================
-->
<!-- Menu: My Requests (Action đã định nghĩa ở file view) -->
<menuitem
id="menu_epr_purchase_request_act"
name="My Requests"
parent="menu_epr_purchase_request_category"
action="action_epr_purchase_request"
sequence="10"/>
<!-- Gợi ý: Menu To Approve (Dành cho Manager) -->
<!-- Bạn có thể thêm action riêng cho Manager ở đây sau này -->
<menuitem
id="menu_epr_request_to_approve"
name="To Approve"
parent="menu_epr_purchase_request_category"
action="action_epr_purchase_request_to_approve"
sequence="20"
groups="group_epr_manager"/>
<!--
===================================================================
CONFIGURATION MENU
Thường đặt sequence cao để nằm cuối
===================================================================
-->
<menuitem
id="menu_epr_config"
name="Configuration"
parent="menu_epr_root"
sequence="100"
groups="epr.group_epr_manager"/>
</data>
</odoo>

View File

@ -0,0 +1,230 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!--
===================================================================
1. SEARCH VIEW
Bộ lọc và thanh tìm kiếm
===================================================================
-->
<record id="view_epr_purchase_request_search" model="ir.ui.view">
<field name="name">epr.purchase.request.search</field>
<field name="model">epr.purchase.request</field>
<field name="arch" type="xml">
<search string="Search Purchase Request">
<field name="name" string="Reference"/>
<field name="employee_id"/>
<field name="department_id"/>
<!-- Filters -->
<filter string="My Requests" name="my_requests" domain="[('employee_id.user_id', '=', uid)]"/>
<filter string="To Approve by Me" name="to_approve_by_me" domain="[('approver_ids', 'in', uid)]"/>
<separator/>
<filter string="To Approve" name="to_approve" domain="[('state', '=', 'to_approve')]"/>
<filter string="Approved" name="approved" domain="[('state', '=', 'approved')]"/>
<separator/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<!-- Group By -->
<group expand="0" string="Group By">
<filter string="Employee" name="employee" domain="[]" context="{'group_by': 'employee_id'}"/>
<filter string="Department" name="department" domain="[]" context="{'group_by': 'department_id'}"/>
<filter string="Status" name="status" domain="[]" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!--
===================================================================
2. LIST VIEW
Danh sách tổng quan
===================================================================
-->
<record id="view_epr_purchase_request_list" model="ir.ui.view">
<field name="name">epr.purchase.request.list</field>
<field name="model">epr.purchase.request</field>
<field name="arch" type="xml">
<list string="Purchase Requests"
decoration-muted="state == 'cancel'"
decoration-info="state == 'to_approve'"
decoration-danger="state == 'rejected'"
sample="1">
<field name="name"/>
<field name="date_required"/>
<field name="employee_id" widget="many2one_avatar_user"/>
<field name="department_id" optional="show"/>
<field name="priority" widget="priority" optional="hide"/>
<field name="estimated_total" widget="monetary" options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="currency_id" invisible="1"/>
<field name="state"
widget="badge"
decoration-success="state == 'approved' or state == 'done'"
decoration-warning="state == 'to_approve'"
decoration-danger="state == 'rejected'"/>
</list>
</field>
</record>
<!--
===================================================================
3. FORM VIEW
Giao diện chi tiết
===================================================================
-->
<record id="view_epr_purchase_request_form" model="ir.ui.view">
<field name="name">epr.purchase.request.form</field>
<field name="model">epr.purchase.request</field>
<field name="arch" type="xml">
<form string="Purchase Request">
<header>
<!-- Nút Submit: Chỉ hiện khi Draft -->
<button name="action_submit"
string="Submit for Approval"
type="object"
class="oe_highlight"
invisible="state != 'draft'"/>
<!-- Nút Approve: Dành cho Manager -->
<button name="action_approve"
string="Approve"
type="object"
class="oe_highlight btn-success"
invisible="state != 'to_approve'"
groups="epr.group_epr_manager"/>
<!-- Nút Reject: Gọi Wizard -->
<button name="action_reject_wizard"
string="Reject"
type="object"
class="btn-danger"
invisible="state != 'to_approve'"
groups="epr.group_epr_manager"/>
<!-- Nút Reset: Cho User sửa lại khi đã submit nhầm -->
<button name="action_reset_to_draft"
string="Reset to Draft"
type="object"
class="btn-warning"
invisible="state not in ['to_approve']"/>
<field name="state" widget="statusbar" statusbar_visible="draft,to_approve,approved,done"/>
</header>
<sheet>
<!-- Ribbons trạng thái -->
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
<widget name="web_ribbon" title="Rejected" bg_color="bg-danger" invisible="state != 'rejected'"/>
<div class="oe_title">
<label for="name" string="Request Reference"/>
<h1>
<field name="name" placeholder="e.g. PR/2023/001" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="employee_id" options="{'no_create': True}"/>
<field name="department_id"/>
<field name="currency_id" groups="base.group_multi_currency"/>
</group>
<group>
<field name="date_required"/>
<field name="priority" widget="priority"/>
<!-- Lý do từ chối chỉ hiện khi bị từ chối -->
<field name="rejection_reason"
invisible="state != 'rejected'"
readonly="1"
class="text-danger"/>
</group>
</group>
<notebook>
<page string="Products" name="products">
<!-- List sản phẩm (One2many) -->
<field name="line_ids"
readonly="state in ['to_approve', 'approved', 'done', 'rejected', 'cancel']">
<list editable="bottom">
<!-- Free Text cho Staff nhập -->
<field name="name" string="Product Name"/>
<field name="product_description" optional="show"/>
<field name="quantity"/>
<field name="uom_name" string="UoM"/>
<field name="estimated_price"/>
<field name="subtotal_estimated" sum="Total"/>
<field name="currency_id" invisible="1"/>
<field name="vendor_name" optional="hide"/>
<!-- Product ID ẩn, dành cho Purchasing map sau này -->
<field name="product_id" optional="hide" groups="epr.group_epr_purchasing_officer"/>
</list>
</field>
<group class="oe_subtotal_footer oe_right">
<field name="estimated_total" widget="monetary"/>
</group>
</page>
<page string="Other Information" name="other_info">
<group>
<group string="Approvals">
<field name="date_submitted" readonly="1"/>
<field name="date_approved" readonly="1"/>
<field name="approved_by_id" readonly="1"/>
<field name="approver_ids" widget="many2many_tags" readonly="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
<!-- Chatter -->
<chatter reload_on_post="True"/>
</form>
</field>
</record>
<!--
===================================================================
4. WINDOW ACTION
Định nghĩa hành động mở view (Được gọi bởi menu)
===================================================================
-->
<record id="action_epr_purchase_request" model="ir.actions.act_window">
<field name="name">Purchase Requests</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">epr.purchase.request</field>
<field name="view_mode">list,form,search</field>
<field name="search_view_id" ref="view_epr_purchase_request_search"/>
<field name="context">{'search_default_my_requests': 1}</field>
<!-- <field name="context">{'search_default_my_requests': 1, 'search_default_to_approve_by_me': 1}</field> -->
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first Purchase Request!
</p>
</field>
</record>
<!-- Action cho Manager: Chỉ hiện PR cần duyệt -->
<record id="action_epr_purchase_request_to_approve" model="ir.actions.act_window">
<field name="name">To Approve</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">epr.purchase.request</field>
<field name="view_mode">list,form,search</field>
<field name="search_view_id" ref="view_epr_purchase_request_search"/>
<field name="context">{'search_default_to_approve_by_me': 1, 'search_default_to_approve': 1}</field>
<field name="domain">[('state', '=', 'to_approve')]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No requests waiting for your approval!
</p>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1 @@
from . import epr_reject_wizard

View File

@ -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 do từ chối cho một Purchase Request.
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 đ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ử 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
}

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!--
===================================================================
VIEW DEFINITION
Mô tả giao diện Form của Wizard (Popup)
===================================================================
-->
<record id="view_epr_reject_wizard_form" model="ir.ui.view">
<field name="name">epr.reject.wizard.form</field>
<field name="model">epr.reject.wizard</field>
<field name="arch" type="xml">
<form string="Reject Purchase Request">
<!--
UX Improvement:
Hiển thị cảnh báo màu vàng để nhắc nhở Line Manager
rằng họ sắp thực hiện hành động từ chối.
-->
<div class="alert alert-warning" role="alert">
<div class="d-flex">
<div class="me-2">
<i class="fa fa-exclamation-triangle fa-2x"/>
</div>
<div>
<strong>Attention:</strong> You are about to reject this request.<br/>
Please provide a clear reason so the requester can understand and correct it.
</div>
</div>
</div>
<group>
<!--
Invisible Field:
Trường này ẩn đi nhưng cần thiết để giữ ID của bản ghi gốc.
Dữ liệu được điền tự động bởi hàm default_get trong Python.
-->
<field name="request_id" invisible="1"/>
<!--
Reason Field:
Nơi nhập lý do. Sử dụng widget="text" không bắt buộc nếu
field Python đã là Text, nhưng giữ lại để tường minh.
-->
<field name="reason"
widget="text"
placeholder="e.g., Budget exceeded / Item not standard / Quantity too high..."
required="1"/> <!-- Bắt buộc nhập ở mức giao diện -->
</group>
<!--
Footer:
Khu vực chứa các nút hành động của Wizard.
-->
<footer>
<!--
Confirm Button:
Gọi hàm action_confirm_reject trong model epr.reject.wizard.
Class btn-danger (màu đỏ) để nhấn mạnh hành động phủ quyết.
-->
<button string="Confirm Reject"
name="action_confirm_reject"
type="object"
class="btn-danger"
data-hotkey="q"/>
<!--
Cancel Button:
special="cancel" giúp đóng popup mà không làm gì cả.
-->
<button string="Cancel"
class="btn-secondary"
special="cancel"
data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<!--
===================================================================
ACTION DEFINITION
Định nghĩa hành động để mở Wizard này.
Action này sẽ được gọi từ nút "Reject" trên form epr.purchase.request
===================================================================
-->
<record id="action_epr_reject_wizard" model="ir.actions.act_window">
<field name="name">Reject Purchase Request</field>
<field name="res_model">epr.reject.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_epr_reject_wizard_form"/>
<field name="target">new</field> <!-- Quan trọng: 'new' để mở dạng Popup -->
</record>
</data>
</odoo>

View File

@ -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,
}
}

View File

@ -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',

View File

@ -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"

View File

@ -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)")

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="action_report_zoo_animal" model="ir.actions.report">
<field name="name">Zoo Animals (PDF)</field>
<field name="model">zoo.animal</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">zoo.report_animal_template</field>
<field name="report_file">zoo.report_animal_template</field>
<field name="print_report_name">'Animal - %s' % (object.name)</field>
<field name="binding_model_id" ref="model_zoo_animal"/>
<field name="binding_type">report</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="report_animal_document">
<t t-call="web.external_layout">
<div class="page">
<div class="oe_structure"/>
<!-- Header with Animal Name -->
<div class="row mb-4">
<div class="col-12 text-center">
<h2 class="mt-3">
<strong>Animal Report</strong>
</h2>
<h3 class="text-primary">
<span t-field="o.name"/>
</h3>
</div>
</div>
<!-- Main Content: Avatar + Info -->
<div class="row mt-4">
<!-- Left Column: Avatar -->
<div class="col-6" style="text-align: right;">
<t t-if="o.image">
<img t-att-src="image_data_uri(o.image)"
style="max-height: 150px; max-width: 100%; border: 1px solid #ccc; padding: 2px;"/>
</t>
<t t-else="">
<span class="text-muted fst-italic">No Image Available</span>
</t>
</div>
<!-- Right Column: Information -->
<div class="col-6">
<div class="border rounded p-3 bg-light">
<h5 class="mb-3">
<i class="fa fa-info-circle"/> Basic Information
</h5>
<table class="table table-sm table-borderless">
<tr>
<td style="width: 30%;"><strong>Species:</strong></td>
<td><span t-field="o.creature_id"/></td>
</tr>
<tr>
<td><strong>Gender:</strong></td>
<td><span t-field="o.gender"/></td>
</tr>
<tr>
<td><strong>Age:</strong></td>
<td><span t-field="o.age"/></td>
</tr>
<tr>
<td><strong>Cage/Enclosure:</strong></td>
<td><span t-field="o.cage_id"/></td>
</tr>
<tr t-if="o.dob">
<td><strong>Date of Birth:</strong></td>
<td><span t-field="o.dob" t-options="{'widget': 'date'}"/></td>
</tr>
</table>
</div>
</div>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="report_animal_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-set="lang" t-value="o.create_uid.lang"/>
<t t-call="zoo.report_animal_document" t-lang="lang"/>
</t>
</t>
</template>
</data>
</odoo>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="module_category_zoo" model="ir.module.category">
<field name="name">Zoo Management</field>
<field name="sequence">10</field>
</record>
<record id="group_zoo_keeper" model="res.groups">
<field name="name">Keeper</field>
<field name="category_id" ref="module_category_zoo"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_zoo_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_zoo"/>
<field name="implied_ids" eval="[(4, ref('group_zoo_keeper'))]"/>
</record>
</data>
<data noupdate="1">
<record id="rule_zoo_task_keeper_personal" model="ir.rule">
<field name="name">Zoo Task: Personal</field>
<field name="model_id" ref="model_zoo_husbandry_task"/>
<field name="groups" eval="[(4, ref('group_zoo_keeper'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
<record id="rule_zoo_task_manager_hierarchy" model="ir.rule">
<field name="name">Zoo Task: Manager Hierarchy</field>
<field name="model_id" ref="model_zoo_husbandry_task"/>
<field name="groups" eval="[(4, ref('group_zoo_manager'))]"/>
<field name="domain_force">['|', ('user_id', '=', user.id), ('approver_id', '=', user.id)]</field>
</record>
</data>
</odoo>

View File

@ -41,16 +41,34 @@
<button name="action_start" string="Start Working" type="object" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_done" string="Mark as Done" type="object" class="btn-success"
<!-- <button name="action_done" string="Mark as Done" type="object" class="btn-success"
invisible="state != 'in_progress'"/> -->
<!-- Button Get Approval for Zoo Keepers -->
<button name="action_request_approval" string="Get Approval"
type="object" class="btn-primary"
invisible="state != 'in_progress'"/>
<!-- Button Approve for Zoo Manager -->
<button name="action_approve" string="Approve"
type="object" class="btn-success"
invisible="state != 'to_approve'"
groups="zoo.group_zoo_manager"/>
<!-- Button Refuse for Zoo Manager -->
<button name="action_reject" string="Reject"
type="object" class="btn-danger"
invisible="state != 'to_approve'"
groups="zoo.group_zoo_manager"/>
<!-- Button Cancel for Zoo Keepers -->
<button name="action_cancel" string="Cancel" type="object"
invisible="state in ('done', 'cancel')"/>
<button name="action_draft" string="Reset to Draft" type="object"
invisible="state != 'cancel'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,in_progress,done"/>
<field name="state" widget="statusbar" statusbar_visible="draft,in_progress,to_approve,done"/>
</header>
<sheet>
<widget name="web_ribbon" title="Done" bg_color="bg-success" invisible="state != 'done'"/>
@ -204,6 +222,14 @@
</record>
<record id="action_zoo_task_to_approve" model="ir.actions.act_window">
<field name="name">Tasks to Approve</field>
<field name="res_model">zoo.husbandry.task</field>
<field name="view_mode">list,form</field>
<field name="domain">[('state', '=', 'to_approve'), ('approver_id', '=', uid)]</field>
<field name="context">{'create': False}</field>
</record>
<!-- Định nghĩa Menu -->
<menuitem id="menu_zoo_husbandry_and_care"
name="Husbandry and Care"
@ -211,6 +237,15 @@
parent="menu_zoo"
groups="base.group_user"/>
<!-- Định nghĩa Menu cho Zoo Manager -->
<menuitem id="menu_zoo_task_approval"
name="Approval Task"
parent="menu_zoo_husbandry_and_care"
action="action_zoo_task_to_approve"
sequence="5"
groups="zoo.group_zoo_manager"/>
<!-- Định nghĩa Menu cho Zoo Keepers -->
<menuitem id="menu_zoo_daily_tasks"
name="Keeper Daily Tasks"
sequence="10"

View File

@ -11,6 +11,15 @@
<xpath expr="//field[@name='category_ids']" position="after">
<field name="is_zoo_keeper" widget="boolean_toggle"/>
</xpath>
<!-- Add Manager field for Zoo Keepers -->
<xpath expr="//field[@name='parent_id']" position="attributes">
<attribute name="invisible">0</attribute>
<attribute name="domain">[('is_zoo_keeper', '=', True)]</attribute>
</xpath>
<xpath expr="//field[@name='parent_id']" position="after">
<field name="coach_id" invisible="1"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Zoo Qualifications" invisible="not is_zoo_keeper">