Merge 3afc5baff2 into cb68d1a71a
This commit is contained in:
commit
4f552469b8
96
.agent/rules/code-execution.md
Normal file
96
.agent/rules/code-execution.md
Normal file
@ -0,0 +1,96 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# [ALWAYS_ON] ODOO 18 DEVELOPMENT PROTOCOL
|
||||
This rule file is GLOBALLY ACTIVE. It governs the generation, structure, and validation of Odoo 18 Addons (Modules).
|
||||
|
||||
# IDENTITY_EXTENSION
|
||||
You are an **Odoo 18 Technical Architect**. You do not write loose scripts; you build structured, installable Odoo Modules. You prioritize OWL 2.0 for frontend and clean Python composition for backend.
|
||||
|
||||
# ODOO_18_CONFIGURATION (JSON)
|
||||
You must strictly adhere to these version-specific constraints.
|
||||
|
||||
{
|
||||
"module_structure": {
|
||||
"mandatory_files": ["__manifest__.py", "__init__.py"],
|
||||
"directory_layout": {
|
||||
"models": "Python business logic",
|
||||
"views": "XML definitions (Actions, Menus, Views)",
|
||||
"security": "ir.model.access.csv & rules.xml",
|
||||
"static/src/components": "OWL Components (JS + XML templates)",
|
||||
"static/src/scss": "Styles",
|
||||
"controllers": "HTTP routes"
|
||||
}
|
||||
},
|
||||
"syntax_enforcement": {
|
||||
"xml_views": {
|
||||
"list_view_tag": "<list>",
|
||||
"forbidden_tags": ["<tree>"],
|
||||
"modifiers": "Use Python expressions in 'invisible', 'readonly', 'required'. NO 'attrs'.",
|
||||
"smart_buttons": "Use type='object' inside div[name='button_box']"
|
||||
},
|
||||
"javascript_owl": {
|
||||
"framework": "OWL 2.0",
|
||||
"module_type": "ES6 Modules (/** @odoo-module */)",
|
||||
"legacy_widgets": "FORBIDDEN"
|
||||
},
|
||||
"manifest_assets": {
|
||||
"web.assets_backend": ["Includes .js and .scss files for internal UI"],
|
||||
"web.assets_frontend": ["Includes .js and .scss files for Website"]
|
||||
}
|
||||
},
|
||||
"execution_safety": {
|
||||
"scaffolding": "Always create folders before files",
|
||||
"hot_reload": "Advise user to restart odoo-bin with '-u module_name' after python changes"
|
||||
}
|
||||
}
|
||||
|
||||
# OPERATIONAL_RULES
|
||||
1. Python Logic (.py) - The Backend Core
|
||||
Init Files: Never create a .py file without ensuring it is imported in the corresponding __init__.py.
|
||||
Decorators: Use @api.depends, @api.onchange, and @api.constrains appropriately.
|
||||
Model Definitions:
|
||||
Always define _name, _description.
|
||||
If inheriting, clarify _inherit (extension) vs _inherits (delegation).
|
||||
|
||||
2. XML Views (.xml) - The Odoo 18 Interface
|
||||
The <list> Mandate: When defining a list view, you MUST use the <list> tag.
|
||||
Incorrect: <tree string="Contacts">
|
||||
Correct: <list string="Contacts">
|
||||
No attrs Dictionary:
|
||||
Incorrect: attrs="{'invisible': [('state', '=', 'draft')]}"
|
||||
Correct: invisible="state == 'draft'"
|
||||
QWeb & OWL: When writing templates for JS components, place them in static/src/components/.... Do NOT mix them with Backend Views unless strictly necessary.
|
||||
|
||||
3. Security & Access (.csv)
|
||||
Zero-Trust Default: Every new model MUST have a corresponding entry in ir.model.access.csv.
|
||||
CSV Structure: id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink.
|
||||
Naming Convention: Use access_model_name_group_name.
|
||||
|
||||
4. Javascript & OWL Framework (.js)
|
||||
Header: All JS files must start with /** @odoo-module */.
|
||||
Component Structure:
|
||||
Import Component, useState from @odoo/owl.
|
||||
Define template linking to the XML ID or inline template.
|
||||
Use registry.category("...").add(...) to register the component.
|
||||
|
||||
5. Styling (.scss)
|
||||
Scope: Use specific CSS classes (BEM naming preferred) to avoid breaking Odoo's core UI.
|
||||
Registration: Ensure the .scss file is listed in the __manifest__.py under assets -> web.assets_backend.
|
||||
|
||||
# EXECUTION_AND_SCAFFOLDING_LOGIC
|
||||
When the user asks to "Create a module for [Idea]":
|
||||
1. Draft the Manifest: Define the module name and dependencies (e.g., base, sale, web).
|
||||
2. Create Directory Tree: Generate the standard Odoo folder structure.
|
||||
3. Generate Core Models: Write the .py files and register them in __init__.py.
|
||||
4. Generate Security: Create ir.model.access.csv immediately.
|
||||
5. Generate Views: Write .xml files using Odoo 18 syntax (<list>, invisible=expr).
|
||||
6. Register Assets: Update __manifest__.py to include Views and Static Assets.
|
||||
|
||||
# SELF_CORRECTION_CHECKLIST
|
||||
Before outputting any code block:
|
||||
Did I use <tree> for a view? -> Change to <list>.
|
||||
Did I use attrs? -> Convert to Python expression.
|
||||
Is the JS file strictly ES6? -> Add /** @odoo-module */.
|
||||
Is the model listed in __init__.py? -> Add it.
|
||||
110
.agent/rules/code-style.md
Normal file
110
.agent/rules/code-style.md
Normal file
@ -0,0 +1,110 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# [ALWAYS_ON] ODOO 18 CODE STYLE & QUALITY STANDARDS
|
||||
This rule file governs the stylistic and syntactic quality of Odoo 18 Addons. It enforces consistency across Python, XML, JavaScript (OWL), and SCSS.
|
||||
|
||||
# IDENTITY_EXTENSION
|
||||
You are the **Odoo Code Quality Gatekeeper**. You reject deprecated syntax, enforce strict linting, and prioritize readability according to OCA (Odoo Community Association) standards.
|
||||
|
||||
# STYLE_CONFIGURATION (JSON)
|
||||
Adhere strictly to these formatter/linter configurations:
|
||||
|
||||
{
|
||||
"python_engine": {
|
||||
"style_guide": "PEP 8 + OCA Guidelines",
|
||||
"linter": "Flake8",
|
||||
"formatter": "Black or Odoo-Bin Formatter",
|
||||
"line_length": 88,
|
||||
"import_sorting": "isort (Groups: Stdlib -> Odoo -> Local)",
|
||||
"docstrings": "Google Style or ReST"
|
||||
},
|
||||
"xml_engine": {
|
||||
"indentation": "4 spaces",
|
||||
"attribute_ordering": "name -> string -> class -> attrs (invisible/readonly)",
|
||||
"self_closing_tags": "Always use self-closing for empty elements (<field ... />)"
|
||||
},
|
||||
"javascript_owl": {
|
||||
"syntax": "ES6 Modules",
|
||||
"linter": "ESLint",
|
||||
"formatter": "Prettier",
|
||||
"naming": {
|
||||
"components": "PascalCase",
|
||||
"hooks": "useCamelCase"
|
||||
}
|
||||
},
|
||||
"scss_engine": {
|
||||
"methodology": "BEM (Block Element Modifier)",
|
||||
"nesting_limit": 3
|
||||
}
|
||||
}
|
||||
|
||||
# FILE_SPECIFIC_RULES
|
||||
1. Python (.py) - Backend Logic
|
||||
Imports Order (Strict):
|
||||
Standard Libraries (os, json, logging)
|
||||
Odoo Core (from odoo import models, fields, api, Command)
|
||||
Local Addon Imports
|
||||
|
||||
Naming Conventions:
|
||||
Models: _name = "module.model_name" (Use dots).
|
||||
Variables: snake_case. Avoid single-letter variables except i, x in short loops.
|
||||
|
||||
Odoo 18 Specifics:
|
||||
x2many Writes: ALWAYS use Command objects.
|
||||
Bad: (0, 0, {...})
|
||||
Good: [Command.create({...})]
|
||||
Docstrings: Every method public API (def action_...) must have a docstring.
|
||||
Empty Strings: Use False for empty text fields in logic comparison, but "" when writing to DB if required.
|
||||
|
||||
2. XML Views (.xml) - Interface Definition
|
||||
The V18 Paradigm Shift:
|
||||
<list> Tag: NEVER use <tree> for list views. Always use <list>.
|
||||
Python Expressions: Do NOT use attrs="{...}". Use direct attributes:
|
||||
invisible="state == 'done'"
|
||||
readonly="user_id != False"
|
||||
required="type == 'goods'"
|
||||
|
||||
Structure:
|
||||
Root tag <odoo> must wrap all content.
|
||||
Records must have unique IDs formatted as view_model_name_type.
|
||||
|
||||
Readability: Break long attribute lines (e.g., inside <field>) into multiple lines if they exceed column 100.
|
||||
|
||||
3. JavaScript (.js) - OWL 2.0 Frontend
|
||||
Module Header: Every JS file MUST start with /** @odoo-module */.
|
||||
|
||||
Class Definition:
|
||||
Extend Component from @odoo/owl.
|
||||
Define static template and static props.
|
||||
|
||||
Reactivity:
|
||||
Use useState for internal component state.
|
||||
Do NOT manipulate DOM directly (no jQuery unless absolutely unavoidable).
|
||||
|
||||
Code Style:
|
||||
Prefer const over let.
|
||||
Use Arrow functions for callbacks to preserve this context (though OWL binds automatically in templates).
|
||||
|
||||
4. SCSS (.scss) - Styling
|
||||
Bootstrap Utility: Prioritize Odoo's internal Bootstrap 5 utility classes (e.g., mb-2, d-flex) over writing custom CSS.
|
||||
Scoping: Wrap your styles in a unique class related to your module to prevent global pollution.
|
||||
SCSS
|
||||
|
||||
.o_my_module_container {
|
||||
// Styles here
|
||||
}
|
||||
|
||||
5. Security (.csv) - Access Rights
|
||||
Naming: id should follow access_model_name_group_role.
|
||||
Completeness: Do not leave permission columns empty. Use 1 or 0.
|
||||
Header: strictly id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink.
|
||||
|
||||
# QUALITY_ASSURANCE_CHECK
|
||||
Before finalizing the code output, run this mental linter:
|
||||
Did I use Command instead of Magic Tuples? (Python)
|
||||
Did I replace <tree> with <list>? (XML)
|
||||
Did I remove all attrs dictionaries? (XML)
|
||||
Does the JS file have the Odoo Module header? (JS)
|
||||
If any answer is "No", correct it immediately.
|
||||
430
addons/epr/Implementation Plan.md
Normal file
430
addons/epr/Implementation Plan.md
Normal 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
2
addons/epr/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
48
addons/epr/__manifest__.py
Normal file
48
addons/epr/__manifest__.py
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
'name': 'Electronic Purchase Request (ePR) Enterprise',
|
||||
'version': '18.0.1.0.0',
|
||||
'category': 'Procurement/Inventory',
|
||||
'summary': 'Hệ thống quản lý yêu cầu mua sắm nội bộ với quy trình phê duyệt đa cấp động',
|
||||
'description': """
|
||||
Module ePR được thiết kế chuyên biệt cho Odoo 18.
|
||||
Các tính năng cốt lõi:
|
||||
- Tách biệt quy trình Yêu cầu (Internal) và Mua hàng (External).
|
||||
- Cho phép gom nhiều PRs vào một RFQs và có thể truy xuất theo từng dòng trên PO.
|
||||
- Ma trận phê duyệt động (Approval Matrix) dựa trên ngưỡng tiền tệ và phòng ban.
|
||||
""",
|
||||
'author': 'Trường Phan',
|
||||
'depends': [
|
||||
'base',
|
||||
'purchase', # Để kết nối với purchase.order [8]
|
||||
'hr', # Để lấy thông tin phòng ban và quản lý
|
||||
'product', # Quản lý danh mục sản phẩm
|
||||
# 'stock', # Quản lý kho và địa điểm nhận hàng
|
||||
'mail', # Tích hợp Chatter và Activity
|
||||
'uom', # Đơn vị tính
|
||||
# 'analytic', # Kế toán quản trị (nếu cần phân bổ chi phí)
|
||||
],
|
||||
|
||||
'data': [
|
||||
'security/epr_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'security/epr_record_rules.xml',
|
||||
'data/epr_pr_sequence_data.xml',
|
||||
'data/epr_rfq_sequence_data.xml',
|
||||
'views/epr_purchase_request_views.xml',
|
||||
'views/epr_rfq_views.xml',
|
||||
'views/epr_approval_views.xml',
|
||||
'views/epr_po_views.xml',
|
||||
'views/epr_menus.xml',
|
||||
'wizards/epr_reject_wizard_views.xml',
|
||||
'wizards/epr_create_rfq_views.xml',
|
||||
'wizards/epr_create_po_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
# 'web.assets_backend': [
|
||||
# 'epr_management/static/src/scss/epr_status_widget.scss',
|
||||
# ],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
BIN
addons/epr/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/epr/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
29
addons/epr/data/epr_pr_sequence_data.xml
Normal file
29
addons/epr/data/epr_pr_sequence_data.xml
Normal 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>
|
||||
39
addons/epr/data/epr_rfq_sequence_data.xml
Normal file
39
addons/epr/data/epr_rfq_sequence_data.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
noupdate="1": BẮT BUỘC.
|
||||
- Nếu để 0, mỗi lần bạn upgrade module, số thứ tự (Next Number)
|
||||
sẽ bị reset về 1, gây trùng lặp mã phiếu.
|
||||
- Nếu muốn cập nhật lại (ví dụ đổi prefix): Bạn phải vào giao diện Odoo:
|
||||
Settings > Technical > Sequences, tìm mã epr.rfq và sửa trực tiếp trên giao diện.
|
||||
-->
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="seq_epr_rfq" model="ir.sequence">
|
||||
<field name="name">ePR Request for Quotation</field>
|
||||
<!-- CODE này phải khớp chính xác với hàm next_by_code trong Python -->
|
||||
<field name="code">epr.rfq</field>
|
||||
|
||||
<!-- Định dạng: RFQ/2024/00001 -->
|
||||
<field name="prefix">RFQ/%(year)s/</field>
|
||||
|
||||
<!-- Padding: Độ dài phần số. Nên để 5 (đến 99,999) để dùng lâu dài -->
|
||||
<field name="padding">5</field>
|
||||
|
||||
<!-- Cấu hình bước nhảy -->
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
|
||||
<!--
|
||||
company_id = False: Sequence này dùng chung cho toàn hệ thống.
|
||||
Nếu bạn muốn mỗi công ty có chuỗi số riêng (VD: Cty A số 1, Cty B số 1),
|
||||
bạn cần để trường này, nhưng thường cấu hình mặc định là False để tránh lỗi không tìm thấy.
|
||||
-->
|
||||
<field name="company_id" eval="False"/>
|
||||
|
||||
<!-- implementation: 'standard' (nhanh hơn) hoặc 'no_gap' (chậm hơn nhưng không mất số) -->
|
||||
<field name="implementation">standard</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
11
addons/epr/data/import_partners.csv
Normal file
11
addons/epr/data/import_partners.csv
Normal file
@ -0,0 +1,11 @@
|
||||
id,name,email,phone,is_company,supplier_rank
|
||||
partner_vendor_1,CÔNG TY TNHH THIẾT BỊ VĂN PHÒNG ABC,contact@abc-office.vn,028 1234 5678,True,1
|
||||
partner_vendor_2,CÔNG TY CP CÔNG NGHỆ DELTA,sales@deltatech.vn,024 9876 5432,True,1
|
||||
partner_vendor_3,NHÀ PHÂN PHỐI PHẦN MỀM SIGMA,info@sigmasoft.vn,028 5555 6666,True,1
|
||||
partner_vendor_4,CÔNG TY VẬT TƯ XÂY DỰNG OMEGA,order@omega-building.vn,028 7777 8888,True,1
|
||||
partner_vendor_5,ĐẠI LÝ VĂN PHÒNG PHẨM BETA,sales@beta-stationery.vn,028 2222 3333,True,1
|
||||
partner_vendor_6,CÔNG TY TNHH ĐIỆN TỬ GAMMA,contact@gamma-electronics.vn,024 4444 5555,True,1
|
||||
partner_vendor_7,NHÀ CUNG CẤP NỘI THẤT EPSILON,info@epsilon-furniture.vn,028 6666 7777,True,1
|
||||
partner_vendor_8,CÔNG TY DỊCH VỤ IT ZETA,support@zeta-it.vn,1900 1234,True,1
|
||||
partner_vendor_9,ĐẠI LÝ PHÂN PHỐI LAMBDA,order@lambda-dist.vn,028 8888 9999,True,1
|
||||
partner_vendor_10,CÔNG TY THƯƠNG MẠI THETA,sales@theta-trading.vn,024 1111 2222,True,1
|
||||
|
21
addons/epr/data/import_purchase_request_lines.csv
Normal file
21
addons/epr/data/import_purchase_request_lines.csv
Normal file
@ -0,0 +1,21 @@
|
||||
id,request_id/id,name,product_description,quantity,estimated_price,uom_name,suggested_vendor_name
|
||||
pr_line_1,pr_demo_1,Máy in HP LaserJet Pro M404dn,Máy in laser đen trắng - in 2 mặt tự động - kết nối mạng LAN,2,8500000,Cái,CÔNG TY TNHH THIẾT BỊ VĂN PHÒNG ABC
|
||||
pr_line_2,pr_demo_2,Laptop Dell Latitude 5540,Laptop văn phòng - Intel Core i5 - RAM 16GB - SSD 512GB,5,25000000,Bộ,CÔNG TY CP CÔNG NGHỆ DELTA
|
||||
pr_line_3,pr_demo_3,Bàn làm việc 1m4,Bàn làm việc chữ L - kích thước 1m4 x 0.6m - màu gỗ sồi,10,3500000,Cái,NHÀ CUNG CẤP NỘI THẤT EPSILON
|
||||
pr_line_4,pr_demo_4,Ghế công thái học Ergohuman,Ghế văn phòng cao cấp - hỗ trợ lưng - tay vịn điều chỉnh,5,12000000,Cái,NHÀ CUNG CẤP NỘI THẤT EPSILON
|
||||
pr_line_5,pr_demo_5,Máy chiếu Epson EB-X51,Máy chiếu 3800 lumens - độ phân giải XGA - kết nối HDMI,2,15000000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA
|
||||
pr_line_6,pr_demo_6,Microsoft 365 Business Premium (1 năm),Bản quyền phần mềm Office 365 - bao gồm Teams - OneDrive 1TB,50,3000000,License,NHÀ PHÂN PHỐI PHẦN MỀM SIGMA
|
||||
pr_line_7,pr_demo_7,Ổ cứng SSD Samsung 870 EVO 1TB,Ổ cứng SSD SATA 2.5 inch - tốc độ đọc 560MB/s,10,2500000,Cái,CÔNG TY CP CÔNG NGHỆ DELTA
|
||||
pr_line_8,pr_demo_8,Dịch vụ bảo trì Server (6 tháng),Gói bảo trì hệ thống server - bao gồm backup và monitoring 24/7,1,50000000,Gói,CÔNG TY DỊCH VỤ IT ZETA
|
||||
pr_line_9,pr_demo_9,Router Wifi TP-Link Archer AX73,Router Wifi 6 - băng tần kép - tốc độ 5400Mbps,5,3200000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA
|
||||
pr_line_10,pr_demo_10,Webcam Logitech C920 HD Pro,Webcam Full HD 1080p - tự động lấy nét - micro kép,10,2800000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA
|
||||
pr_line_11,pr_demo_11,Giấy A4 Double A 80gsm,Giấy in A4 chất lượng cao - độ trắng 96%,100,85000,Ream,ĐẠI LÝ VĂN PHÒNG PHẨM BETA
|
||||
pr_line_12,pr_demo_12,Bút bi Thiên Long TL-027,Bút bi mực xanh - nét thanh đẹp,200,5000,Cây,ĐẠI LÝ VĂN PHÒNG PHẨM BETA
|
||||
pr_line_13,pr_demo_13,Kẹp giấy loại lớn,Kẹp giấy kim loại 51mm - hộp 12 cái,50,25000,Hộp,ĐẠI LÝ VĂN PHÒNG PHẨM BETA
|
||||
pr_line_14,pr_demo_14,Sổ tay bìa cứng A5,Sổ tay bìa da cao cấp - 200 trang giấy kẻ ngang,30,65000,Quyển,ĐẠI LÝ VĂN PHÒNG PHẨM BETA
|
||||
pr_line_15,pr_demo_15,Mực in HP 76A Black,Hộp mực chính hãng cho máy in HP LaserJet Pro M404,10,2200000,Hộp,CÔNG TY TNHH THIẾT BỊ VĂN PHÒNG ABC
|
||||
pr_line_16,pr_demo_16,Điều hòa Daikin Inverter 1HP,Điều hòa tiết kiệm điện - công suất 9000BTU,3,12000000,Bộ,CÔNG TY TNHH ĐIỆN TỬ GAMMA
|
||||
pr_line_17,pr_demo_17,Bình nước nóng Ariston 30L,Bình nước nóng điện - dung tích 30 lít - có chống giật,2,4500000,Cái,CÔNG TY VẬT TƯ XÂY DỰNG OMEGA
|
||||
pr_line_18,pr_demo_18,Camera IP Hikvision DS-2CD1047G2,Camera IP 4MP - hồng ngoại 30m - chuẩn IP67,8,1800000,Cái,CÔNG TY TNHH ĐIỆN TỬ GAMMA
|
||||
pr_line_19,pr_demo_19,Tủ hồ sơ sắt 3 ngăn,Tủ hồ sơ kim loại - có khóa - kích thước 1.2m x 0.5m x 0.4m,5,2800000,Cái,NHÀ CUNG CẤP NỘI THẤT EPSILON
|
||||
pr_line_20,pr_demo_20,Nước uống đóng chai Aquafina 500ml,Nước tinh khiết chai 500ml - thùng 24 chai,50,95000,Thùng,CÔNG TY THƯƠNG MẠI THETA
|
||||
|
21
addons/epr/data/import_purchase_requests.csv
Normal file
21
addons/epr/data/import_purchase_requests.csv
Normal file
@ -0,0 +1,21 @@
|
||||
id,date_required,priority,state
|
||||
pr_demo_1,2024-12-23,2,draft
|
||||
pr_demo_2,2024-12-21,3,draft
|
||||
pr_demo_3,2024-12-26,1,draft
|
||||
pr_demo_4,2024-12-19,4,draft
|
||||
pr_demo_5,2024-12-30,2,draft
|
||||
pr_demo_6,2025-01-15,2,draft
|
||||
pr_demo_7,2024-12-23,3,draft
|
||||
pr_demo_8,2024-12-21,4,draft
|
||||
pr_demo_9,2025-01-06,1,draft
|
||||
pr_demo_10,2024-12-26,2,draft
|
||||
pr_demo_11,2024-12-19,1,draft
|
||||
pr_demo_12,2024-12-21,1,draft
|
||||
pr_demo_13,2024-12-23,2,draft
|
||||
pr_demo_14,2024-12-26,1,draft
|
||||
pr_demo_15,2024-12-21,2,draft
|
||||
pr_demo_16,2024-12-30,3,draft
|
||||
pr_demo_17,2025-01-06,2,draft
|
||||
pr_demo_18,2024-12-23,4,draft
|
||||
pr_demo_19,2024-12-26,2,draft
|
||||
pr_demo_20,2025-01-15,1,draft
|
||||
|
5
addons/epr/models/__init__.py
Normal file
5
addons/epr/models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from . import epr_purchase_request
|
||||
from . import epr_rfq
|
||||
from . import epr_approval_rule
|
||||
from . import epr_approval_entry
|
||||
from . import epr_po
|
||||
BIN
addons/epr/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/epr/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/models/__pycache__/epr_approval.cpython-312.pyc
Normal file
BIN
addons/epr/models/__pycache__/epr_approval.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/models/__pycache__/epr_approval_entry.cpython-312.pyc
Normal file
BIN
addons/epr/models/__pycache__/epr_approval_entry.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/models/__pycache__/epr_approval_rule.cpython-312.pyc
Normal file
BIN
addons/epr/models/__pycache__/epr_approval_rule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/models/__pycache__/epr_po.cpython-312.pyc
Normal file
BIN
addons/epr/models/__pycache__/epr_po.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc
Normal file
BIN
addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc
Normal file
Binary file not shown.
129
addons/epr/models/epr_approval_entry.py
Normal file
129
addons/epr/models/epr_approval_entry.py
Normal file
@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class EprApprovalEntry(models.Model):
|
||||
_name = 'epr.approval.entry'
|
||||
_description = 'Approval Request Entry'
|
||||
_order = 'sequence, id'
|
||||
|
||||
# Link về RFQ
|
||||
rfq_id = fields.Many2one(
|
||||
comodel_name='epr.rfq',
|
||||
string='RFQ Reference',
|
||||
ondelete='cascade',
|
||||
required=True
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
related='rfq_id.currency_id',
|
||||
string='Currency',
|
||||
readonly=True
|
||||
)
|
||||
amount_total = fields.Monetary(
|
||||
related='rfq_id.amount_total',
|
||||
string='Total Amount',
|
||||
currency_field='currency_id',
|
||||
readonly=True
|
||||
)
|
||||
# Thông tin snapshot từ Rule (để truy vết nếu rule gốc bị sửa)
|
||||
rule_line_id = fields.Many2one(
|
||||
comodel_name='epr.approval.rule.line',
|
||||
string='Original Rule Line'
|
||||
)
|
||||
|
||||
name = fields.Char(
|
||||
string='Step Name',
|
||||
required=True
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Step Sequence',
|
||||
required=True
|
||||
)
|
||||
|
||||
# Trạng thái của bước này
|
||||
status = fields.Selection(
|
||||
selection=[
|
||||
('new', 'To Approve'),
|
||||
('pending', 'Pending Previous Step'), # Chờ bước trước
|
||||
('approved', 'Approved'),
|
||||
('refused', 'Refused')
|
||||
],
|
||||
string='Status',
|
||||
default='new',
|
||||
required=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Ai cần duyệt (Copy từ Rule sang)
|
||||
required_user_ids = fields.Many2many(
|
||||
comodel_name='res.users',
|
||||
string='Required Approvers',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Ai đã duyệt thực tế (Audit Log)
|
||||
actual_user_id = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
string='Approved By',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
approval_date = fields.Datetime(
|
||||
string='Date',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
rejection_reason = fields.Text(
|
||||
string='Reason'
|
||||
)
|
||||
|
||||
# Logic UI: Cho phép nút Duyệt hiện hay ẩn
|
||||
can_approve = fields.Boolean(
|
||||
compute='_compute_can_approve'
|
||||
)
|
||||
|
||||
@api.depends('status', 'required_user_ids')
|
||||
@api.depends_context('uid')
|
||||
def _compute_can_approve(self):
|
||||
current_user = self.env.user
|
||||
for entry in self:
|
||||
# 1. Phải ở trạng thái 'new'
|
||||
# 2. User hiện tại phải nằm trong danh sách được phép
|
||||
if entry.status == 'new' and current_user in entry.required_user_ids:
|
||||
entry.can_approve = True
|
||||
else:
|
||||
entry.can_approve = False
|
||||
|
||||
# =========================================================================
|
||||
# ACTIONS
|
||||
# =========================================================================
|
||||
def action_approve_line(self):
|
||||
"""User bấm nút Approve trên dòng"""
|
||||
self.ensure_one()
|
||||
if not self.can_approve:
|
||||
raise UserError(_("You are not authorized to approve this step or it is not ready."))
|
||||
|
||||
self.write({
|
||||
'status': 'approved',
|
||||
'actual_user_id': self.env.user.id,
|
||||
'approval_date': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# Trigger kiểm tra xem RFQ đã được duyệt hoàn toàn chưa
|
||||
self.rfq_id._check_approval_completion()
|
||||
|
||||
def action_refuse_line(self):
|
||||
"""User bấm nút Refuse"""
|
||||
# return {
|
||||
# 'type': 'ir.actions.act_window',
|
||||
# 'name': _('Refuse Reason'),
|
||||
# 'res_model': 'epr.approval.refuse.wizard', # Viết sau
|
||||
# 'target': 'new',
|
||||
# 'context': {'default_entry_id': self.id}
|
||||
# }
|
||||
self.write({
|
||||
'status': 'refused'
|
||||
})
|
||||
91
addons/epr/models/epr_approval_rule.py
Normal file
91
addons/epr/models/epr_approval_rule.py
Normal file
@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EprApprovalRule(models.Model):
|
||||
_name = 'epr.approval.rule'
|
||||
_description = 'Approval Rule Header'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Rule Name',
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company
|
||||
)
|
||||
|
||||
# Thứ tự ưu tiên của Rule (nếu có nhiều rule khớp điều kiện)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10
|
||||
)
|
||||
|
||||
# Điều kiện áp dụng Rule (VD: Áp dụng cho phòng ban nào?)
|
||||
department_id = fields.Many2one(
|
||||
comodel_name='hr.department',
|
||||
string='Apply for Department'
|
||||
)
|
||||
|
||||
# Chi tiết các bước duyệt
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='epr.approval.rule.line',
|
||||
inverse_name='rule_id',
|
||||
string='Approval Steps'
|
||||
)
|
||||
|
||||
|
||||
class EprApprovalRuleLine(models.Model):
|
||||
_name = 'epr.approval.rule.line'
|
||||
_description = 'Approval Rule Steps'
|
||||
_order = 'sequence, id'
|
||||
|
||||
rule_id = fields.Many2one(
|
||||
comodel_name='epr.approval.rule',
|
||||
ondelete='cascade'
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Step',
|
||||
default=1
|
||||
)
|
||||
|
||||
# Tên bước (VD: Quản lý duyệt, Giám đốc duyệt)
|
||||
name = fields.Char(
|
||||
string='Step Name',
|
||||
required=True
|
||||
)
|
||||
|
||||
# Điều kiện kích hoạt bước này
|
||||
min_amount = fields.Monetary(
|
||||
string='Minimum Amount',
|
||||
currency_field='currency_id'
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
related='rule_id.company_id.currency_id'
|
||||
)
|
||||
|
||||
# Ai được duyệt bước này?
|
||||
user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
string='Approvers',
|
||||
required=True
|
||||
)
|
||||
|
||||
# Loại duyệt: 1 người bất kỳ trong list hay bắt buộc tất cả?
|
||||
approval_type = fields.Selection([
|
||||
('any', 'Any User'),
|
||||
('all', 'All Users')
|
||||
], string='Approval Type', default='any', required=True)
|
||||
70
addons/epr/models/epr_po.py
Normal file
70
addons/epr/models/epr_po.py
Normal file
@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
_inherit = 'purchase.order'
|
||||
|
||||
# === HEADER-LEVEL LINKING ===
|
||||
epr_source_rfq_ids = fields.Many2many(
|
||||
comodel_name='epr.rfq',
|
||||
relation='epr_rfq_purchase_order_rel', # Tên bảng trung gian rõ ràng
|
||||
column1='purchase_id',
|
||||
column2='epr_rfq_id',
|
||||
string='Source ePR RFQs',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
help="Danh sách các phiếu yêu cầu báo giá (EPR RFQ) nguồn tạo nên PO này."
|
||||
)
|
||||
|
||||
# === COMPUTED FIELDS CHO SMART BUTTON (Line-Level Linking) ===
|
||||
epr_rfq_count = fields.Integer(
|
||||
string='RFQ Count',
|
||||
compute='_compute_epr_rfq_count'
|
||||
)
|
||||
|
||||
@api.depends('epr_source_rfq_ids')
|
||||
def _compute_epr_rfq_count(self):
|
||||
for po in self:
|
||||
# Đếm trực tiếp từ field Many2many đã lưu trữ
|
||||
po.epr_rfq_count = len(po.epr_source_rfq_ids)
|
||||
|
||||
# === ACTION SMART BUTTON ===
|
||||
def action_view_epr_rfqs(self):
|
||||
"""Mở danh sách các EPR RFQ nguồn"""
|
||||
self.ensure_one()
|
||||
rfq_ids = self.epr_source_rfq_ids.ids
|
||||
|
||||
# Nếu chỉ có 1 RFQ nguồn, mở form view trực tiếp cho tiện
|
||||
if len(rfq_ids) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'epr.rfq',
|
||||
'view_mode': 'form',
|
||||
'res_id': rfq_ids[0],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# Nếu có nhiều RFQs nguồn, mở list view
|
||||
return {
|
||||
'name': _('Source RFQs'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'epr.rfq',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', rfq_ids)],
|
||||
'context': {'create': False},
|
||||
}
|
||||
|
||||
|
||||
class PurchaseOrderLine(models.Model):
|
||||
_inherit = 'purchase.order.line'
|
||||
|
||||
# === FIELD LIÊN KẾT QUAN TRỌNG NHẤT ===
|
||||
epr_rfq_line_id = fields.Many2one(
|
||||
comodel_name='epr.rfq.line',
|
||||
string='EPR RFQ Line Ref',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
help="Dòng chi tiết tương ứng trên phiếu yêu cầu báo giá."
|
||||
)
|
||||
574
addons/epr/models/epr_purchase_request.py
Normal file
574
addons/epr/models/epr_purchase_request.py
Normal file
@ -0,0 +1,574 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class EprPurchaseRequest(models.Model):
|
||||
_name = 'epr.purchase.request'
|
||||
_description = 'Electronic Purchase Request'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Request Reference',
|
||||
required=True,
|
||||
readonly=True,
|
||||
copy=False,
|
||||
default=lambda self: _('New')
|
||||
)
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True
|
||||
)
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
comodel_name='hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
tracking=True,
|
||||
default=lambda self: self.env.user.employee_id
|
||||
)
|
||||
|
||||
department_id = fields.Many2one(
|
||||
comodel_name='hr.department',
|
||||
string='Department',
|
||||
related='employee_id.department_id',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Xác định người tạo PR
|
||||
is_owner = fields.Boolean(
|
||||
compute='_compute_is_owner',
|
||||
store=False
|
||||
)
|
||||
|
||||
date_required = fields.Date(
|
||||
string='Date Required',
|
||||
required=True,
|
||||
tracking=True,
|
||||
default=fields.Date.context_today
|
||||
)
|
||||
priority = fields.Selection(
|
||||
[('1', 'Low'), ('2', 'Medium'), ('3', 'High'), ('4', 'Very High')],
|
||||
string='Priority',
|
||||
default='1',
|
||||
tracking=True
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('to_approve', 'To Approve'),
|
||||
('approved', 'Approved'),
|
||||
('in_progress', 'In Progress'),
|
||||
('done', 'Done'),
|
||||
('rejected', 'Rejected'),
|
||||
('cancel', 'Cancelled')
|
||||
],
|
||||
string='Status',
|
||||
default='draft',
|
||||
tracking=True,
|
||||
index=True,
|
||||
copy=False,
|
||||
group_expand='_expand_groups'
|
||||
)
|
||||
|
||||
# Approvers: Nên là field thường (không compute) để lưu cố định người duyệt lúc Submit
|
||||
approver_ids = fields.Many2many(
|
||||
comodel_name='res.users',
|
||||
string='Approvers',
|
||||
copy=False
|
||||
)
|
||||
|
||||
rejection_reason = fields.Text(
|
||||
string='Rejection Reason',
|
||||
readonly=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
required=True
|
||||
)
|
||||
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='epr.purchase.request.line',
|
||||
inverse_name='request_id',
|
||||
string='Products'
|
||||
)
|
||||
|
||||
estimated_total = fields.Monetary(
|
||||
string='Estimated Total',
|
||||
compute='_compute_estimated_total',
|
||||
store=True,
|
||||
currency_field='currency_id'
|
||||
)
|
||||
|
||||
# Link sang RFQs
|
||||
rfq_ids = fields.Many2many(
|
||||
comodel_name='epr.rfq',
|
||||
relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian
|
||||
column1='request_id',
|
||||
column2='rfq_id',
|
||||
string='RFQs',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Số lượng RFQ
|
||||
rfq_count = fields.Integer(
|
||||
compute='_compute_rfq_count',
|
||||
string='RFQ Count'
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# LOG FIELDS
|
||||
# ==========================================================================
|
||||
date_approved = fields.Datetime(
|
||||
string='Approved Date',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help="Ngày được phê duyệt."
|
||||
)
|
||||
|
||||
approved_by_id = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
string='Approved By',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help="Người đã phê duyệt."
|
||||
)
|
||||
|
||||
date_rejected = fields.Datetime(
|
||||
string='Rejected Date',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help="Ngày bị từ chối."
|
||||
)
|
||||
|
||||
rejected_by_id = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
string='Rejected By',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help="Người đã từ chối."
|
||||
)
|
||||
|
||||
date_submitted = fields.Datetime(
|
||||
string='Submitted Date',
|
||||
readonly=True,
|
||||
copy=False
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# MODEL METHODS
|
||||
# ==========================================================================
|
||||
# Hàm tạo sequence cho Request Reference
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('epr.purchase.request') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
# --- Kanban Grouping (Để Kanban hiển thị đủ cột Draft/Done dù không có data) ---
|
||||
@api.model
|
||||
def _expand_groups(self, states, domain, order=None):
|
||||
"""Force display all state columns in Kanban, even if empty"""
|
||||
return ['draft', 'to_approve', 'approved', 'rejected', 'in_progress', 'done', 'cancel']
|
||||
|
||||
# Compute estimated total
|
||||
@api.depends('line_ids.subtotal_estimated', 'currency_id')
|
||||
def _compute_estimated_total(self):
|
||||
"""Tính tổng tiền dự kiến từ các dòng chi tiết"""
|
||||
for request in self:
|
||||
# Sử dụng sum() trên danh sách mapped là đúng, nhưng cần cẩn thận nếu line khác tiền tệ
|
||||
# Ở đây giả định line dùng chung currency với header
|
||||
total = sum(line.subtotal_estimated for line in request.line_ids)
|
||||
request.estimated_total = total
|
||||
|
||||
# Xác định người tạo PR
|
||||
@api.depends_context('uid')
|
||||
def _compute_is_owner(self):
|
||||
for record in self:
|
||||
record.is_owner = record.employee_id.user_id.id == self.env.uid
|
||||
|
||||
# Compute approvers
|
||||
# @api.depends('employee_id', 'department_id', 'estimated_total')
|
||||
# def _compute_approvers(self):
|
||||
# # Placeholder for approval matrix logic
|
||||
# for request in self:
|
||||
# # For now, set line manager as approver
|
||||
# if request.employee_id and request.employee_id.parent_id:
|
||||
# request.approver_ids = [
|
||||
# (6, 0, [request.employee_id.parent_id.user_id.id])
|
||||
# ]
|
||||
# else:
|
||||
# request.approver_ids = False
|
||||
|
||||
# Compute RFQ count
|
||||
@api.depends('rfq_ids')
|
||||
def _compute_rfq_count(self):
|
||||
for record in self:
|
||||
record.rfq_count = len(record.rfq_ids)
|
||||
|
||||
# ==========================================================================
|
||||
# HELPER METHODS (Tách logic tìm người duyệt ra riêng)
|
||||
# ==========================================================================
|
||||
|
||||
def _get_applicable_approvers(self):
|
||||
"""
|
||||
Hàm logic xác định ai là người duyệt.
|
||||
Tách ra để dễ tái sử dụng hoặc override sau này
|
||||
(ví dụ thêm Matrix duyệt theo số tiền).
|
||||
"""
|
||||
self.ensure_one()
|
||||
approvers = self.env['res.users']
|
||||
|
||||
# Logic 1: Line Manager (Trưởng bộ phận)
|
||||
if self.employee_id and self.employee_id.parent_id and self.employee_id.parent_id.user_id:
|
||||
approvers |= self.employee_id.parent_id.user_id
|
||||
|
||||
# Logic 2 (Ví dụ): Nếu tiền > 50tr thì cần thêm Giám đốc (Code ví dụ)
|
||||
# if self.estimated_total > 50000000:
|
||||
# director = ...
|
||||
# approvers |= director
|
||||
|
||||
return approvers
|
||||
|
||||
# ==========================================================================
|
||||
# BUSINESS ACTIONS
|
||||
# ==========================================================================
|
||||
|
||||
# Submit PR for approval
|
||||
def action_submit(self):
|
||||
"""Submit PR for approval"""
|
||||
self.ensure_one()
|
||||
|
||||
# 1. Validate dữ liệu đầu vào
|
||||
if not self.line_ids:
|
||||
raise ValidationError(_('Cannot submit an empty purchase request. Please add at least one product line.'))
|
||||
|
||||
# 2. Tính toán và Gán người duyệt (Freeze approvers list)
|
||||
# Thay vì dùng compute field, ta gán trực tiếp lúc submit để "chốt" người duyệt
|
||||
required_approvers = self._get_applicable_approvers()
|
||||
|
||||
if not required_approvers:
|
||||
raise ValidationError(_('No approver found (Line Manager). Please contact HR or Admin to update your employee profile.'))
|
||||
|
||||
self.write({
|
||||
'approver_ids': [(6, 0, required_approvers.ids)],
|
||||
'state': 'to_approve',
|
||||
'date_submitted': fields.Datetime.now()
|
||||
})
|
||||
|
||||
# # 3. Tạo Activity (To-Do) cho người duyệt để họ nhận thông báo
|
||||
# NOTE: Commented out for testing without mail server
|
||||
# for approver in self.approver_ids:
|
||||
# self.activity_schedule(
|
||||
# 'mail.mail_activity_data_todo',
|
||||
# user_id=approver.id,
|
||||
# summary=_('Purchase Request Approval Required'),
|
||||
# note=_(
|
||||
# 'Purchase Request %s requires your approval.\n'
|
||||
# 'Total Amount: %s %s'
|
||||
# ) % (
|
||||
# self.name,
|
||||
# self.estimated_total,
|
||||
# self.currency_id.symbol
|
||||
# )
|
||||
# )
|
||||
|
||||
# Post message to chatter
|
||||
# self.message_post(
|
||||
# body=_('Purchase Request submitted for approval to: %s') % (
|
||||
# ', '.join(self.approver_ids.mapped('name'))
|
||||
# )
|
||||
# )
|
||||
|
||||
# Approver action: Approve PR
|
||||
def action_approve(self):
|
||||
"""Approve PR"""
|
||||
self.ensure_one()
|
||||
|
||||
# 1. Check quyền: User hiện tại có nằm trong danh sách được duyệt không?
|
||||
# Cho phép Administrator bypass
|
||||
if not self.env.is_superuser() and self.env.user not in self.approver_ids:
|
||||
raise UserError(_('You are not authorized to approve this request.'))
|
||||
|
||||
# 2. Cập nhật trạng thái
|
||||
self.write({
|
||||
'state': 'approved',
|
||||
# Lưu lại ngày và người duyệt để audit sau này
|
||||
'date_approved': fields.Datetime.now(),
|
||||
'approved_by_id': self.env.user.id
|
||||
})
|
||||
|
||||
# Hàm mở Wizard
|
||||
def action_reject_wizard(self):
|
||||
"""Open rejection wizard"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Reject Purchase Request'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'epr.reject.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
# Truyền ID hiện tại vào context để wizard tự động nhận diện
|
||||
'context': {'default_request_id': self.id}
|
||||
}
|
||||
|
||||
# Approver action: Reject PR
|
||||
def action_reject(self, reason):
|
||||
"""
|
||||
Reject PR with reason (called from wizard).
|
||||
Chuyển trạng thái sang 'draft' và ghi log.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Check quyền: User hiện tại có nằm trong danh sách được duyệt không?
|
||||
# Lưu ý: Nên cho phép cả Administrator bypass check này để xử lý sự cố
|
||||
if not self.env.is_superuser() and self.env.user not in self.approver_ids:
|
||||
raise UserError(
|
||||
_('You are not authorized to reject this request.')
|
||||
)
|
||||
|
||||
# Thực hiện ghi dữ liệu
|
||||
self.write({
|
||||
'state': 'rejected',
|
||||
'rejection_reason': reason,
|
||||
'approver_ids': [(5, 0, 0)], # QUAN TRỌNG: Xóa sạch người duyệt để clear danh sách chờ
|
||||
# LOG: Ghi nhận thông tin từ chối
|
||||
'date_rejected': fields.Datetime.now(),
|
||||
'rejected_by_id': self.env.user.id,
|
||||
})
|
||||
|
||||
# User action: Reset to draft when accidentally submitted
|
||||
def action_reset_to_draft(self):
|
||||
"""
|
||||
Reset PR back to draft state.
|
||||
Cho phép User sửa lại phiếu khi submit nhầm hoặc sau khi bị reject.
|
||||
Chỉ owner mới được reset (không phải Manager).
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Validation 1: Check state
|
||||
if self.state not in ['to_approve', 'rejected']:
|
||||
raise UserError(_(
|
||||
'You can only reset PR when it is in "To Approve" '
|
||||
'or "Rejected" state.'
|
||||
))
|
||||
|
||||
# Validation 2: Check permission - Only owner can reset
|
||||
# Admin có thể bypass
|
||||
if (not self.env.is_superuser() and
|
||||
self.employee_id.user_id != self.env.user):
|
||||
raise UserError(_(
|
||||
'Only the requester (%s) can reset this PR to draft.'
|
||||
) % self.employee_id.name)
|
||||
|
||||
# Reset state và clear data
|
||||
self.write({
|
||||
'state': 'draft',
|
||||
'approver_ids': [(5, 0, 0)], # Clear approvers list
|
||||
'rejection_reason': False, # Clear rejection reason
|
||||
'date_submitted': False, # Clear submission date
|
||||
})
|
||||
|
||||
# Post message for audit trail
|
||||
# NOTE: Commented out for testing without mail server
|
||||
# self.message_post(
|
||||
# body=_(
|
||||
# 'Purchase Request reset to Draft by %s for editing.'
|
||||
# ) % self.env.user.name
|
||||
# )
|
||||
|
||||
# Cancel pending activities
|
||||
# NOTE: Commented out for testing without mail server
|
||||
# self.activity_ids.unlink()
|
||||
|
||||
# === ACTION SMART BUTTON ===
|
||||
def action_view_rfqs(self):
|
||||
"""Mở danh sách các RFQ liên quan đến PR này"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Request for Quotations'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'epr.rfq',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.rfq_ids.ids)],
|
||||
'context': {
|
||||
'default_request_ids': [(6, 0, [self.id])], # Tự động link ngược lại PR này nếu tạo mới RFQ
|
||||
'create': True,
|
||||
},
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# CLASS CON: epr.purchase.request.line (Chi tiết hàng hóa trong PR)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class EprPurchaseRequestLine(models.Model):
|
||||
"""
|
||||
Chi tiết dòng yêu cầu mua sắm.
|
||||
Thiết kế theo hướng Free-text để thuận tiện cho người yêu cầu (Staff).
|
||||
"""
|
||||
_name = 'epr.purchase.request.line'
|
||||
_description = 'Chi tiết yêu cầu mua sắm'
|
||||
|
||||
# ==========================================================================
|
||||
# RELATIONAL FIELDS
|
||||
# ==========================================================================
|
||||
|
||||
request_id = fields.Many2one(
|
||||
comodel_name='epr.purchase.request',
|
||||
string='Purchase Request',
|
||||
required=True,
|
||||
ondelete='cascade', # Xóa PR cha sẽ xóa luôn các dòng con
|
||||
index=True,
|
||||
help="Liên kết đến phiếu yêu cầu mua sắm gốc."
|
||||
)
|
||||
|
||||
# Lấy tiền tệ từ phiếu cha để tính toán giá trị.
|
||||
# store=True để hỗ trợ tìm kiếm và báo cáo nhanh hơn.
|
||||
currency_id = fields.Many2one(
|
||||
related='request_id.currency_id',
|
||||
string='Currency',
|
||||
store=True,
|
||||
readonly=True,
|
||||
help="Tiền tệ được kế thừa từ phiếu yêu cầu chính."
|
||||
)
|
||||
|
||||
# Trường này để Purchasing Staff map sau khi duyệt. Staff không cần thấy.
|
||||
product_id = fields.Many2one(
|
||||
comodel_name='product.product',
|
||||
string='Product',
|
||||
domain=[('purchase_ok', '=', True)], # Chỉ chọn các sản phẩm được phép mua
|
||||
help="Trường dành cho bộ phận thu mua map với kho."
|
||||
)
|
||||
|
||||
# product_uom_id = fields.Many2one(
|
||||
# comodel_name='uom.uom',
|
||||
# string='Unit of Measure',
|
||||
# domain="[('category_id', '=', product_uom_category_id)]",
|
||||
# help="Đơn vị tính của sản phẩm. Tự động điền nếu chọn sản phẩm."
|
||||
# )
|
||||
|
||||
# Đơn vị tính dạng text tự nhập (VD: Cái, Hộp, Giờ, Lô...)
|
||||
# Tránh bắt Staff phải chọn UoM phức tạp của hệ thống
|
||||
uom_name = fields.Char(
|
||||
string='Unit of Measure',
|
||||
default='Unit',
|
||||
required=True
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# DATA FIELDS
|
||||
# ==========================================================================
|
||||
|
||||
# Dùng trường này làm tên hiển thị (rec_name)
|
||||
# Staff sẽ nhập ngắn gọn: VD "Laptop Dell XPS 13"
|
||||
name = fields.Char(
|
||||
string='Product Name',
|
||||
required=True,
|
||||
help="Nhập tên ngắn gọn của hàng hóa cần mua."
|
||||
)
|
||||
|
||||
# Dùng HTML để Staff có thể copy/paste hình ảnh, link, format màu sắc từ web
|
||||
product_description = fields.Html(
|
||||
string='Product Description',
|
||||
required=True,
|
||||
help="Mô tả chi tiết, có thể dán link, hình ảnh minh họa, thông số kỹ thuật..."
|
||||
)
|
||||
|
||||
# === 1. USER INPUT FIELDS ===
|
||||
# User chọn từ danh bạ (Không cho tạo mới ở View)
|
||||
user_vendor_id = fields.Many2one(
|
||||
comodel_name='res.partner',
|
||||
string='Approved Vendor',
|
||||
domain=lambda self: [
|
||||
('supplier_rank', '>', 0),
|
||||
'|', ('company_id', '=', False),
|
||||
('company_id', '=', self.env.company.id)
|
||||
],
|
||||
help="Chọn nhà cung cấp có sẵn trong hệ thống."
|
||||
)
|
||||
|
||||
# User nhập text tự do (Dùng khi không tìm thấy hoặc đề xuất mới)
|
||||
suggested_vendor_name = fields.Char(
|
||||
string='Suggested Vendor Name',
|
||||
help="Tên nhà cung cấp được đề xuất bởi người yêu cầu (tham khảo)."
|
||||
)
|
||||
|
||||
# === 2. PURCHASING ONLY FIELDS ===
|
||||
# Purchasing chốt Vendor cuối cùng để làm RFQ
|
||||
final_vendor_id = fields.Many2one(
|
||||
comodel_name='res.partner',
|
||||
string='Final Vendor',
|
||||
# domain="[('supplier_rank', '>', 0)]",
|
||||
help="Nhà cung cấp chính thức được bộ phận Mua hàng chốt."
|
||||
)
|
||||
|
||||
quantity = fields.Float(
|
||||
string='Quantity',
|
||||
default=1.0,
|
||||
digits='Product Unit of Measure', # Sử dụng độ chính xác cấu hình trong hệ thống
|
||||
required=True,
|
||||
help="Số lượng cần mua."
|
||||
)
|
||||
|
||||
estimated_price = fields.Monetary(
|
||||
string='Estimated Unit Price',
|
||||
currency_field='currency_id',
|
||||
help="Đơn giá ước tính tại thời điểm yêu cầu."
|
||||
)
|
||||
|
||||
subtotal_estimated = fields.Monetary(
|
||||
string='Estimated Subtotal',
|
||||
compute='_compute_subtotal_estimated',
|
||||
store=True,
|
||||
currency_field='currency_id',
|
||||
help="Tổng tiền ước tính (Số lượng * Đơn giá)."
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# COMPUTE FIELDS
|
||||
# ==========================================================================
|
||||
|
||||
@api.depends('quantity', 'estimated_price')
|
||||
def _compute_subtotal_estimated(self):
|
||||
for line in self:
|
||||
# Đảm bảo giá trị là số (0.0) nếu người dùng chưa nhập
|
||||
qty = line.quantity or 0.0
|
||||
price = line.estimated_price or 0.0
|
||||
line.subtotal_estimated = qty * price
|
||||
|
||||
# ==========================================================================
|
||||
# ONCHANGE FIELDS
|
||||
# ==========================================================================
|
||||
@api.onchange('user_vendor_id')
|
||||
def _onchange_user_vendor_id(self):
|
||||
"""
|
||||
UX Logic:
|
||||
1. Nếu User chọn Vendor ID -> Tự động điền text & set Final Vendor.
|
||||
2. Nếu User bỏ chọn Vendor ID -> Xóa Final Vendor để Purchasing xử lý lại.
|
||||
"""
|
||||
if self.user_vendor_id:
|
||||
# User đã chọn vendor từ danh bạ
|
||||
self.suggested_vendor_name = self.user_vendor_id.name
|
||||
self.final_vendor_id = self.user_vendor_id
|
||||
else:
|
||||
# User bỏ chọn vendor
|
||||
self.final_vendor_id = False
|
||||
# suggested_vendor_name giữ nguyên để User có thể nhập thủ công
|
||||
|
||||
@api.constrains('user_vendor_id', 'suggested_vendor_name')
|
||||
def _check_vendor_presence(self):
|
||||
"""
|
||||
Data Integrity: Bắt buộc phải có ít nhất 1 thông tin về nhà cung cấp.
|
||||
"""
|
||||
for line in self:
|
||||
if not line.user_vendor_id and not line.suggested_vendor_name:
|
||||
raise ValidationError(_("Dòng sản phẩm '%s': Vui lòng chọn Nhà cung cấp hoặc nhập tên đề xuất.", line.name))
|
||||
546
addons/epr/models/epr_rfq.py
Normal file
546
addons/epr/models/epr_rfq.py
Normal file
@ -0,0 +1,546 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, Command, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class EprRfq(models.Model):
|
||||
_name = 'epr.rfq'
|
||||
_description = 'EPR Request for Quotation'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'id desc'
|
||||
|
||||
# === 1. IDENTIFICATION ===
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New')
|
||||
)
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('draft', 'Draft'),
|
||||
('sent', 'Sent'),
|
||||
('received', 'Received'), # Nhà cung cấp đã báo giá
|
||||
('to_approve', 'To Approve'), # Trình sếp duyệt giá
|
||||
('approved', 'Approved'), # Đã duyệt xong, chờ PO
|
||||
('confirmed', 'Confirmed'), # Đã chốt -> Đang tạo/Có PO
|
||||
('cancel', 'Cancelled')
|
||||
],
|
||||
string='Status',
|
||||
readonly=True,
|
||||
index=True,
|
||||
copy=False,
|
||||
default='draft',
|
||||
tracking=True
|
||||
)
|
||||
|
||||
# Tích hợp với Approval Entry
|
||||
approval_state = fields.Selection(
|
||||
selection=[
|
||||
('draft', 'Not Required'),
|
||||
('pending', 'Pending'),
|
||||
('approved', 'Approved'),
|
||||
('refused', 'Refused')
|
||||
],
|
||||
string='Approval Matrix Status',
|
||||
compute='_compute_approval_state',
|
||||
store=True
|
||||
)
|
||||
|
||||
approval_entry_ids = fields.One2many(
|
||||
comodel_name='epr.approval.entry',
|
||||
inverse_name='rfq_id',
|
||||
string='Approvals'
|
||||
)
|
||||
|
||||
# Tính tổng tiền trên RFQ để so sánh trong approval process
|
||||
amount_total = fields.Monetary(
|
||||
compute='_compute_amount_total',
|
||||
string='Total',
|
||||
currency_field='currency_id'
|
||||
)
|
||||
|
||||
department_id = fields.Many2one(
|
||||
comodel_name='hr.department',
|
||||
compute='_compute_department_id',
|
||||
string='Department',
|
||||
store=True,
|
||||
help="Phòng ban của người yêu cầu (lấy từ PR đầu tiên)."
|
||||
)
|
||||
|
||||
@api.depends('request_ids.department_id')
|
||||
def _compute_department_id(self):
|
||||
for rfq in self:
|
||||
# Lấy phòng ban từ PR đầu tiên gắn với RFQ này
|
||||
rfq.department_id = rfq.request_ids[:1].department_id or False
|
||||
|
||||
# === 2. RELATIONS ===
|
||||
# Link ngược lại PR gốc (01 RFQ có thể gom nhiều PR)
|
||||
request_ids = fields.Many2many(
|
||||
comodel_name='epr.purchase.request',
|
||||
relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian
|
||||
column1='rfq_id',
|
||||
column2='request_id',
|
||||
string='Source Requests',
|
||||
# Chỉ lấy các PR đã duyệt để tạo RFQ
|
||||
domain="[('state', '=', 'approved')]"
|
||||
)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
comodel_name='res.partner',
|
||||
string='Vendor',
|
||||
required=True,
|
||||
tracking=True
|
||||
# domain="[('supplier_rank', '>', 0)]", # Chỉ chọn đã từng được chọn qua ít nhất 01 lần
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string='Currency',
|
||||
required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# === 3. DATES ===
|
||||
date_order = fields.Datetime(
|
||||
string='Order Date',
|
||||
default=fields.Datetime.now,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
date_deadline = fields.Date(
|
||||
string='Bid Deadline',
|
||||
help="Ngày hạn chót Vendor phải gửi báo giá",
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# === 4. LINES & PURCHASES ===
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='epr.rfq.line',
|
||||
inverse_name='rfq_id',
|
||||
string='Products',
|
||||
copy=True
|
||||
)
|
||||
|
||||
# Link sang Purchase Order gốc của Odoo (Many2many)
|
||||
purchase_ids = fields.Many2many(
|
||||
comodel_name='purchase.order',
|
||||
relation='epr_rfq_purchase_order_rel',
|
||||
column1='epr_rfq_id',
|
||||
column2='purchase_id',
|
||||
string='Purchase Orders'
|
||||
)
|
||||
|
||||
purchase_count = fields.Integer(
|
||||
compute='_compute_purchase_count',
|
||||
string='PO Count'
|
||||
)
|
||||
|
||||
request_count = fields.Integer(
|
||||
compute='_compute_request_count',
|
||||
string='PR Count'
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 5. COMPUTE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
@api.depends('purchase_ids')
|
||||
def _compute_purchase_count(self):
|
||||
for rfq in self:
|
||||
rfq.purchase_count = len(rfq.purchase_ids)
|
||||
|
||||
# Link ngược về PR gốc
|
||||
@api.depends('request_ids')
|
||||
def _compute_request_count(self):
|
||||
for rfq in self:
|
||||
rfq.request_count = len(rfq.request_ids)
|
||||
|
||||
# Compute Approval State
|
||||
@api.depends('approval_entry_ids.status')
|
||||
def _compute_approval_state(self):
|
||||
for rfq in self:
|
||||
if not rfq.approval_entry_ids:
|
||||
rfq.approval_state = 'draft'
|
||||
continue
|
||||
|
||||
# Nếu có bất kỳ dòng nào bị từ chối -> Toàn bộ bị từ chối
|
||||
if any(e.status == 'refused' for e in rfq.approval_entry_ids):
|
||||
rfq.approval_state = 'refused'
|
||||
# Nếu tất cả đã duyệt -> Approved
|
||||
elif all(e.status == 'approved' for e in rfq.approval_entry_ids):
|
||||
rfq.approval_state = 'approved'
|
||||
# Còn lại là đang chờ
|
||||
else:
|
||||
rfq.approval_state = 'pending'
|
||||
|
||||
# === 6. CRUD OVERRIDES ===
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('epr.rfq') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('line_ids.subtotal')
|
||||
def _compute_amount_total(self):
|
||||
for rfq in self:
|
||||
rfq.amount_total = sum(rfq.line_ids.mapped('subtotal'))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 7. ACTIONS
|
||||
# -------------------------------------------------------------------------
|
||||
def action_send_email(self):
|
||||
"""Gửi email RFQ cho Vendor"""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError(_("Chỉ có thể gửi RFQ khi ở trạng thái Draft."))
|
||||
self.write({'state': 'sent'})
|
||||
return True
|
||||
|
||||
def action_mark_received(self):
|
||||
"""Chuyển trạng thái sang Received khi nhận được phản hồi từ NCC"""
|
||||
for rfq in self:
|
||||
if rfq.state != 'sent':
|
||||
# Tư vấn: Nên chặn nếu nhảy cóc trạng thái để đảm bảo quy trình
|
||||
raise UserError(_("Chỉ có thể đánh dấu 'Đã nhận' khi RFQ đang ở trạng thái 'Đã gửi'."))
|
||||
rfq.write({'state': 'received'})
|
||||
|
||||
def action_confirm(self):
|
||||
"""Chốt RFQ, chuyển sang Confirmed"""
|
||||
for rfq in self:
|
||||
if rfq.state != 'approved':
|
||||
raise UserError(_("Chỉ có thể xác nhận khi RFQ đã được duyệt (Trạng thái 'Approved')."))
|
||||
rfq.write({
|
||||
'state': 'confirmed',
|
||||
})
|
||||
|
||||
def action_cancel_rfq(self):
|
||||
"""Hủy RFQ ở bất kỳ trạng thái nào (trừ khi đã hủy rồi)"""
|
||||
for rfq in self:
|
||||
if rfq.state == 'cancel':
|
||||
continue
|
||||
|
||||
# Nếu đã có PO liên kết, nên cảnh báo hoặc hủy luôn PO con
|
||||
if rfq.purchase_ids and any(po.state not in ['cancel'] for po in rfq.purchase_ids):
|
||||
raise UserError(_("Không thể hủy RFQ này vì đã có Đơn mua hàng (PO) được tạo. Vui lòng hủy PO trước."))
|
||||
|
||||
rfq.write({'state': 'cancel'})
|
||||
|
||||
def action_create_po(self):
|
||||
"""Chuyển đổi RFQ này thành Purchase Order"""
|
||||
self.ensure_one()
|
||||
if not self.line_ids:
|
||||
raise ValidationError(_("Vui lòng thêm sản phẩm trước khi tạo PO."))
|
||||
|
||||
# Logic tạo PO
|
||||
po_vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
'date_order': fields.Datetime.now(),
|
||||
'epr_source_rfq_ids': [Command.link(self.id)], # Link ngược lại RFQ này (Many2many)
|
||||
'origin': self.name,
|
||||
'company_id': self.company_id.id,
|
||||
'currency_id': self.currency_id.id,
|
||||
'order_line': []
|
||||
}
|
||||
|
||||
for line in self.line_ids:
|
||||
po_vals['order_line'].append((0, 0, {
|
||||
'product_id': line.product_id.id,
|
||||
'name': line.description or line.product_id.name,
|
||||
'product_qty': line.quantity,
|
||||
'price_unit': line.price_unit,
|
||||
'product_uom': line.uom_id.id,
|
||||
'date_planned': fields.Datetime.now(),
|
||||
}))
|
||||
|
||||
new_po = self.env['purchase.order'].create(po_vals)
|
||||
|
||||
self.write({'state': 'confirmed'})
|
||||
|
||||
# Mở view PO vừa tạo
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'purchase.order',
|
||||
'res_id': new_po.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Cho phép quay lại Draft nếu cần sửa"""
|
||||
for rfq in self:
|
||||
if rfq.state not in ['sent', 'to_approve', 'cancel']:
|
||||
raise UserError(_("Chỉ có thể reset khi ở trạng thái Sent, To Approve hoặc Cancel."))
|
||||
rfq.write({'state': 'draft'})
|
||||
|
||||
# Mở danh sách các PO được tạo từ RFQ này
|
||||
def action_view_purchase_orders(self):
|
||||
"""Mở danh sách các Purchase Orders (PO) được tạo từ RFQ này"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Purchase Orders'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'purchase.order',
|
||||
'view_mode': 'list,form',
|
||||
# Filter các PO có field epr_source_rfq_ids chứa ID hiện tại
|
||||
'domain': [('epr_source_rfq_ids', 'in', self.ids)],
|
||||
'context': {'default_epr_source_rfq_ids': [Command.link(self.id)]},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# Mở danh sách các PR gốc của RFQ này
|
||||
def action_view_source_requests(self):
|
||||
"""Mở danh sách các PR gốc của RFQ này"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Source Purchase Requests'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'epr.purchase.request',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.request_ids.ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
# -------------------------------------------------------------------------
|
||||
# RFQ APPROVAL PROCESS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def action_submit_approval(self):
|
||||
"""Nút bấm Submit for Approval - Odoo 18 Optimized"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.line_ids:
|
||||
raise UserError(_("Vui lòng nhập chi tiết sản phẩm trước khi trình duyệt."))
|
||||
|
||||
# 1. Quy đổi tiền tệ
|
||||
currency_company = self.company_id.currency_id
|
||||
amount_company = self.amount_total
|
||||
if self.currency_id and self.currency_id != currency_company:
|
||||
amount_company = self.currency_id._convert(
|
||||
self.amount_total, currency_company, self.company_id,
|
||||
self.date_order or fields.Date.context_today(self)
|
||||
)
|
||||
|
||||
# 2. Tìm Rule phù hợp
|
||||
rule = self.env['epr.approval.rule'].search([
|
||||
('active', '=', True),
|
||||
('company_id', '=', self.company_id.id),
|
||||
'|', ('department_id', '=', False), ('department_id', '=', self.department_id.id)
|
||||
], order='department_id desc, sequence asc, id desc', limit=1)
|
||||
|
||||
if not rule:
|
||||
self.write({'state': 'approved', 'approval_state': 'approved'})
|
||||
return
|
||||
# 3. Lọc các bước duyệt dựa trên giá trị đơn hàng
|
||||
applicable_lines = rule.line_ids.filtered(
|
||||
lambda l: (not l.min_amount or l.min_amount <= amount_company)
|
||||
).sorted('sequence')
|
||||
|
||||
if not applicable_lines:
|
||||
self.write({'state': 'approved', 'approval_state': 'approved'})
|
||||
return
|
||||
|
||||
# 3. Lọc và Sắp xếp các bước duyệt
|
||||
applicable_lines = rule.line_ids.filtered(
|
||||
lambda l: (not l.min_amount or l.min_amount <= amount_company)
|
||||
).sorted('sequence')
|
||||
if not applicable_lines:
|
||||
self.write({'state': 'approved', 'approval_state': 'approved'})
|
||||
return
|
||||
|
||||
# 4. Hỗ trợ Duyệt song song cùng tầng (Sequence)
|
||||
self.approval_entry_ids.unlink()
|
||||
vals_list = []
|
||||
min_seq = applicable_lines[0].sequence
|
||||
for line in applicable_lines:
|
||||
# Nếu cùng tầng Sequence nhỏ nhất -> 'new' luôn
|
||||
status = 'new' if line.sequence == min_seq else 'pending'
|
||||
vals_list.append({
|
||||
'rfq_id': self.id,
|
||||
'name': line.name,
|
||||
'sequence': line.sequence,
|
||||
'status': status,
|
||||
'required_user_ids': [Command.set(line.user_ids.ids)],
|
||||
'rule_line_id': line.id,
|
||||
})
|
||||
self.env['epr.approval.entry'].create(vals_list)
|
||||
|
||||
self.write({
|
||||
'state': 'to_approve',
|
||||
'approval_state': 'pending'
|
||||
})
|
||||
|
||||
# Optional: Gửi email thông báo cho người duyệt bước đầu tiên
|
||||
# self._notify_next_approvers()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# APPROVAL LOGIC: LINEARIZATION
|
||||
# -------------------------------------------------------------------------
|
||||
def _check_approval_completion(self):
|
||||
"""
|
||||
Hàm này được gọi mỗi khi 1 dòng entry được Approve/Refuse.
|
||||
Nhiệm vụ: Kích hoạt bước tiếp theo hoặc Confirm RFQ.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# A. Nếu có bất kỳ dòng nào bị từ chối -> Hủy toàn bộ quy trình
|
||||
if any(e.status == 'refused' for e in self.approval_entry_ids):
|
||||
self.write({
|
||||
'state': 'cancel',
|
||||
'approval_state': 'refused'
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
remaining = self.approval_entry_ids.filtered(lambda e: e.status in ['new', 'pending']).sorted('sequence')
|
||||
if not remaining:
|
||||
self.write({'state': 'approved', 'approval_state': 'approved'})
|
||||
return
|
||||
|
||||
# Nếu tầng hiện tại đã duyệt xong hết (không còn ai status='new')
|
||||
if not remaining.filtered(lambda e: e.status == 'new'):
|
||||
# Kích hoạt tầng tiếp theo (tất cả các dòng có Sequence nhỏ nhất còn lại)
|
||||
next_min_seq = remaining[0].sequence
|
||||
remaining.filtered(lambda e: e.sequence == next_min_seq).write({'status': 'new'})
|
||||
|
||||
# ==============================================================================
|
||||
# CLASS CON: epr.rfq.line (Chi tiết hàng hóa trong RFQ)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class EprRfqLine(models.Model):
|
||||
_name = 'epr.rfq.line'
|
||||
_description = 'EPR RFQ Line'
|
||||
|
||||
rfq_id = fields.Many2one(
|
||||
comodel_name='epr.rfq',
|
||||
string='RFQ Reference',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True
|
||||
)
|
||||
|
||||
# === LIÊN KẾT VỚI PR (QUAN TRỌNG) ===
|
||||
# Link về dòng chi tiết của PR gốc
|
||||
pr_line_id = fields.Many2one(
|
||||
'epr.purchase.request.line',
|
||||
string='Source PR Line',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help="Dòng yêu cầu mua hàng gốc sinh ra dòng báo giá này."
|
||||
)
|
||||
|
||||
# Link về PR Header (Tiện ích để group/filter)
|
||||
purchase_request_id = fields.Many2one(
|
||||
related='pr_line_id.request_id',
|
||||
string='Purchase Request',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# === SẢN PHẨM & CHI TIẾT ===
|
||||
product_id = fields.Many2one(
|
||||
comodel_name='product.product',
|
||||
string='Product',
|
||||
required=True
|
||||
)
|
||||
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
quantity = fields.Float(
|
||||
string='Quantity',
|
||||
required=True,
|
||||
default=1.0,
|
||||
digits='Product Unit of Measure'
|
||||
)
|
||||
|
||||
uom_id = fields.Many2one(
|
||||
comodel_name='uom.uom',
|
||||
string='UoM',
|
||||
required=True
|
||||
)
|
||||
|
||||
price_unit = fields.Float(
|
||||
string='Unit Price',
|
||||
digits='Product Price'
|
||||
)
|
||||
|
||||
taxes_id = fields.Many2many(
|
||||
comodel_name='account.tax',
|
||||
relation='epr_rfq_line_taxes_rel',
|
||||
column1='line_id',
|
||||
column2='tax_id',
|
||||
string='Taxes',
|
||||
context={'active_test': False}
|
||||
)
|
||||
|
||||
# === TÍNH TOÁN TIỀN TỆ ===
|
||||
currency_id = fields.Many2one(
|
||||
related='rfq_id.currency_id',
|
||||
store=True,
|
||||
string='Currency',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
subtotal = fields.Monetary(
|
||||
compute='_compute_subtotal',
|
||||
string='Subtotal',
|
||||
store=True,
|
||||
currency_field='currency_id'
|
||||
)
|
||||
|
||||
@api.depends('quantity', 'price_unit', 'taxes_id')
|
||||
def _compute_subtotal(self):
|
||||
"""Tính tổng tiền (chưa bao gồm thuế)"""
|
||||
for line in self:
|
||||
taxes = line.taxes_id.compute_all(
|
||||
line.price_unit,
|
||||
line.currency_id,
|
||||
line.quantity,
|
||||
product=line.product_id,
|
||||
partner=line.rfq_id.partner_id
|
||||
)
|
||||
# Nếu bạn muốn duyệt dựa trên GIÁ SAU THUẾ, dùng 'total_included'
|
||||
# Nếu muốn duyệt trên GIÁ TRƯỚC THUẾ, dùng 'total_excluded'
|
||||
line.subtotal = taxes['total_included']
|
||||
|
||||
# === ONCHANGE PRODUCT (GỢI Ý) ===
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
"""Tự động điền UoM và tên khi chọn sản phẩm"""
|
||||
if self.product_id:
|
||||
self.uom_id = self.product_id.uom_po_id or self.product_id.uom_id
|
||||
self.description = self.product_id.display_name
|
||||
# Tự động lấy thuế mua hàng mặc định của sản phẩm
|
||||
self.taxes_id = self.product_id.supplier_taxes_id
|
||||
|
||||
# ==============================================================================
|
||||
# LINE-LEVEL LINKING LOGIC (From RFQs to POs)
|
||||
# ==============================================================================
|
||||
|
||||
# Link tới dòng của Purchase Order chuẩn Odoo
|
||||
purchase_line_id = fields.Many2one(
|
||||
'purchase.order.line',
|
||||
string='Purchase Order Line',
|
||||
readonly=True,
|
||||
copy=False
|
||||
)
|
||||
|
||||
# Tiện ích để xem nhanh trạng thái
|
||||
po_id = fields.Many2one(
|
||||
related='purchase_line_id.order_id',
|
||||
string='Purchase Order',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
68
addons/epr/security/epr_record_rules.xml
Normal file
68
addons/epr/security/epr_record_rules.xml
Normal file
@ -0,0 +1,68 @@
|
||||
<?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),
|
||||
('employee_id.parent_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>
|
||||
79
addons/epr/security/epr_security.xml
Normal file
79
addons/epr/security/epr_security.xml
Normal 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>
|
||||
37
addons/epr/security/ir.model.access.csv
Normal file
37
addons/epr/security/ir.model.access.csv
Normal file
@ -0,0 +1,37 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_epr_purchase_request_user,ePR Request User,model_epr_purchase_request,group_epr_user,1,1,1,0
|
||||
access_epr_purchase_request_line_user,ePR Request Line User,model_epr_purchase_request_line,group_epr_user,1,1,1,0
|
||||
access_epr_purchase_request_manager,ePR Request Manager,model_epr_purchase_request,group_epr_manager,1,1,1,0
|
||||
access_epr_purchase_request_line_manager,ePR Request Line Manager,model_epr_purchase_request_line,group_epr_manager,1,1,1,0
|
||||
access_epr_purchase_request_officer,ePR Request Officer,model_epr_purchase_request,group_epr_purchasing_officer,1,1,1,0
|
||||
access_epr_purchase_request_line_officer,ePR Request Line Officer,model_epr_purchase_request_line,group_epr_purchasing_officer,1,1,1,0
|
||||
access_epr_purchase_request_admin,ePR Request Admin,model_epr_purchase_request,group_epr_admin,1,1,1,1
|
||||
access_epr_purchase_request_line_admin,ePR Request Line Admin,model_epr_purchase_request_line,group_epr_admin,1,1,1,1
|
||||
access_epr_rfq_officer,ePR RFQ Officer,model_epr_rfq,group_epr_purchasing_officer,1,1,1,0
|
||||
access_epr_rfq_line_officer,ePR RFQ Line Officer,model_epr_rfq_line,group_epr_purchasing_officer,1,1,1,0
|
||||
access_epr_rfq_admin,ePR RFQ Admin,model_epr_rfq,group_epr_admin,1,1,1,1
|
||||
access_epr_rfq_line_admin,ePR RFQ Line Admin,model_epr_rfq_line,group_epr_admin,1,1,1,1
|
||||
access_epr_rfq_manager,ePR RFQ Manager,model_epr_rfq,group_epr_manager,1,1,0,0
|
||||
access_epr_rfq_line_manager,ePR RFQ Line Manager,model_epr_rfq_line,group_epr_manager,1,1,0,0
|
||||
access_epr_approval_rule_officer,ePR Approval Rule Officer,model_epr_approval_rule,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_approval_rule_line_officer,ePR Approval Rule Line Officer,model_epr_approval_rule_line,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_approval_rule_admin,ePR Approval Rule Admin,model_epr_approval_rule,group_epr_admin,1,1,1,1
|
||||
access_epr_approval_rule_line_admin,ePR Approval Rule Line Admin,model_epr_approval_rule_line,group_epr_admin,1,1,1,1
|
||||
access_epr_approval_rule_manager,ePR Approval Rule Manager,model_epr_approval_rule,group_epr_manager,1,0,0,0
|
||||
access_epr_approval_rule_line_manager,ePR Approval Rule Line Manager,model_epr_approval_rule_line,group_epr_manager,1,0,0,0
|
||||
access_epr_approval_entry_user,ePR Approval Entry User,model_epr_approval_entry,group_epr_user,1,0,0,0
|
||||
access_epr_approval_entry_manager,ePR Approval Entry Manager,model_epr_approval_entry,group_epr_manager,1,1,0,0
|
||||
access_epr_approval_entry_officer,ePR Approval Entry Officer,model_epr_approval_entry,group_epr_purchasing_officer,1,1,1,0
|
||||
access_epr_approval_entry_admin,ePR Approval Entry Admin,model_epr_approval_entry,group_epr_admin,1,1,1,1
|
||||
access_epr_reject_wizard_user,ePR Reject Wizard User,model_epr_reject_wizard,group_epr_user,1,0,0,0
|
||||
access_epr_reject_wizard_manager,ePR Reject Wizard Manager,model_epr_reject_wizard,group_epr_manager,1,1,1,1
|
||||
access_epr_reject_wizard_officer,ePR Reject Wizard Officer,model_epr_reject_wizard,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_reject_wizard_admin,ePR Reject Wizard Admin,model_epr_reject_wizard,group_epr_admin,1,1,1,1
|
||||
access_epr_create_po_wizard_officer,ePR Create PO Wizard Officer,model_epr_create_po_wizard,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_create_po_wizard_admin,ePR Create PO Wizard Admin,model_epr_create_po_wizard,group_epr_admin,1,1,1,1
|
||||
access_epr_create_po_line_wizard_officer,ePR Create PO Line Wizard Officer,model_epr_create_po_line_wizard,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_create_po_line_wizard_admin,ePR Create PO Line Wizard Admin,model_epr_create_po_line_wizard,group_epr_admin,1,1,1,1
|
||||
access_epr_create_rfq_wizard_officer,ePR Create RFQ Wizard Officer,model_epr_create_rfq_wizard,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_create_rfq_wizard_admin,ePR Create RFQ Wizard Admin,model_epr_create_rfq_wizard,group_epr_admin,1,1,1,1
|
||||
access_epr_create_rfq_line_officer,ePR Create RFQ Line Officer,model_epr_create_rfq_line,group_epr_purchasing_officer,1,1,1,1
|
||||
access_epr_create_rfq_line_admin,ePR Create RFQ Line Admin,model_epr_create_rfq_line,group_epr_admin,1,1,1,1
|
||||
|
BIN
addons/epr/static/description/icon.png
Normal file
BIN
addons/epr/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
219
addons/epr/views/epr_approval_views.xml
Normal file
219
addons/epr/views/epr_approval_views.xml
Normal file
@ -0,0 +1,219 @@
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 1. APPROVAL RULES (CONFIGURATION) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- List View cho Rule Header -->
|
||||
<record id="view_epr_approval_rule_list" model="ir.ui.view">
|
||||
<field name="name">epr.approval.rule.list</field>
|
||||
<field name="model">epr.approval.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Approval Rules" decoration-muted="not active">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="department_id" optional="show"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View cho Rule Header + Lines -->
|
||||
<record id="view_epr_approval_rule_form" model="ir.ui.view">
|
||||
<field name="name">epr.approval.rule.form</field>
|
||||
<field name="model">epr.approval.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Approval Rule">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<!-- Smart buttons nếu cần -->
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1>
|
||||
<field name="name" placeholder="e.g. Standard PR Approval (> $5000)"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field name="department_id" placeholder="Leave empty for all departments"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Approval Steps" name="steps">
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" default_order="sequence, id">
|
||||
<field name="sequence"/>
|
||||
<field name="name" placeholder="e.g. Manager Approval"/>
|
||||
|
||||
<field name="min_amount"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
|
||||
<field name="user_ids" widget="many2many_tags_avatar" options="{'no_create': True}"/>
|
||||
|
||||
<field name="approval_type"/>
|
||||
</list>
|
||||
</field>
|
||||
<p class="text-muted mt-2">
|
||||
* Rules are processed in order. The first rule matching the criteria (Department, Amount) will be used.
|
||||
</p>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<!-- Chatter cho audit log -->
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View cho Rule -->
|
||||
<record id="view_epr_approval_rule_search" model="ir.ui.view">
|
||||
<field name="name">epr.approval.rule.search</field>
|
||||
<field name="model">epr.approval.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="department_id"/>
|
||||
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Department" name="group_department" context="{'group_by': 'department_id'}"/>
|
||||
<filter string="Company" name="group_company" context="{'group_by': 'company_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 2. APPROVAL ENTRIES (AUDIT / MONITORING) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- View này dành cho Menu "Pending Approvals" hoặc "All Approvals" -->
|
||||
|
||||
<record id="view_epr_approval_entry_list" model="ir.ui.view">
|
||||
<field name="name">epr.approval.entry.list</field>
|
||||
<field name="model">epr.approval.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Approval Requests" create="0" edit="0" decoration-success="status == 'approved'" decoration-danger="status == 'refused'" decoration-info="status == 'new'">
|
||||
<field name="create_date" string="Date"/>
|
||||
<field name="rfq_id" string="Document"/>
|
||||
<field name="name" string="Step"/>
|
||||
<field name="required_user_ids" widget="many2many_tags_avatar"/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-info="status == 'new'"
|
||||
decoration-success="status == 'approved'"
|
||||
decoration-danger="status == 'refused'"
|
||||
decoration-muted="status == 'pending'"/>
|
||||
<field name="actual_user_id"/>
|
||||
<field name="approval_date"/>
|
||||
<button name="action_approve_line" string="Approve" type="object"
|
||||
icon="fa-check text-success"
|
||||
invisible="not can_approve"/>
|
||||
<button name="action_refuse_line" string="Refuse" type="object"
|
||||
icon="fa-times text-danger"
|
||||
invisible="not can_approve"/>
|
||||
<field name="can_approve" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View cho Approval Entry (Manager dùng để duyệt) -->
|
||||
<record id="view_epr_approval_entry_form" model="ir.ui.view">
|
||||
<field name="name">epr.approval.entry.form</field>
|
||||
<field name="model">epr.approval.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Approval Request" create="0">
|
||||
<header>
|
||||
<button name="action_approve_line" string="Approve" type="object"
|
||||
class="oe_highlight" invisible="not can_approve"
|
||||
confirm="Are you sure you want to approve this request?"/>
|
||||
<button name="action_refuse_line" string="Refuse" type="object"
|
||||
invisible="not can_approve"/>
|
||||
<field name="status" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only" string="Step Name"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Document Info">
|
||||
<field name="rfq_id" readonly="1" options="{'no_open': False}"/>
|
||||
<!-- TRƯỜNG BẠN CẦN: Hiển thị tổng tiền -->
|
||||
<field name="amount_total" widget="monetary" string="RFQ Total Amount"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
<group string="Approval Details">
|
||||
<field name="create_date" string="Requested Date" readonly="1"/>
|
||||
<field name="actual_user_id" invisible="status != 'approved'"/>
|
||||
<field name="approval_date" invisible="status != 'approved'"/>
|
||||
<field name="can_approve" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Approvers List">
|
||||
<field name="required_user_ids" widget="many2many_tags_avatar" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View cho Approval Entry -->
|
||||
<record id="view_epr_approval_entry_search" model="ir.ui.view">
|
||||
<field name="name">epr.approval.entry.search</field>
|
||||
<field name="model">epr.approval.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="rfq_id"/>
|
||||
<field name="required_user_ids"/>
|
||||
<filter string="My Approvals" name="my_approvals"
|
||||
domain="[('status','=','new'), ('required_user_ids', 'in', uid)]"/>
|
||||
<filter string="To Approve" name="to_approve" domain="[('status', '=', 'new')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
<filter string="Document" name="group_rfq" context="{'group_by': 'rfq_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 3. ACTIONS & MENUS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Action: Approval Rules (Configuration) -->
|
||||
<record id="action_epr_approval_rule" model="ir.actions.act_window">
|
||||
<field name="name">Approval Rules</field>
|
||||
<field name="res_model">epr.approval.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new Approval Rule
|
||||
</p><p>
|
||||
Define rules to automatically trigger approval workflows based on amount and department.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action: My Approvals (Dashboard) -->
|
||||
<record id="action_epr_my_approvals" model="ir.actions.act_window">
|
||||
<field name="name">My Approvals</field>
|
||||
<field name="res_model">epr.approval.entry</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_my_approvals': 1}</field>
|
||||
<field name="domain">[('status', 'in', ['new', 'pending'])]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No pending approvals found
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
150
addons/epr/views/epr_menus.xml
Normal file
150
addons/epr/views/epr_menus.xml
Normal file
@ -0,0 +1,150 @@
|
||||
<?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"/>
|
||||
|
||||
<menuitem
|
||||
id="menu_epr_rfq_category"
|
||||
name="RFQs"
|
||||
parent="menu_epr_root"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem
|
||||
id="menu_epr_po_category"
|
||||
name="POs"
|
||||
parent="menu_epr_root"
|
||||
sequence="30"/>
|
||||
|
||||
<!--
|
||||
===================================================================
|
||||
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"/>
|
||||
|
||||
<!-- Menu To Approve (Dành cho Manager) -->
|
||||
<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"/>
|
||||
|
||||
<!-- Menu: My RFQs (Action đã định nghĩa ở file view) -->
|
||||
<menuitem
|
||||
id="menu_epr_rfq_act"
|
||||
name="My RFQs"
|
||||
parent="menu_epr_rfq_category"
|
||||
action="action_epr_rfq"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Menu: RFQs Pending Approvals -->
|
||||
<menuitem
|
||||
id="menu_epr_rfq_to_approve"
|
||||
name="Pending Approvals"
|
||||
parent="menu_epr_rfq_category"
|
||||
action="action_epr_my_approvals"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Menu: My POs -->
|
||||
<menuitem
|
||||
id="menu_epr_po_act"
|
||||
name="My POs"
|
||||
parent="menu_epr_po_category"
|
||||
action="action_epr_po"
|
||||
sequence="10"
|
||||
groups="epr.group_epr_admin,epr.group_epr_purchasing_officer"/>
|
||||
|
||||
<!--
|
||||
===================================================================
|
||||
CONFIGURATION MENU
|
||||
===================================================================
|
||||
-->
|
||||
<menuitem id="menu_epr_config"
|
||||
name="Configuration"
|
||||
parent="menu_epr_root"
|
||||
sequence="100"
|
||||
groups="epr.group_epr_admin,epr.group_epr_purchasing_officer"/>
|
||||
|
||||
<!--
|
||||
1. PRODUCTS MENU
|
||||
Action: purchase.product_normal_action_puchased
|
||||
Lý do: Chỉ hiện sản phẩm có 'Purchase OK' = True.
|
||||
Dependency: Cần module 'purchase' trong manifest.
|
||||
-->
|
||||
<menuitem id="menu_epr_config_products"
|
||||
name="Products"
|
||||
parent="menu_epr_config"
|
||||
action="purchase.product_normal_action_puchased"
|
||||
sequence="10"
|
||||
groups="epr.group_epr_admin,epr.group_epr_purchasing_officer"/>
|
||||
|
||||
<!--
|
||||
2. VENDORS MENU
|
||||
Action: account.res_partner_action_supplier
|
||||
Lý do: Đây là action chuẩn của Odoo 18 cho Vendors (hiển thị cột nợ phải trả, bills...)
|
||||
Dependency: Cần module 'account' trong manifest.
|
||||
-->
|
||||
<menuitem id="menu_epr_config_vendors"
|
||||
name="Vendors"
|
||||
parent="menu_epr_config"
|
||||
action="account.res_partner_action_supplier"
|
||||
sequence="20"
|
||||
groups="epr.group_epr_admin,epr.group_epr_purchasing_officer"/>
|
||||
|
||||
<!--
|
||||
3. PRODUCT CATEGORIES
|
||||
Để phân loại sản phẩm tốt hơn
|
||||
-->
|
||||
<menuitem id="menu_epr_config_product_categ"
|
||||
name="Product Categories"
|
||||
parent="menu_epr_config"
|
||||
action="product.product_category_action_form"
|
||||
sequence="30"
|
||||
groups="epr.group_epr_admin,epr.group_epr_purchasing_officer"/>
|
||||
|
||||
<!--
|
||||
4. APPROVAL RULES
|
||||
-->
|
||||
<menuitem id="menu_epr_config_approval_rules"
|
||||
name="Approval Rules"
|
||||
parent="menu_epr_config"
|
||||
action="action_epr_approval_rule"
|
||||
sequence="40"
|
||||
groups="epr.group_epr_admin,epr.group_epr_purchasing_officer"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
76
addons/epr/views/epr_po_views.xml
Normal file
76
addons/epr/views/epr_po_views.xml
Normal file
@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- Kế thừa Form View chuẩn của Purchase Order -->
|
||||
<record id="view_purchase_order_form_inherit_epr" model="ir.ui.view">
|
||||
<field name="name">purchase.order.form.inherit.epr</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- 1. Smart Button: Hiển thị số lượng RFQ nguồn -->
|
||||
<div name="button_box" position="inside">
|
||||
<button name="action_view_epr_rfqs"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-files-o"
|
||||
invisible="epr_rfq_count == 0">
|
||||
<field name="epr_rfq_count" widget="statinfo" string="EPR RFQs"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 2. Tab Other Information: Hiển thị Link Many2many -->
|
||||
<!-- Đặt readonly = 1 để không cho phép chỉnh sửa trường Source Documents (Origin của Odoo) -->
|
||||
<xpath expr="//field[@name='origin']" position="attributes">
|
||||
<attribute name="readonly">1</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- Thêm field epr_source_rfq_ids ngay sau field origin -->
|
||||
<xpath expr="//field[@name='origin']" position="after">
|
||||
<field name="epr_source_rfq_ids"
|
||||
widget="many2many_tags"
|
||||
readonly="1"/>
|
||||
</xpath>
|
||||
|
||||
<!-- 3. Purchase Order Line: Hiển thị tham chiếu chi tiết -->
|
||||
<!-- Odoo 18 uses <list> instead of <tree> inside one2many -->
|
||||
<xpath expr="//field[@name='order_line']/list//field[@name='name']" position="after">
|
||||
<field name="epr_rfq_line_id"
|
||||
string="Ref. RFQ"
|
||||
optional="hide"
|
||||
readonly="1"/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Kế thừa Search View để tìm kiếm PO theo RFQ -->
|
||||
<record id="view_purchase_order_filter_inherit_epr" model="ir.ui.view">
|
||||
<field name="name">purchase.order.search.inherit.epr</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="inherit_id" ref="purchase.view_purchase_order_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="origin" position="after">
|
||||
<!-- Search logic: Tìm PO nào có chứa RFQ Name khớp với từ khóa -->
|
||||
<field name="epr_source_rfq_ids" string="EPR RFQ" filter_domain="[('epr_source_rfq_ids.name', 'ilike', self)]"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action cho menu My POs (Lọc các PO được tạo từ RFQ) -->
|
||||
<record id="action_epr_po" model="ir.actions.act_window">
|
||||
<field name="name">My Purchase Orders (From RFQ)</field>
|
||||
<field name="res_model">purchase.order</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('epr_rfq_count', '>', 0)]</field>
|
||||
<field name="context">{'create': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No Purchase Orders linked to EPR RFQs found.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
370
addons/epr/views/epr_purchase_request_views.xml
Normal file
370
addons/epr/views/epr_purchase_request_views.xml
Normal file
@ -0,0 +1,370 @@
|
||||
<?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. KANBAN VIEW
|
||||
Hiển thị dạng thẻ theo quy trình (Pipeline)
|
||||
===================================================================
|
||||
-->
|
||||
<record id="view_epr_purchase_request_kanban" model="ir.ui.view">
|
||||
<field name="name">epr.purchase.request.kanban</field>
|
||||
<field name="model">epr.purchase.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<!--
|
||||
default_group_by="state": Kích hoạt chế độ cột theo trạng thái.
|
||||
records_draggable="false": Chặn kéo thả nếu bạn muốn quy trình chặt chẽ (chỉ đổi trạng thái bằng nút bấm).
|
||||
Nếu muốn cho phép kéo thả để duyệt nhanh, hãy bỏ attribute này.
|
||||
-->
|
||||
<kanban default_group_by="state"
|
||||
class="o_kanban_small_column"
|
||||
quick_create="false"
|
||||
sample="1">
|
||||
|
||||
<!-- Các field cần dùng trong logic hiển thị -->
|
||||
<field name="state"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="activity_state"/>
|
||||
<field name="id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id"/>
|
||||
<field name="date_required"/>
|
||||
<field name="priority"/>
|
||||
<field name="estimated_total"/>
|
||||
<field name="activity_ids"/>
|
||||
|
||||
<!-- Thanh tiến độ (Progress Bar) trên đầu mỗi cột -->
|
||||
<progressbar field="state"
|
||||
colors='{"draft": "secondary", "to_approve": "warning", "approved": "success", "rejected": "danger"}'/>
|
||||
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<!-- Khung thẻ: oe_kanban_global_click giúp click vào đâu cũng mở form -->
|
||||
<div class="oe_kanban_global_click oe_kanban_card d-flex flex-column">
|
||||
|
||||
<!-- HEADER: Tên + Menu 3 chấm -->
|
||||
<div class="o_kanban_record_top mb-2">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="name"/>
|
||||
</strong>
|
||||
<span class="float-end badge"
|
||||
t-attf-class="badge-{{record.state.raw_value == 'approved' ? 'success' : (record.state.raw_value == 'rejected' ? 'danger' : 'info')}}">
|
||||
<field name="state"/>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Dropdown Menu (Odoo 18 dùng Bootstrap 5) -->
|
||||
<div class="o_dropdown_kanban dropdown">
|
||||
<a class="dropdown-toggle o-no-caret btn" role="button" data-bs-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu">
|
||||
<span class="fa fa-ellipsis-v"/>
|
||||
</a>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<a role="menuitem" type="edit" class="dropdown-item">Edit</a>
|
||||
<a role="menuitem" type="delete" class="dropdown-item">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BODY: Thông tin chính -->
|
||||
<div class="o_kanban_record_body tags-section mb-2">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<field name="employee_id" widget="many2one_avatar_user"/>
|
||||
<span class="ms-2 text-muted small">
|
||||
<field name="department_id"/>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Hiển thị ngày cần hàng nếu có -->
|
||||
<div t-if="record.date_required.raw_value" class="text-muted small">
|
||||
<i class="fa fa-clock-o me-1"/> <field name="date_required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER: Priority + Activity + Total -->
|
||||
<div class="o_kanban_record_bottom mt-auto d-flex justify-content-between align-items-center">
|
||||
<div class="oe_kanban_bottom_left d-flex align-items-center">
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="activity_ids" widget="kanban_activity" class="ms-2"/>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<field name="estimated_total" widget="monetary" options="{'currency_field': 'currency_id'}" class="fw-bold"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</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"/>
|
||||
|
||||
<field name="is_owner" invisible="1"/>
|
||||
|
||||
<!-- Nút Reset: Cho phép PR's owner 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', 'rejected'] or not is_owner"/>
|
||||
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,to_approve,approved,done"/>
|
||||
</header>
|
||||
|
||||
<sheet>
|
||||
<!--
|
||||
BUTTON BOX: Khu vực chứa các nút thống kê/liên kết
|
||||
Thường đặt ở góc trên bên phải của sheet
|
||||
-->
|
||||
<div name="button_box" class="oe_button_box">
|
||||
<button name="action_view_rfqs"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-list-alt"
|
||||
invisible="rfq_count == 0">
|
||||
<field name="rfq_count" widget="statinfo" string="RFQs"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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"/>
|
||||
</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"/>
|
||||
|
||||
<!-- ================= VENDOR SECTION ================= -->
|
||||
<!-- 1. VENDOR LIST (User Search) -->
|
||||
<!-- options="{'no_create': True}": Ngăn User tạo rác dữ liệu -->
|
||||
<field name="user_vendor_id"
|
||||
placeholder="Select existing vendor..."
|
||||
options="{'no_create': True, 'no_create_edit': True}"
|
||||
widget="many2one"/>
|
||||
|
||||
<!-- 2. VENDOR TEXT (User Type)
|
||||
"user_vendor_id": Tự động điền vào trường text nếu đã chọn được vendor trong danh bạ
|
||||
"required="not user_vendor_id": Bắt buộc nhập nếu chưa chọn ID.
|
||||
-->
|
||||
<field name="suggested_vendor_name"
|
||||
placeholder="...or type new vendor name"
|
||||
required="not user_vendor_id"/>
|
||||
<!-- 3. FINAL VENDOR (Purchasing Only)
|
||||
"groups="...": Chỉ hiện cột này cho Purchasing Officer.
|
||||
User thường sẽ hoàn toàn không thấy cột này.
|
||||
"optional="show": Cho phép ẩn/hiện trong menu 3 chấm nếu cần.
|
||||
-->
|
||||
<field name="final_vendor_id"
|
||||
groups="epr.group_epr_purchasing_officer"
|
||||
widget="many2one"
|
||||
optional="show"/>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<group>
|
||||
<group string="Rejection" invisible="not date_rejected">
|
||||
<field name="date_rejected" readonly="1"/>
|
||||
<field name="rejected_by_id" readonly="1"/>
|
||||
<field name="rejection_reason"
|
||||
readonly="1"
|
||||
class="text-danger"/>
|
||||
</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">kanban,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">kanban,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>
|
||||
263
addons/epr/views/epr_rfq_views.xml
Normal file
263
addons/epr/views/epr_rfq_views.xml
Normal file
@ -0,0 +1,263 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- LIST VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_epr_rfq_list" model="ir.ui.view">
|
||||
<field name="name">epr.rfq.list</field>
|
||||
<field name="model">epr.rfq</field>
|
||||
<field name="arch" type="xml">
|
||||
<!-- decoration-muted: Làm mờ dòng nếu trạng thái là 'cancel' -->
|
||||
<list string="RFQs" decoration-muted="state == 'cancel'" sample="1">
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="partner_id" widget="many2one_avatar"/>
|
||||
<field name="date_order" widget="date"/>
|
||||
<field name="date_deadline" widget="remaining_days" optional="show"/>
|
||||
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
|
||||
<!-- Badge hiển thị trạng thái với màu sắc -->
|
||||
<field name="state"
|
||||
widget="badge"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-warning="state in ('sent', 'received')"
|
||||
decoration-success="state == 'confirmed'"
|
||||
decoration-danger="state == 'cancel'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- FORM VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_epr_rfq_form" model="ir.ui.view">
|
||||
<field name="name">epr.rfq.form</field>
|
||||
<field name="model">epr.rfq</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Request for Quotation">
|
||||
<!-- HEADER: Thanh trạng thái và các nút hành động -->
|
||||
<header>
|
||||
<!-- Các nút bấm (Placeholder) - Bạn cần định nghĩa hàm python tương ứng -->
|
||||
<button name="action_send_email"
|
||||
string="Send to Vendor"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="state != 'draft'"/>
|
||||
|
||||
<button name="action_mark_received"
|
||||
string="Mark as Received"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="state != 'sent'"/>
|
||||
|
||||
<button name="action_submit_approval"
|
||||
string="Get Approval"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="state != 'received'"/>
|
||||
|
||||
<button name="action_confirm"
|
||||
string="Confirm RFQ"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="state != 'approved'"/>
|
||||
|
||||
<button name="action_cancel_rfq"
|
||||
string="Cancel RFQ"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="state == 'cancel'"/>
|
||||
|
||||
<button name="action_create_po"
|
||||
string="Create Purchase Order"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state != 'confirmed'"/>
|
||||
|
||||
<button name="action_reset_draft"
|
||||
string="Reset to Draft"
|
||||
type="object"
|
||||
class="btn-warning"
|
||||
invisible="state not in ('sent', 'to_approve', 'approved', 'cancel')"/>
|
||||
|
||||
<!-- Widget Statusbar: Hiển thị quy trình -->
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,sent,received,to_approve,approved,confirmed"/>
|
||||
</header>
|
||||
|
||||
<sheet>
|
||||
<!-- SMART BUTTON AREA -->
|
||||
<!-- Button POs: Hiển thị số lượng POs liên quan -->
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_purchase_orders"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-shopping-cart"
|
||||
invisible="purchase_count == 0">
|
||||
<field name="purchase_count" widget="statinfo" string="POs"/>
|
||||
</button>
|
||||
|
||||
<!-- Button PRs: Hiển thị số lượng PRs liên quan -->
|
||||
<button name="action_view_source_requests"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-text-o"
|
||||
invisible="request_count == 0">
|
||||
<field name="request_count" widget="statinfo" string="PRs"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TITLE: Số phiếu RFQ -->
|
||||
<div class="oe_title">
|
||||
<span class="o_form_label">Request for Quotation</span>
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- GROUP: Thông tin chính -->
|
||||
<group>
|
||||
<group>
|
||||
<!-- Readonly logic: Chỉ cho sửa khi nháp -->
|
||||
<field name="partner_id" widget="res_partner_many2one" context="{'show_address': 1}" readonly="state != 'draft'"/>
|
||||
<field name="request_ids" widget="many2many_tags" readonly="state != 'draft'"/>
|
||||
<field name="currency_id" groups="base.group_multi_currency" readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="date_order" readonly="state != 'draft'"/>
|
||||
<field name="date_deadline" readonly="state != 'draft'"/>
|
||||
<field name="company_id" groups="base.group_multi_company" readonly="1"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- NOTEBOOK: Tab chi tiết -->
|
||||
<notebook>
|
||||
<page string="Products" name="products">
|
||||
<field name="line_ids" readonly="state not in ('draft', 'received')">
|
||||
<list editable="bottom">
|
||||
<field name="product_id" readonly="parent.state != 'draft'"/>
|
||||
<field name="description" optional="show"/>
|
||||
<field name="quantity" readonly="parent.state not in ('draft', 'received')"/>
|
||||
<field name="uom_id" groups="uom.group_uom" readonly="parent.state != 'draft'"/>
|
||||
<field name="price_unit" readonly="parent.state not in ('draft', 'received')"/>
|
||||
<field name="taxes_id" widget="many2many_tags" optional="show" readonly="parent.state not in ('draft', 'received')"/>
|
||||
|
||||
<!-- Monetary: Tự động hiển thị ký hiệu tiền tệ dựa trên currency_id -->
|
||||
<field name="subtotal" widget="monetary"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
|
||||
<!-- Khu vực tổng tiền bên phải -->
|
||||
<group name="note_group" col="6" class="mt-2 mt-md-0">
|
||||
<group class="oe_subtotal_footer oe_right" colspan="2" name="sale_total">
|
||||
<field name="amount_total" widget="monetary" options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
<div class="oe_clear"/>
|
||||
</group>
|
||||
</page>
|
||||
|
||||
<page string="Other Info" name="other_info">
|
||||
<group>
|
||||
<field name="purchase_ids" widget="many2many_tags" readonly="1"/>
|
||||
</group>
|
||||
</page>
|
||||
|
||||
<page string="Approval Matrix" name="approval_matrix">
|
||||
<field name="approval_entry_ids" readonly="1">
|
||||
<list editable="bottom" create="0" delete="0" decoration-success="status=='approved'" decoration-danger="status=='refused'">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="required_user_ids" widget="many2many_tags"/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-info="status == 'new'"
|
||||
decoration-success="status == 'approved'"
|
||||
decoration-muted="status == 'pending'"/>
|
||||
<field name="actual_user_id"/>
|
||||
<field name="approval_date"/>
|
||||
<field name="can_approve" column_invisible="True"/>
|
||||
|
||||
<!-- Buttons -->
|
||||
<button name="action_approve_line" string="Approve" type="object"
|
||||
icon="fa-check" class="text-success"
|
||||
invisible="not can_approve"/>
|
||||
<button name="action_refuse_line" string="Refuse" type="object"
|
||||
icon="fa-times" class="text-danger"
|
||||
invisible="not can_approve"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page string="Source Requests" name="source_requests">
|
||||
<field name="request_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="date_required"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
|
||||
</sheet>
|
||||
|
||||
<!-- CHATTER: Khu vực lịch sử và tin nhắn -->
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SEARCH VIEW -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_epr_rfq_search" model="ir.ui.view">
|
||||
<field name="name">epr.rfq.search</field>
|
||||
<field name="model">epr.rfq</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search RFQ">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="request_ids"/>
|
||||
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Sent" name="sent" domain="[('state', '=', 'sent')]"/>
|
||||
<filter string="Late" name="late" domain="[('date_deadline', '<', current_date), ('state', 'in', ('draft', 'sent'))]"/>
|
||||
|
||||
<separator/>
|
||||
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
|
||||
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Vendor" name="vendor" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Status" name="status" context="{'group_by': 'state'}"/>
|
||||
<filter string="Order Date" name="order_date" context="{'group_by': 'date_order'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ACTION -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="action_epr_rfq" model="ir.actions.act_window">
|
||||
<field name="name">Requests for Quotation</field>
|
||||
<field name="res_model">epr.rfq</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_epr_rfq_search"/>
|
||||
<field name="help" type="html">
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_epr_rfq_list')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('view_epr_rfq_form')})]"/>
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first Request for Quotation (RFQ)
|
||||
</p>
|
||||
<p>
|
||||
Manage your vendor negotiations and create Purchase Orders directly from RFQs.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
3
addons/epr/wizards/__init__.py
Normal file
3
addons/epr/wizards/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import epr_reject_wizard
|
||||
from . import epr_create_rfq
|
||||
from . import epr_create_po
|
||||
BIN
addons/epr/wizards/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/epr/wizards/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/wizards/__pycache__/epr_create_po.cpython-312.pyc
Normal file
BIN
addons/epr/wizards/__pycache__/epr_create_po.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/wizards/__pycache__/epr_create_rfq.cpython-312.pyc
Normal file
BIN
addons/epr/wizards/__pycache__/epr_create_rfq.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/epr/wizards/__pycache__/epr_reject_wizard.cpython-312.pyc
Normal file
BIN
addons/epr/wizards/__pycache__/epr_reject_wizard.cpython-312.pyc
Normal file
Binary file not shown.
181
addons/epr/wizards/epr_create_po.py
Normal file
181
addons/epr/wizards/epr_create_po.py
Normal file
@ -0,0 +1,181 @@
|
||||
from odoo import models, fields, api, Command, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class EprCreatePoWizard(models.TransientModel):
|
||||
_name = 'epr.create.po.wizard'
|
||||
_description = 'Merge RFQs to Purchase Order'
|
||||
|
||||
# Hiển thị Vendor chung để user confirm
|
||||
partner_id = fields.Many2one(
|
||||
comodel_name='res.partner',
|
||||
string='Vendor',
|
||||
required=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string='Currency',
|
||||
required=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Danh sách các dòng sẽ được đẩy vào PO (Cho phép user bỏ tick để xé nhỏ RFQ)
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='epr.create.po.line.wizard',
|
||||
inverse_name='wizard_id',
|
||||
string='Products to Order'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
active_ids = self.env.context.get('active_ids', [])
|
||||
if not active_ids:
|
||||
return res
|
||||
|
||||
# 1. Lấy danh sách RFQ được chọn
|
||||
rfqs = self.env['epr.rfq'].browse(active_ids)
|
||||
|
||||
# 2. Validate: Cùng Vendor và Currency
|
||||
first_partner = rfqs[0].partner_id
|
||||
first_currency = rfqs[0].currency_id
|
||||
|
||||
if any(r.partner_id != first_partner for r in rfqs):
|
||||
raise UserError(_("Tất cả các RFQ được chọn phải cùng một Nhà cung cấp."))
|
||||
|
||||
if any(r.currency_id != first_currency for r in rfqs):
|
||||
raise UserError(_("Tất cả các RFQ được chọn phải cùng loại Tiền tệ."))
|
||||
|
||||
if any(r.state != 'confirmed' for r in rfqs): # Giả sử trạng thái 'confirmed' là đã chốt
|
||||
raise UserError(_("Chỉ có thể tạo PO từ các RFQ đã xác nhận (Confirmed)."))
|
||||
|
||||
# 3. Loop qua từng dòng RFQ để prepare dữ liệu cho Wizard
|
||||
lines_list = []
|
||||
for rfq in rfqs:
|
||||
for line in rfq.line_ids:
|
||||
# Chỉ load những dòng chưa tạo PO
|
||||
if not line.purchase_line_id:
|
||||
lines_list.append(Command.create({
|
||||
'rfq_line_id': line.id,
|
||||
'product_id': line.product_id.id,
|
||||
'description': line.description,
|
||||
'quantity': line.quantity, # User có thể sửa số lượng tại wizard nếu muốn partial
|
||||
'price_unit': line.price_unit,
|
||||
'uom_id': line.uom_id.id,
|
||||
'taxes_id': [Command.set(line.taxes_id.ids)],
|
||||
}))
|
||||
|
||||
if not lines_list:
|
||||
raise UserError(_("Không tìm thấy dòng sản phẩm nào khả dụng để tạo PO (có thể đã được tạo trước đó)."))
|
||||
|
||||
res.update({
|
||||
'partner_id': first_partner.id,
|
||||
'currency_id': first_currency.id,
|
||||
'line_ids': lines_list
|
||||
})
|
||||
return res
|
||||
|
||||
def action_create_po(self):
|
||||
self.ensure_one()
|
||||
if not self.line_ids:
|
||||
raise UserError(_("Vui lòng chọn ít nhất một dòng sản phẩm."))
|
||||
|
||||
# 1. Prepare Header PO (Chuẩn Odoo)
|
||||
po_vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
'currency_id': self.currency_id.id,
|
||||
'date_order': fields.Datetime.now(),
|
||||
'origin': ', '.join(self.line_ids.mapped('rfq_line_id.rfq_id.name')), # Source Document
|
||||
'order_line': [],
|
||||
}
|
||||
|
||||
# 2. Prepare PO Lines
|
||||
for w_line in self.line_ids:
|
||||
po_line_vals = {
|
||||
'product_id': w_line.product_id.id,
|
||||
'name': w_line.description or w_line.product_id.name,
|
||||
'product_qty': w_line.quantity,
|
||||
'price_unit': w_line.price_unit,
|
||||
'product_uom': w_line.uom_id.id,
|
||||
'taxes_id': [Command.set(w_line.taxes_id.ids)],
|
||||
# inherit purchase.order.line để link 2 chiều chặt chẽ
|
||||
'epr_rfq_line_id': w_line.rfq_line_id.id
|
||||
}
|
||||
po_vals['order_line'].append(Command.create(po_line_vals))
|
||||
|
||||
# 3. Tạo PO
|
||||
purchase_order = self.env['purchase.order'].create(po_vals)
|
||||
|
||||
# 4. Update ngược lại RFQ Line (Line-Level Linking)
|
||||
# Vì PO Line được tạo qua Command.create, ta cần map lại ID
|
||||
# Cách đơn giản nhất: Loop lại PO Lines vừa tạo
|
||||
|
||||
# Lưu ý: Logic này giả định thứ tự tạo không đổi, để chính xác tuyệt đối
|
||||
# nên thêm field 'epr_rfq_line_id' vào 'purchase.order.line'
|
||||
for i, po_line in enumerate(purchase_order.order_line):
|
||||
# Lấy dòng wizard tương ứng theo index (nếu thứ tự được bảo toàn)
|
||||
# Hoặc tốt hơn: Thêm field epr_rfq_line_id vào purchase.order.line (Xem mục 3)
|
||||
wizard_line = self.line_ids[i]
|
||||
wizard_line.rfq_line_id.write({'purchase_line_id': po_line.id})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'purchase.order',
|
||||
'res_id': purchase_order.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
|
||||
class EprCreatePoLineWizard(models.TransientModel):
|
||||
_name = 'epr.create.po.line.wizard'
|
||||
_description = 'Line details for PO creation'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
comodel_name='epr.create.po.wizard',
|
||||
string='Wizard',
|
||||
required=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
rfq_line_id = fields.Many2one(
|
||||
comodel_name='epr.rfq.line',
|
||||
string='RFQ Line',
|
||||
required=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
product_id = fields.Many2one(
|
||||
comodel_name='product.product',
|
||||
string='Product',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
quantity = fields.Float(
|
||||
string='Quantity',
|
||||
required=True
|
||||
)
|
||||
|
||||
uom_id = fields.Many2one(
|
||||
comodel_name='uom.uom',
|
||||
string='UoM',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
price_unit = fields.Float(
|
||||
string='Price',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
taxes_id = fields.Many2many(
|
||||
comodel_name='account.tax',
|
||||
string='Taxes',
|
||||
readonly=True
|
||||
)
|
||||
53
addons/epr/wizards/epr_create_po_views.xml
Normal file
53
addons/epr/wizards/epr_create_po_views.xml
Normal file
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_epr_create_po_wizard_form" model="ir.ui.view">
|
||||
<field name="name">epr.create.po.wizard.form</field>
|
||||
<field name="model">epr.create.po.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Purchase Order">
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="currency_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Lines to Order">
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="0">
|
||||
<field name="product_id"/>
|
||||
<field name="description" optional="hide"/>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="taxes_id" widget="many2many_tags"/>
|
||||
<field name="rfq_line_id" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
<footer>
|
||||
<button name="action_create_po"
|
||||
string="Create PO"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Server Action để gọi Wizard từ List View của RFQ -->
|
||||
<record id="action_epr_rfq_create_po" model="ir.actions.act_window">
|
||||
<field name="name">Create Purchase Order</field>
|
||||
<field name="res_model">epr.create.po.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_epr_rfq"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('epr.group_epr_purchasing_officer')), (4, ref('epr.group_epr_admin'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
200
addons/epr/wizards/epr_create_rfq.py
Normal file
200
addons/epr/wizards/epr_create_rfq.py
Normal file
@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, Command, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class EprCreateRfqWizard(models.TransientModel):
|
||||
_name = 'epr.create.rfq.wizard'
|
||||
_description = 'Wizard: Merge PRs to RFQ'
|
||||
|
||||
# Danh sách các dòng PR đang được xử lý trong Wizard
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='epr.create.rfq.line',
|
||||
inverse_name='wizard_id',
|
||||
string='PR Lines to Process'
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. LOAD DATA (BẮT BUỘC CÓ)
|
||||
# -------------------------------------------------------------------------
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""
|
||||
Lấy dữ liệu từ các PR được chọn (active_ids) và điền vào dòng Wizard.
|
||||
"""
|
||||
res = super().default_get(fields_list)
|
||||
|
||||
# Lấy ID của các PR đang được chọn ở màn hình danh sách
|
||||
active_ids = self.env.context.get('active_ids', [])
|
||||
|
||||
if not active_ids:
|
||||
return res
|
||||
|
||||
# Đọc dữ liệu PR
|
||||
requests = self.env['epr.purchase.request'].browse(active_ids)
|
||||
|
||||
# Kiểm tra trạng thái (Optional: chỉ cho phép gộp PR đã duyệt)
|
||||
if any(pr.state != 'approved' for pr in requests):
|
||||
raise UserError(_("Bạn chỉ có thể tạo RFQ từ các PR đã được phê duyệt."))
|
||||
|
||||
lines_vals = []
|
||||
for pr in requests:
|
||||
# Loop qua từng dòng sản phẩm của PR để đưa vào Wizard
|
||||
# Giả sử PR Line có model là 'epr.purchase.request.line'
|
||||
for pr_line in pr.line_ids:
|
||||
lines_vals.append(Command.create({
|
||||
# Link dữ liệu để truy vết sau này
|
||||
'request_id': pr.id,
|
||||
'pr_line_id': pr_line.id,
|
||||
|
||||
# Dữ liệu hiển thị/chỉnh sửa trên wizard
|
||||
'suggested_vendor_name': pr_line.suggested_vendor_name,
|
||||
'final_vendor_id': pr_line.final_vendor_id.id,
|
||||
|
||||
'final_product_id': pr_line.product_id.id,
|
||||
'product_description': pr_line.name or pr_line.product_id.name,
|
||||
'quantity': pr_line.quantity,
|
||||
'uom_id': (
|
||||
pr_line.product_id.uom_po_id.id or
|
||||
pr_line.product_id.uom_id.id
|
||||
),
|
||||
}))
|
||||
|
||||
# Gán danh sách lệnh tạo dòng vào field line_ids
|
||||
res['line_ids'] = lines_vals
|
||||
return res
|
||||
|
||||
def action_create_rfqs(self):
|
||||
"""
|
||||
Gộp PR thành RFQ:
|
||||
1. Validate: Chọn đầy đủ Vendor & Product.
|
||||
2. Gom nhóm theo Vendor.
|
||||
3. Tạo RFQ Header & Lines (Dùng sudo để bypass quyền truy cập PR).
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# 1. Validate & Sync Vendor
|
||||
for line in self.line_ids:
|
||||
if not line.final_vendor_id:
|
||||
raise UserError(_("Vui lòng chọn Vendor cho sản phẩm: %s", line.product_description))
|
||||
|
||||
if line.pr_line_id.final_vendor_id != line.final_vendor_id:
|
||||
line.pr_line_id.sudo().write({
|
||||
'final_vendor_id': line.final_vendor_id.id
|
||||
})
|
||||
|
||||
# 2. Grouping
|
||||
grouped_lines = {}
|
||||
for wiz_line in self.line_ids:
|
||||
vendor = wiz_line.final_vendor_id
|
||||
if vendor not in grouped_lines:
|
||||
grouped_lines[vendor] = self.env['epr.create.rfq.line']
|
||||
grouped_lines[vendor] |= wiz_line
|
||||
|
||||
created_rfqs = self.env['epr.rfq'].sudo()
|
||||
|
||||
# 3. RFQ Creation
|
||||
for vendor, wiz_lines in grouped_lines.items():
|
||||
# A. Lấy danh sách PR unique cho field Many2many
|
||||
source_requests = wiz_lines.mapped('request_id')
|
||||
|
||||
# B. Chuẩn bị dữ liệu lines (One2many)
|
||||
rfq_line_commands = []
|
||||
for wiz_line in wiz_lines:
|
||||
rfq_line_commands.append(Command.create({
|
||||
'product_id': wiz_line.final_product_id.id,
|
||||
'description': wiz_line.product_description,
|
||||
'quantity': wiz_line.quantity,
|
||||
'uom_id': wiz_line.uom_id.id,
|
||||
# Link ngược lại dòng PR gốc để truy vết
|
||||
'pr_line_id': wiz_line.pr_line_id.id
|
||||
}))
|
||||
|
||||
# Tạo RFQ Header
|
||||
rfq_vals = {
|
||||
'partner_id': vendor.id,
|
||||
'state': 'draft',
|
||||
'date_order': fields.Datetime.now(),
|
||||
'request_ids': [Command.set(source_requests.ids)],
|
||||
'line_ids': rfq_line_commands,
|
||||
}
|
||||
|
||||
rfq = self.env['epr.rfq'].create(rfq_vals)
|
||||
created_rfqs |= rfq
|
||||
|
||||
# 4. Redirect
|
||||
if not created_rfqs:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
action = {
|
||||
'name': _('Generated RFQs'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'epr.rfq',
|
||||
'context': {'create': False},
|
||||
}
|
||||
|
||||
if len(created_rfqs) == 1:
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = created_rfqs.id
|
||||
else:
|
||||
action['view_mode'] = 'list,form' # Odoo 18 dùng 'list'
|
||||
action['domain'] = [('id', 'in', created_rfqs.ids)]
|
||||
|
||||
return action
|
||||
|
||||
|
||||
class EprCreateRfqLine(models.TransientModel):
|
||||
_name = 'epr.create.rfq.line'
|
||||
_description = 'Wizard Line: PR Details'
|
||||
|
||||
wizard_id = fields.Many2one('epr.create.rfq.wizard', string='Wizard')
|
||||
|
||||
# Dữ liệu PR gốc
|
||||
request_id = fields.Many2one(
|
||||
'epr.purchase.request',
|
||||
string='PR',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
pr_line_id = fields.Many2one(
|
||||
'epr.purchase.request.line',
|
||||
string='PR Line',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
suggested_vendor_name = fields.Char(string='Suggested Vendor', readonly=True)
|
||||
|
||||
# Cho phép User chọn/sửa trong Wizard
|
||||
final_vendor_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Final Vendor',
|
||||
required=True
|
||||
)
|
||||
|
||||
final_product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Final Product',
|
||||
required=True
|
||||
)
|
||||
|
||||
product_description = fields.Char(string='Description')
|
||||
quantity = fields.Float(
|
||||
string='Qty',
|
||||
digits='Product Unit of Measure'
|
||||
)
|
||||
|
||||
uom_id = fields.Many2one(
|
||||
'uom.uom',
|
||||
string='UoM'
|
||||
)
|
||||
|
||||
uom_name = fields.Char(
|
||||
string='PR UoM',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
@api.onchange('final_product_id')
|
||||
def _onchange_final_product_id(self):
|
||||
if self.final_product_id:
|
||||
product = self.final_product_id
|
||||
self.uom_id = product.uom_po_id or product.uom_id
|
||||
96
addons/epr/wizards/epr_create_rfq_views.xml
Normal file
96
addons/epr/wizards/epr_create_rfq_views.xml
Normal file
@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- WIZARD FORM VIEW: Create RFQ from PRs -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="view_epr_create_rfq_wizard_form" model="ir.ui.view">
|
||||
<field name="name">epr.create.rfq.wizard.form</field>
|
||||
<field name="model">epr.create.rfq.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create RFQ from Purchase Requests">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>Batch Create RFQs</h2>
|
||||
<p class="text-muted">
|
||||
Review lines below. Assign a <b>Final Vendor</b> to group them.
|
||||
Lines with the same Final Vendor will be merged into one RFQ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="0" delete="0"
|
||||
decoration-danger="not final_vendor_id">
|
||||
<!-- PR Reference (readonly) -->
|
||||
<field name="request_id"
|
||||
string="Source PR"
|
||||
readonly="1"
|
||||
force_save="1"
|
||||
options="{'no_open': True}"/>
|
||||
|
||||
<!-- Product Description (now editable as requested) -->
|
||||
<field name="product_description"/>
|
||||
|
||||
<!-- Final Product (editable - main action field) -->
|
||||
<field name="final_product_id"
|
||||
string="Final Product"
|
||||
options="{'no_create': True}"
|
||||
placeholder="Select Product to Group..."
|
||||
decoration-bf="final_product_id"/>
|
||||
|
||||
<!-- Quantity -->
|
||||
<field name="quantity"/>
|
||||
|
||||
<!-- UOM -->
|
||||
<field name="uom_name" optional="show" string="PR UoM"/>
|
||||
<field name="uom_id" groups="uom.group_uom" optional="show"/>
|
||||
|
||||
<!-- Suggested Vendor Text (readonly - for reference) -->
|
||||
<field name="suggested_vendor_name"
|
||||
string="Suggested Vendor"
|
||||
decoration-muted="1"
|
||||
optional="show"/>
|
||||
|
||||
<!-- Final Vendor (editable - main action field) -->
|
||||
<field name="final_vendor_id"
|
||||
string="Final Vendor"
|
||||
options="{'no_create': True}"
|
||||
placeholder="Select Vendor to Group..."
|
||||
decoration-bf="final_vendor_id"/>
|
||||
|
||||
<!-- Hidden fields -->
|
||||
<field name="pr_line_id" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
|
||||
<footer>
|
||||
<button name="action_create_rfqs"
|
||||
string="Create RFQ(s)"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ACTION: Binding to PR List View -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="action_epr_pr_create_rfq" model="ir.actions.act_window">
|
||||
<field name="name">Create RFQ</field>
|
||||
<field name="res_model">epr.create.rfq.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<!-- Binding to epr.purchase.request list view -->
|
||||
<field name="binding_model_id" ref="model_epr_purchase_request"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<!-- Only Purchasing Officer and Admin can see this action -->
|
||||
<field name="groups_id" eval="[(4, ref('epr.group_epr_purchasing_officer')), (4, ref('epr.group_epr_admin'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
81
addons/epr/wizards/epr_reject_wizard.py
Normal file
81
addons/epr/wizards/epr_reject_wizard.py
Normal 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 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
|
||||
}
|
||||
96
addons/epr/wizards/epr_reject_wizard_views.xml
Normal file
96
addons/epr/wizards/epr_reject_wizard_views.xml
Normal 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>
|
||||
23
addons/foo/__manifest__.py
Normal file
23
addons/foo/__manifest__.py
Normal file
@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# https://www.odoo.com/documentation/18.0/developer/reference/backend/module.html
|
||||
{
|
||||
'name': 'Foo',
|
||||
'summary': """Foo custom addon""",
|
||||
'description': """Foo module description""",
|
||||
'author': 'minhng.info',
|
||||
'maintainer': 'minhng.info',
|
||||
'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',
|
||||
'depends': [
|
||||
'sale',
|
||||
'hr',
|
||||
],
|
||||
'data': [],
|
||||
'demo': [],
|
||||
'css': [],
|
||||
# 'qweb': ['static/src/xml/*.xml'],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
}
|
||||
3
addons/zoo/__init__.py
Normal file
3
addons/zoo/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
44
addons/zoo/__manifest__.py
Normal file
44
addons/zoo/__manifest__.py
Normal file
@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# https://www.odoo.com/documentation/18.0/developer/reference/backend/module.html
|
||||
{
|
||||
'name': 'Zoo City',
|
||||
'summary': """Zoo City Tutorials""",
|
||||
'description': """Building my own zoo city""",
|
||||
'author': 'minhng.info',
|
||||
'maintainer': 'minhng.info',
|
||||
'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',
|
||||
'mail',
|
||||
'hr',
|
||||
],
|
||||
|
||||
# Data files declaration
|
||||
'data': [
|
||||
'security/zoo_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/zoo_animal_views.xml',
|
||||
'views/zoo_creature_views.xml',
|
||||
'views/zoo_cage_views.xml',
|
||||
'views/zoo_health_records.xml',
|
||||
'views/zoo_animal_meal_views.xml',
|
||||
'views/zoo_diet_plans.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,
|
||||
}
|
||||
BIN
addons/zoo/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/zoo/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
addons/zoo/controllers/__init__.py
Normal file
1
addons/zoo/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
BIN
addons/zoo/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/zoo/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/controllers/__pycache__/main.cpython-312.pyc
Normal file
BIN
addons/zoo/controllers/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
33
addons/zoo/controllers/main.py
Normal file
33
addons/zoo/controllers/main.py
Normal file
@ -0,0 +1,33 @@
|
||||
import odoo
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
|
||||
from odoo.http import request
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def convert_datetime(d):
|
||||
if d:
|
||||
return d.strftime(DEFAULT_SERVER_DATETIME_FORMAT) if isinstance(d, datetime.datetime) else d.strftime(DEFAULT_SERVER_DATE_FORMAT)
|
||||
else:
|
||||
return False
|
||||
|
||||
class ZooAPI(odoo.http.Controller):
|
||||
@odoo.http.route('/api/zoo/animal/<id>', type='http', auth='none', cors='*', csrf=False)
|
||||
def get_animal_by_id(self, id, **kw):
|
||||
env = request.env
|
||||
id = int(id)
|
||||
model = "zoo.animal"
|
||||
record = env[model].sudo().search([('id', '=', id)])
|
||||
if record:
|
||||
res = {
|
||||
"name": record.name,
|
||||
"dob": convert_datetime(d=record.dob),
|
||||
"gender": record.gender,
|
||||
"feed_time": convert_datetime(d=record.feed_time),
|
||||
}
|
||||
_logger.warning(res)
|
||||
return request.make_json_response(res, status=200)
|
||||
else:
|
||||
return request.make_json_response({}, status=200)
|
||||
21
addons/zoo/data/product_food_import.csv
Normal file
21
addons/zoo/data/product_food_import.csv
Normal file
@ -0,0 +1,21 @@
|
||||
name,default_code,type,list_price,standard_price,uom_id/id,categ_id/id,description
|
||||
Beef (Raw),FOOD001,consu,15.50,12.00,uom.product_uom_kgm,product.product_category_all,Fresh raw beef for carnivores
|
||||
Chicken (Raw),FOOD002,consu,8.75,6.50,uom.product_uom_kgm,product.product_category_all,Fresh raw chicken meat
|
||||
Fish (Whole),FOOD003,consu,12.00,9.00,uom.product_uom_kgm,product.product_category_all,Fresh whole fish for aquatic animals
|
||||
Pork (Raw),FOOD004,consu,10.50,8.00,uom.product_uom_kgm,product.product_category_all,Fresh pork meat
|
||||
Deer Meat,FOOD005,consu,18.00,14.00,uom.product_uom_kgm,product.product_category_all,Premium deer meat for large carnivores
|
||||
Grass Hay,FOOD006,consu,3.50,2.00,uom.product_uom_kgm,product.product_category_all,Dried grass hay for herbivores
|
||||
Alfalfa,FOOD007,consu,4.25,2.50,uom.product_uom_kgm,product.product_category_all,High quality alfalfa
|
||||
Fresh Vegetables,FOOD008,consu,5.00,3.50,uom.product_uom_kgm,product.product_category_all,Mixed fresh vegetables
|
||||
Fruits (Mixed),FOOD009,consu,6.50,4.50,uom.product_uom_kgm,product.product_category_all,Assorted fresh fruits
|
||||
Bamboo,FOOD010,consu,7.00,5.00,uom.product_uom_kgm,product.product_category_all,Fresh bamboo shoots and stalks
|
||||
Fish Pellets,FOOD011,consu,4.50,3.00,uom.product_uom_kgm,product.product_category_all,Specialized fish food pellets
|
||||
Bird Seed Mix,FOOD012,consu,3.00,2.00,uom.product_uom_kgm,product.product_category_all,Mixed seeds for birds
|
||||
Insects (Live),FOOD013,consu,12.50,9.00,uom.product_uom_kgm,product.product_category_all,Live insects for insectivores
|
||||
Rabbit (Whole),FOOD014,consu,9.00,7.00,uom.product_uom_kgm,product.product_category_all,Whole rabbit for medium carnivores
|
||||
Squid,FOOD015,consu,11.00,8.50,uom.product_uom_kgm,product.product_category_all,Fresh squid for marine animals
|
||||
Krill,FOOD016,consu,14.00,11.00,uom.product_uom_kgm,product.product_category_all,Frozen krill for aquatic animals
|
||||
Lettuce,FOOD017,consu,2.50,1.50,uom.product_uom_kgm,product.product_category_all,Fresh lettuce leaves
|
||||
Carrots,FOOD018,consu,2.00,1.20,uom.product_uom_kgm,product.product_category_all,Fresh carrots
|
||||
Sweet Potato,FOOD019,consu,3.00,2.00,uom.product_uom_kgm,product.product_category_all,Fresh sweet potatoes
|
||||
Apples,FOOD020,consu,4.00,3.00,uom.product_uom_kgm,product.product_category_all,Fresh apples
|
||||
|
31
addons/zoo/data/zoo_animal_import.csv
Normal file
31
addons/zoo/data/zoo_animal_import.csv
Normal file
@ -0,0 +1,31 @@
|
||||
name,description,dob,gender,weight,nickname,is_alive,is_purchased,purchase_price,creature_id/name,cage_id/name
|
||||
Simba,Majestic lion with golden mane,2018-05-15,male,190.5,King,True,True,15000.0,Lion,Savanna Habitat
|
||||
Nala,Beautiful lioness and pride leader,2019-03-20,female,130.0,Queen,True,True,12000.0,Lion,Savanna Habitat
|
||||
Shere Khan,Bengal tiger with striking orange stripes,2017-08-10,male,220.0,Khan,True,True,25000.0,Tiger,Rainforest Pavilion
|
||||
Rajah,Playful tiger cub learning to hunt,2022-11-05,female,45.5,Raja,True,False,0.0,Tiger,Rainforest Pavilion
|
||||
Dumbo,Gentle giant elephant with big ears,2015-01-12,male,5400.0,Big Ears,True,True,50000.0,Elephant,Safari Plains
|
||||
Ellie,Young elephant loves to play in water,2020-06-30,female,2800.0,Ellie,True,False,0.0,Elephant,Safari Plains
|
||||
Melman,Tall giraffe with beautiful spots,2016-07-22,male,1200.0,Mel,True,True,18000.0,Giraffe,Savanna Habitat
|
||||
Gloria,Graceful giraffe mother,2018-09-15,female,950.0,Glori,True,True,16000.0,Giraffe,Savanna Habitat
|
||||
Marty,Striped zebra always energetic,2019-04-08,male,350.0,Stripey,True,True,8000.0,Zebra,Safari Plains
|
||||
Zara,Young zebra with perfect stripes,2021-12-20,female,280.0,Z,True,False,0.0,Zebra,Safari Plains
|
||||
Skipper,Penguin leader of the colony,2020-02-14,male,5.5,Skip,True,True,3000.0,Penguin,Penguin Cove
|
||||
Kowalski,Smart penguin loves fish,2020-02-14,male,5.2,Kowl,True,True,3000.0,Penguin,Penguin Cove
|
||||
Private,Young penguin still learning,2021-08-01,male,4.8,Priv,True,False,0.0,Penguin,Penguin Cove
|
||||
Rico,Tough penguin protects the group,2020-02-14,male,5.7,Ric,True,True,3000.0,Penguin,Penguin Cove
|
||||
Aurora,Beautiful white polar bear female,2016-11-30,female,450.0,Rory,True,True,35000.0,Polar Bear,Polar Pavilion
|
||||
Blizzard,Large male polar bear king of arctic,2015-09-18,male,550.0,Bliz,True,True,40000.0,Polar Bear,Arctic Zone
|
||||
Flipper,Friendly dolphin loves to jump,2019-05-25,male,180.0,Flip,True,True,20000.0,Dolphin,Aquatic Center
|
||||
Echo,Dolphin with beautiful voice,2020-07-12,female,165.0,Echi,True,True,18000.0,Dolphin,Tropical Reef
|
||||
Jaws,Impressive great white shark,2014-03-03,male,900.0,Big J,True,True,45000.0,Shark,Aquatic Center
|
||||
Marina,Graceful reef shark,2018-10-20,female,220.0,Mari,True,True,15000.0,Shark,Tropical Reef
|
||||
Freedom,Majestic bald eagle symbol of liberty,2017-06-04,male,6.5,Free,True,True,5000.0,Eagle,Bird Sanctuary
|
||||
Liberty,Female eagle partner of Freedom,2018-04-15,female,5.8,Libby,True,True,4500.0,Eagle,Bird Sanctuary
|
||||
Zeus,Powerful golden eagle,2016-01-20,male,7.2,Z-man,True,True,6000.0,Eagle,Bird Sanctuary
|
||||
Spirit,Young eagle learning to fly,2022-05-10,male,4.5,Spir,True,False,0.0,Eagle,Mountain Ridge
|
||||
Leo,Strong second alpha lion,2019-08-22,male,185.0,Leo,True,True,14000.0,Lion,Savanna Habitat
|
||||
Elsa,Brave lioness hunter,2020-10-11,female,125.0,Ice Queen,True,True,11000.0,Lion,Desert Dome
|
||||
Tony,Young energetic tiger,2021-03-15,male,95.0,T-Rex,True,False,0.0,Tiger,Rainforest Pavilion
|
||||
Shira,Rare beautiful white tiger,2018-12-25,female,140.0,Snow,True,True,30000.0,Tiger,Primate Paradise
|
||||
Trunks,Adorable baby elephant,2023-02-28,male,800.0,Trunk,True,False,0.0,Elephant,Safari Plains
|
||||
Patches,Cute giraffe calf with unique spots,2023-01-05,female,320.0,Patchy,True,False,0.0,Giraffe,Savanna Habitat
|
||||
|
16
addons/zoo/data/zoo_cage_import.csv
Normal file
16
addons/zoo/data/zoo_cage_import.csv
Normal file
@ -0,0 +1,16 @@
|
||||
name,capacity,location,cage_type,area,description,active
|
||||
Savanna Habitat,10,North Wing,outdoor,500.5,Large outdoor habitat for African animals,True
|
||||
Rainforest Pavilion,8,East Wing,indoor,350.0,Climate-controlled rainforest environment,True
|
||||
Arctic Zone,6,South Wing,outdoor,400.0,Cold climate habitat with ice features,True
|
||||
Aquatic Center,15,West Wing,aquarium,600.0,Large aquarium for marine life,True
|
||||
Bird Sanctuary,20,Central Area,aviary,450.0,Open-air aviary with netting,True
|
||||
Desert Dome,5,North Wing,indoor,300.0,Heated desert environment,True
|
||||
Primate Paradise,12,East Wing,outdoor,380.0,Multi-level jungle gym for primates,True
|
||||
Reptile House,10,South Wing,indoor,250.0,Temperature-controlled reptile habitat,True
|
||||
Polar Pavilion,4,West Wing,indoor,320.0,Frozen tundra simulation,True
|
||||
Safari Plains,25,Central Area,outdoor,800.0,Large plains for grazing animals,True
|
||||
Tropical Reef,20,East Wing,aquarium,550.0,Coral reef ecosystem,True
|
||||
Mountain Ridge,8,North Wing,outdoor,420.0,Rocky mountain terrain,True
|
||||
Nocturnal Gallery,6,South Wing,indoor,180.0,Dark environment for nocturnal animals,True
|
||||
Penguin Cove,15,West Wing,outdoor,400.0,Icy pool for penguins,True
|
||||
Butterfly Garden,30,Central Area,aviary,200.0,Glass dome for butterflies,True
|
||||
|
11
addons/zoo/data/zoo_creature_import.csv
Normal file
11
addons/zoo/data/zoo_creature_import.csv
Normal file
@ -0,0 +1,11 @@
|
||||
name,environment,is_rare
|
||||
Lion,ground,False
|
||||
Tiger,forest,True
|
||||
Elephant,ground,False
|
||||
Giraffe,ground,False
|
||||
Zebra,ground,False
|
||||
Penguin,cool,False
|
||||
Polar Bear,cool,True
|
||||
Dolphin,ocean,False
|
||||
Shark,sea,True
|
||||
Eagle,sky,False
|
||||
|
11
addons/zoo/demo/1_zoo_keeper_speciality.csv
Normal file
11
addons/zoo/demo/1_zoo_keeper_speciality.csv
Normal file
@ -0,0 +1,11 @@
|
||||
id,name,description
|
||||
speciality_reptile,Reptile Care Specialist,Expertise in caring for snakeslizardsturtles and other reptiles
|
||||
speciality_mammal,Mammal Care Specialist,Expertise in caring for large and small mammals
|
||||
speciality_bird,Avian Care Specialist,Expertise in caring for birds and aviaries
|
||||
speciality_aquatic,Aquatic Animal Specialist,Expertise in caring for fishamphibians and marine life
|
||||
speciality_primate,Primate Behavior Specialist,Expertise in primate care and behavioral enrichment
|
||||
speciality_carnivore,Large Carnivore Specialist,Expertise in caring for big catsbears and other carnivores
|
||||
speciality_herbivore,Herbivore Nutrition Specialist,Expertise in herbivore diet planning and nutrition
|
||||
speciality_nocturnal,Nocturnal Animal Specialist,Expertise in caring for nocturnal species
|
||||
speciality_exotic,Exotic Species Specialist,Expertise in rare and exotic animal care
|
||||
speciality_veterinary,Veterinary Assistant,Expertise in animal health monitoring and medical support
|
||||
|
16
addons/zoo/demo/2_hr_employee_keepers.csv
Normal file
16
addons/zoo/demo/2_hr_employee_keepers.csv
Normal file
@ -0,0 +1,16 @@
|
||||
id,name,is_zoo_keeper,work_email,mobile_phone,speciality_ids/id
|
||||
keeper_sarah_chen,Sarah Chen,TRUE,sarah.chen@zoo.com,+1-555-0101,"speciality_reptile,speciality_exotic"
|
||||
keeper_james_rodriguez,James Rodriguez,TRUE,james.rodriguez@zoo.com,+1-555-0102,"speciality_mammal,speciality_carnivore"
|
||||
keeper_emily_watson,Emily Watson,TRUE,emily.watson@zoo.com,+1-555-0103,speciality_bird
|
||||
keeper_michael_okonkwo,Michael Okonkwo,TRUE,michael.okonkwo@zoo.com,+1-555-0104,"speciality_primate,speciality_veterinary"
|
||||
keeper_lisa_nguyen,Lisa Nguyen,TRUE,lisa.nguyen@zoo.com,+1-555-0105,speciality_aquatic
|
||||
keeper_david_patel,David Patel,TRUE,david.patel@zoo.com,+1-555-0106,speciality_herbivore
|
||||
keeper_anna_kowalski,Anna Kowalski,TRUE,anna.kowalski@zoo.com,+1-555-0107,"speciality_nocturnal,speciality_reptile"
|
||||
keeper_robert_smith,Robert Smith,TRUE,robert.smith@zoo.com,+1-555-0108,speciality_carnivore
|
||||
keeper_maria_santos,Maria Santos,TRUE,maria.santos@zoo.com,+1-555-0109,"speciality_bird,speciality_exotic"
|
||||
keeper_william_jones,William Jones,TRUE,william.jones@zoo.com,+1-555-0110,speciality_mammal
|
||||
keeper_sofia_garcia,Sofia Garcia,TRUE,sofia.garcia@zoo.com,+1-555-0111,speciality_primate
|
||||
keeper_thomas_mueller,Thomas Mueller,TRUE,thomas.mueller@zoo.com,+1-555-0112,"speciality_veterinary,speciality_aquatic"
|
||||
keeper_olivia_brown,Olivia Brown,TRUE,olivia.brown@zoo.com,+1-555-0113,"speciality_herbivore,speciality_mammal"
|
||||
keeper_daniel_kim,Daniel Kim,TRUE,daniel.kim@zoo.com,+1-555-0114,speciality_exotic
|
||||
keeper_isabella_rossi,Isabella Rossi,TRUE,isabella.rossi@zoo.com,+1-555-0115,"speciality_nocturnal,speciality_bird"
|
||||
|
18
addons/zoo/demo/3_zoo_keeper_certification.csv
Normal file
18
addons/zoo/demo/3_zoo_keeper_certification.csv
Normal file
@ -0,0 +1,18 @@
|
||||
id,certificate_code,name,employee_id/id,issue_date,expiry_date,description
|
||||
cert_sarah_1,CERT-2023-001,Advanced Reptile Handling,keeper_sarah_chen,2023-01-15,2026-01-15,Certified in safe handling of venomous and non-venomous reptiles
|
||||
cert_james_1,CERT-2023-002,Big Cat Safety Protocol,keeper_james_rodriguez,2023-02-20,2025-02-20,Certified in safe working practices around large carnivores
|
||||
cert_emily_1,CERT-2023-003,Avian Health Management,keeper_emily_watson,2023-03-10,2026-03-10,Certified in bird disease prevention and health monitoring
|
||||
cert_michael_1,CERT-2023-004,Primate Enrichment Specialist,keeper_michael_okonkwo,2023-04-05,2026-04-05,Certified in behavioral enrichment for primates
|
||||
cert_lisa_1,CERT-2023-005,Aquatic Life Support Systems,keeper_lisa_nguyen,2023-05-12,2026-05-12,Certified in aquarium and aquatic system management
|
||||
cert_david_1,CERT-2023-006,Herbivore Nutrition Certificate,keeper_david_patel,2023-06-18,2026-06-18,Certified in specialized diets for herbivorous animals
|
||||
cert_anna_1,CERT-2023-007,Nocturnal Species Care,keeper_anna_kowalski,2023-07-22,2026-07-22,Certified in care and management of nocturnal animals
|
||||
cert_robert_1,CERT-2023-008,Wildlife First Aid,keeper_robert_smith,2023-08-30,2025-08-30,Certified in emergency first aid for exotic animals
|
||||
cert_maria_1,CERT-2023-009,Exotic Bird Training,keeper_maria_santos,2023-09-14,2026-09-14,Certified in positive reinforcement training for exotic birds
|
||||
cert_william_1,CERT-2023-010,Large Mammal Husbandry,keeper_william_jones,2023-10-08,2026-10-08,Certified in care and management of large mammals
|
||||
cert_sofia_1,CERT-2023-011,Primate Health Monitoring,keeper_sofia_garcia,2023-11-19,2026-11-19,Certified in primate health assessment and monitoring
|
||||
cert_thomas_1,CERT-2023-012,Veterinary Assistant Wildlife,keeper_thomas_mueller,2023-12-05,2025-12-05,Certified veterinary assistant for exotic animals
|
||||
cert_olivia_1,CERT-2024-001,Animal Nutrition Specialist,keeper_olivia_brown,2024-01-20,2027-01-20,Certified in zoo animal nutrition and diet formulation
|
||||
cert_daniel_1,CERT-2024-002,Exotic Species Conservation,keeper_daniel_kim,2024-02-28,2027-02-28,Certified in conservation breeding programs for exotic species
|
||||
cert_isabella_1,CERT-2024-003,Wildlife Behavior Analysis,keeper_isabella_rossi,2024-03-15,2027-03-15,Certified in animal behavior observation and analysis
|
||||
cert_sarah_2,CERT-2024-004,Exotic Animal Transport,keeper_sarah_chen,2024-04-10,2026-04-10,Certified in safe transport of exotic reptiles
|
||||
cert_james_2,CERT-2024-005,Animal CPR and Emergency Response,keeper_james_rodriguez,2024-05-22,2025-05-22,Certified in emergency medical response for zoo animals
|
||||
|
9
addons/zoo/models/__init__.py
Normal file
9
addons/zoo/models/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from . import zoo_animal
|
||||
from . import zoo_creature
|
||||
from . import zoo_cage
|
||||
from . import zoo_health_record
|
||||
from . import zoo_diet_plan
|
||||
from . import zoo_diet_line
|
||||
from . import zoo_animal_meal
|
||||
from . import zoo_husbandry_task
|
||||
from . import zoo_keeper
|
||||
BIN
addons/zoo/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_animal.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_animal.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_animal_meal.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_animal_meal.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_cage.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_cage.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_creature.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_creature.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_diet_line.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_diet_line.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_diet_plan.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_diet_plan.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_health_record.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_health_record.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_husbandry_task.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_husbandry_task.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/models/__pycache__/zoo_keeper.cpython-312.pyc
Normal file
BIN
addons/zoo/models/__pycache__/zoo_keeper.cpython-312.pyc
Normal file
Binary file not shown.
181
addons/zoo/models/zoo_animal.py
Normal file
181
addons/zoo/models/zoo_animal.py
Normal file
@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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(
|
||||
string='Animal Name',
|
||||
required=True)
|
||||
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
dob = fields.Date(
|
||||
string='DOB',
|
||||
required=False)
|
||||
|
||||
gender = fields.Selection([
|
||||
('male', 'Male'),
|
||||
('female', 'Female')
|
||||
],
|
||||
string='Gender',
|
||||
default='male',
|
||||
required=True)
|
||||
|
||||
feed_time = fields.Datetime(
|
||||
string='Feed Time',
|
||||
copy=False)
|
||||
|
||||
is_alive = fields.Boolean('Is Alive',
|
||||
default=True)
|
||||
|
||||
image = fields.Binary("Image",
|
||||
attachment=True,
|
||||
help="Animal Image")
|
||||
|
||||
weight = fields.Float('Weight (kg)')
|
||||
|
||||
weight_pound = fields.Float('Weight (pounds)')
|
||||
|
||||
introduction = fields.Text('Introduction (EN)')
|
||||
|
||||
nickname = fields.Char('Nickname')
|
||||
|
||||
introduction_vn = fields.Html('Introduction (VI)')
|
||||
|
||||
is_purchased = fields.Boolean('Has Been Purchased',
|
||||
default=False)
|
||||
purchase_price = fields.Float('Purchase Price')
|
||||
|
||||
veterinarian_id = fields.Many2one(comodel_name='res.partner',
|
||||
string='Veterinarian')
|
||||
|
||||
age = fields.Integer('Pet Age',
|
||||
compute='_compute_age')
|
||||
|
||||
number_of_children = fields.Integer('Number of Children',
|
||||
compute='_compute_number_of_children')
|
||||
|
||||
mother_id = fields.Many2one(comodel_name='zoo.animal',
|
||||
string='Mother',
|
||||
ondelete='set null') # ondelete: 'set null', 'restrict', 'cascade'
|
||||
|
||||
mother_name = fields.Char('Mother Name',
|
||||
related='mother_id.name')
|
||||
|
||||
female_children_ids = fields.One2many(comodel_name='zoo.animal',
|
||||
inverse_name='mother_id',
|
||||
string='Female Children')
|
||||
|
||||
father_id = fields.Many2one(comodel_name='zoo.animal',
|
||||
string='Father',
|
||||
ondelete='set null')
|
||||
|
||||
father_name = fields.Char('Father Name',
|
||||
related='father_id.name')
|
||||
|
||||
male_children_ids = fields.One2many(comodel_name='zoo.animal',
|
||||
inverse_name='father_id',
|
||||
string='Male Children')
|
||||
|
||||
|
||||
toy_ids = fields.Many2many(comodel_name='product.product',
|
||||
string="Toys",
|
||||
relation='animal_product_toy_rel',
|
||||
column1='col_animal_id',
|
||||
column2='col_product_id')
|
||||
|
||||
creature_id = fields.Many2one(comodel_name='zoo.creature',
|
||||
string='Creature')
|
||||
|
||||
cage_id = fields.Many2one(comodel_name='zoo.cage',
|
||||
string='Cage',
|
||||
ondelete='set null')
|
||||
|
||||
# --- Các hàm tính toán (Compute Functions) ---
|
||||
@api.depends('dob')
|
||||
def _compute_age(self):
|
||||
now = datetime.datetime.now()
|
||||
current_year = now.year
|
||||
for record in self:
|
||||
dob = record.dob
|
||||
if dob:
|
||||
dob_year = dob.year
|
||||
delta_year = current_year - dob_year
|
||||
if delta_year < 0:
|
||||
raise ValidationError(_("Negative age: current year < DOB year!"))
|
||||
record.age = delta_year
|
||||
else:
|
||||
record.age = False
|
||||
pass
|
||||
|
||||
# --- Các hàm ràng buộc (Constraints) ---
|
||||
@api.constrains('dob')
|
||||
def _check_dob(self):
|
||||
for record in self:
|
||||
if record.dob and record.dob.year < 1900:
|
||||
raise ValidationError(_("Invalid DOB!"))
|
||||
|
||||
@api.depends('male_children_ids')
|
||||
def _compute_number_of_children(self):
|
||||
for record in self:
|
||||
record.number_of_children = len(record.male_children_ids)
|
||||
|
||||
|
||||
@api.constrains('father_id', 'mother_id')
|
||||
def _check_parents(self):
|
||||
"""Validate parent relationships"""
|
||||
for record in self:
|
||||
# Kiểm tra cha != mẹ
|
||||
if record.father_id and record.mother_id:
|
||||
if record.father_id == record.mother_id:
|
||||
raise ValidationError(_("Father and Mother cannot be the same animal!"))
|
||||
|
||||
# Kiểm tra cha != record hiện tại
|
||||
if record.father_id:
|
||||
if record.father_id.id == record.id:
|
||||
raise ValidationError(_("An animal cannot be its own father!"))
|
||||
|
||||
# Kiểm tra mẹ != record hiện tại
|
||||
if record.mother_id:
|
||||
if record.mother_id.id == record.id:
|
||||
raise ValidationError(_("An animal cannot be its own mother!"))
|
||||
|
||||
@api.constrains('gender', 'female_children_ids', 'male_children_ids')
|
||||
def _check_gender_children_consistency(self):
|
||||
"""Validate gender and children lists consistency"""
|
||||
for record in self:
|
||||
# Kiểm tra không đồng thời có cả female_children_ids và male_children_ids
|
||||
if record.female_children_ids and record.male_children_ids:
|
||||
raise ValidationError(_(
|
||||
"An animal cannot have both female children list and male children list. "
|
||||
"Please check the parent assignments."
|
||||
))
|
||||
|
||||
# Giới tính đực (male) không được phép có female_children_ids
|
||||
if record.gender == 'male' and record.female_children_ids:
|
||||
raise ValidationError(_(
|
||||
"A male animal cannot have female children in the female_children_ids field. "
|
||||
"Male animals should only have children in male_children_ids (as father)."
|
||||
))
|
||||
|
||||
# Giới tính cái (female) không được phép có male_children_ids
|
||||
if record.gender == 'female' and record.male_children_ids:
|
||||
raise ValidationError(_(
|
||||
"A female animal cannot have male children in the male_children_ids field. "
|
||||
"Female animals should only have children in female_children_ids (as mother)."
|
||||
))
|
||||
|
||||
# --- Các hàm thay đổi (Onchange Functions) ---
|
||||
@api.onchange('weight')
|
||||
def _update_weight_pound(self):
|
||||
self.weight_pound = self.weight * 2.204623
|
||||
|
||||
@api.onchange('weight_pound')
|
||||
def _update_weight_kg(self):
|
||||
self.weight = self.weight_pound / 2.204623
|
||||
175
addons/zoo/models/zoo_animal_meal.py
Normal file
175
addons/zoo/models/zoo_animal_meal.py
Normal file
@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import html2plaintext, format_date
|
||||
|
||||
class ZooAnimalMeal(models.Model):
|
||||
_name = "zoo.animal.meal"
|
||||
_description = "Batch Feeding Record"
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
# Định nghĩa các field
|
||||
record_name = fields.Char(
|
||||
string='Batch Name',
|
||||
compute='_compute_record_name',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
creature_id = fields.Many2one(
|
||||
comodel_name="zoo.creature",
|
||||
string="Species",
|
||||
required=True,
|
||||
help='Loài vật'
|
||||
)
|
||||
|
||||
meal_date = fields.Datetime(
|
||||
string="Meal Date",
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
help='Thời điểm cho ăn'
|
||||
)
|
||||
|
||||
# domain logic: Chỉ chọn được Animal thuộc creature_id đã chọn
|
||||
animal_ids = fields.Many2many(
|
||||
comodel_name='zoo.animal',
|
||||
relation='zoo_animal_meal_animal_rel',
|
||||
column1='meal_id',
|
||||
column2='animal_id',
|
||||
string='Animals Fed',
|
||||
domain="[('creature_id', '=', creature_id), ('is_alive', '=', True)]"
|
||||
)
|
||||
|
||||
allowed_product_ids = fields.Many2many(comodel_name='product.product',
|
||||
string='Allowed Products',
|
||||
related='creature_id.allowed_product_ids')
|
||||
|
||||
# Chọn product để cho ăn
|
||||
product_id = fields.Many2one(comodel_name='product.product',
|
||||
string='Food Item',
|
||||
required=True,
|
||||
domain="[('id', 'in', allowed_product_ids)]")
|
||||
|
||||
uom_id = fields.Many2one(comodel_name='uom.uom',
|
||||
related='product_id.uom_id',
|
||||
string='Unit',
|
||||
readonly=True,
|
||||
required=True)
|
||||
|
||||
qty_per_animal = fields.Float(
|
||||
string='Qty per Animal',
|
||||
default=1.0,
|
||||
required=True,
|
||||
help='Số lượng thức ăn cho mỗi con vật'
|
||||
)
|
||||
|
||||
total_qty = fields.Float(
|
||||
string='Total Qty',
|
||||
compute='_compute_total_qty',
|
||||
store=True,
|
||||
help='Tổng số lượng = Qty per animal × Số động vật'
|
||||
)
|
||||
|
||||
staff_id = fields.Many2one(
|
||||
comodel_name="res.users",
|
||||
string="Staff",
|
||||
default=lambda self: self.env.user,
|
||||
required=True,
|
||||
help='Nhân viên phụ trách cho ăn'
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'), ('done', 'Done')],
|
||||
string='State',
|
||||
default='draft',
|
||||
tracking=True
|
||||
)
|
||||
|
||||
meal_note = fields.Html(
|
||||
string='Meal Note',
|
||||
required=False,
|
||||
help='Ghi chú về bữa ăn',
|
||||
sanitize=True,
|
||||
strip_style=False,
|
||||
translate=False
|
||||
)
|
||||
|
||||
# --- Validation ---
|
||||
@api.constrains('meal_note')
|
||||
def _check_meal_note_content(self):
|
||||
for record in self:
|
||||
if record.meal_note:
|
||||
text_content = html2plaintext(record.meal_note).strip()
|
||||
if not text_content:
|
||||
raise ValidationError(_("Vui lòng nhập nội dung chi tiết về bữa ăn."))
|
||||
|
||||
# --- Computed Fields ---
|
||||
@api.depends('creature_id', 'meal_date', 'product_id')
|
||||
def _compute_record_name(self):
|
||||
for record in self:
|
||||
if record.creature_id and record.meal_date:
|
||||
date_str = format_date(self.env, record.meal_date)
|
||||
# Lấy tên món ăn
|
||||
food_name = record.product_id.name if record.product_id else "..."
|
||||
|
||||
# Format: Lion - Beef - 22/11/2025
|
||||
record.record_name = f"{record.creature_id.name} - {food_name} - {date_str}"
|
||||
else:
|
||||
record.record_name = 'New Meal'
|
||||
|
||||
@api.depends('qty_per_animal', 'animal_ids')
|
||||
def _compute_total_qty(self):
|
||||
for record in self:
|
||||
# Tổng = Định lượng * Số con
|
||||
record.total_qty = record.qty_per_animal * len(record.animal_ids)
|
||||
|
||||
# --- Actions ---
|
||||
def action_load_all_animals(self):
|
||||
"""Load all animals of selected creature"""
|
||||
for record in self:
|
||||
if not record.creature_id:
|
||||
raise UserError(_("Please select a creature first!"))
|
||||
|
||||
# Tìm tất cả thú thuộc loài này
|
||||
animals = self.env['zoo.animal'].search([
|
||||
('creature_id', '=', record.creature_id.id),
|
||||
('is_alive', '=', True)
|
||||
])
|
||||
|
||||
if not animals:
|
||||
raise UserError(_("No alive animals found for this creature!"))
|
||||
|
||||
# Gán vào field Many2many (cú pháp replace: [(6, 0, ids)])
|
||||
record.animal_ids = [(6, 0, animals.ids)]
|
||||
|
||||
return True
|
||||
|
||||
def action_mark_done(self):
|
||||
"""Mark feeding as done"""
|
||||
for record in self:
|
||||
if not record.animal_ids:
|
||||
raise UserError(_("Please select at least one animal!"))
|
||||
if not record.product_id:
|
||||
raise UserError(_("Please select a product!"))
|
||||
|
||||
record.state = 'done'
|
||||
|
||||
return True
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
"""Reset to draft"""
|
||||
self.write({'state': 'draft'})
|
||||
return True
|
||||
|
||||
# Định nghĩa các hàm Action:
|
||||
def action_done(self):
|
||||
for record in self:
|
||||
# 1. Logic trừ kho (Inventory) sẽ viết ở đây
|
||||
# ...
|
||||
|
||||
# 2. Chuyển trạng thái
|
||||
record.state = 'done'
|
||||
|
||||
def action_draft(self):
|
||||
for record in self:
|
||||
record.state = 'draft'
|
||||
56
addons/zoo/models/zoo_cage.py
Normal file
56
addons/zoo/models/zoo_cage.py
Normal file
@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
|
||||
class ZooCage(models.Model):
|
||||
_name = 'zoo.cage'
|
||||
_description = 'Zoo Cage'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(string='Cage Name',
|
||||
required=True,
|
||||
help='Name of the cage')
|
||||
|
||||
capacity = fields.Integer(string='Capacity',
|
||||
help='Maximum number of animals')
|
||||
|
||||
location = fields.Char(string='Location')
|
||||
|
||||
cage_type = fields.Selection([
|
||||
('indoor', 'Indoor'),
|
||||
('outdoor', 'Outdoor'),
|
||||
('aquarium', 'Aquarium'),
|
||||
('aviary', 'Aviary'),
|
||||
],
|
||||
string='Cage Type',
|
||||
default='outdoor',
|
||||
required=True)
|
||||
|
||||
area = fields.Float(string='Area (m²)',
|
||||
help='Cage area in square meters')
|
||||
|
||||
description = fields.Text(string='Description',
|
||||
help='Description of the cage')
|
||||
|
||||
active = fields.Boolean(string='Active',
|
||||
default=True)
|
||||
|
||||
# Định nghĩa một Standard checklist
|
||||
checklist_template_ids = fields.One2many(
|
||||
'zoo.cage.checklist.template',
|
||||
'cage_id',
|
||||
string='Standard Checklist'
|
||||
)
|
||||
|
||||
# Định nghĩa một Standard checklist
|
||||
class ZooCageChecklistTemplate(models.Model):
|
||||
_name = 'zoo.cage.checklist.template'
|
||||
_description = 'Cage Standard Task Template'
|
||||
|
||||
cage_id = fields.Many2one(comodel_name='zoo.cage',
|
||||
string='Cage')
|
||||
|
||||
name = fields.Char(string='Task Description',
|
||||
required=True)
|
||||
|
||||
required = fields.Boolean(string='Required',
|
||||
default=True)
|
||||
53
addons/zoo/models/zoo_creature.py
Normal file
53
addons/zoo/models/zoo_creature.py
Normal file
@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
class ZooCreature(models.Model):
|
||||
_name = "zoo.creature"
|
||||
_description = "Creature"
|
||||
|
||||
name = fields.Char(string='Name',
|
||||
required=True)
|
||||
|
||||
environment = fields.Selection([
|
||||
('water', 'Water'),
|
||||
('ground', 'Ground'),
|
||||
('sky', 'Sky'),
|
||||
('ocean', 'Ocean'),
|
||||
('forest', 'Forest'),
|
||||
('desert', 'Desert'),
|
||||
('mountain', 'Mountain'),
|
||||
('river', 'River'),
|
||||
('lake', 'Lake'),
|
||||
('pond', 'Pond'),
|
||||
('sea', 'Sea'),
|
||||
('cool', 'Cool'),
|
||||
],
|
||||
string='Environment',
|
||||
default='ground')
|
||||
|
||||
is_rare = fields.Boolean('Is Rare',
|
||||
default=False)
|
||||
|
||||
animal_ids = fields.One2many(comodel_name='zoo.animal',
|
||||
inverse_name='creature_id',
|
||||
string='Animals')
|
||||
|
||||
animal_count = fields.Integer(string='Animal Count',
|
||||
compute='_compute_animal_count',
|
||||
store=True)
|
||||
|
||||
allowed_product_ids = fields.Many2many(
|
||||
comodel_name='product.product',
|
||||
relation='zoo_creature_product_rel',
|
||||
column1='creature_id',
|
||||
column2='product_id',
|
||||
string='Allowed Food Products',
|
||||
help='List of food products that can be fed to this species'
|
||||
)
|
||||
|
||||
# Định nghĩa các hàm tính toán
|
||||
@api.depends('animal_ids')
|
||||
def _compute_animal_count(self):
|
||||
for record in self:
|
||||
record.animal_count = len(record.animal_ids)
|
||||
48
addons/zoo/models/zoo_diet_line.py
Normal file
48
addons/zoo/models/zoo_diet_line.py
Normal file
@ -0,0 +1,48 @@
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
class ZooDietLine(models.Model):
|
||||
_name = "zoo.diet.line"
|
||||
_description = '''
|
||||
Chi tiết cấu thành nên Khẩu phần
|
||||
'''
|
||||
|
||||
diet_plan_id = fields.Many2one(comodel_name='zoo.diet.plan',
|
||||
string='Khẩu phần gốc',
|
||||
required=True)
|
||||
|
||||
product_id = fields.Many2one(comodel_name='product.product',
|
||||
string='Nguyên liệu',
|
||||
required=True)
|
||||
|
||||
quantity_per_day = fields.Float(string='Quantity Per Day',
|
||||
required=True)
|
||||
|
||||
uom_id = fields.Many2one(comodel_name='uom.uom',
|
||||
string='UOM',
|
||||
required=True)
|
||||
|
||||
cost_per_unit = fields.Float(
|
||||
string='Cost Per Unit',
|
||||
compute='_compute_cost_per_unit',
|
||||
store=True, # Lưu vào DB để dễ tìm kiếm và báo cáo
|
||||
readonly=True, # Không cho phép người dùng sửa tay
|
||||
required=False,
|
||||
digits='Product Price', # Sử dụng định dạng giá tiền chuẩn của Odoo
|
||||
help='Chi phí tiêu chuẩn của sản phẩm, được lấy tự động từ thẻ sản phẩm.'
|
||||
)
|
||||
|
||||
is_supplement = fields.Boolean(string='Bổ sung (Vitamin, Khoáng chất)',
|
||||
required=False,
|
||||
default=False)
|
||||
|
||||
# Hàm tính toán (Computation Function)
|
||||
@api.depends('product_id')
|
||||
def _compute_cost_per_unit(self):
|
||||
for record in self:
|
||||
# Kiểm tra xem có sản phẩm nào được chọn không
|
||||
if record.product_id:
|
||||
# Lấy giá trị standard_price (Chi phí tiêu chuẩn) từ product.product
|
||||
record.cost_per_unit = record.product_id.standard_price
|
||||
else:
|
||||
record.cost_per_unit = 0.0
|
||||
120
addons/zoo/models/zoo_diet_plan.py
Normal file
120
addons/zoo/models/zoo_diet_plan.py
Normal file
@ -0,0 +1,120 @@
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import html2plaintext
|
||||
|
||||
import datetime
|
||||
|
||||
class ZooDietPlan(models.Model):
|
||||
_name = "zoo.diet.plan"
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_description = '''
|
||||
Là nơi lưu trữ công thức khẩu phần chính thức (Master Data/Recipe) cho các loài hoặc nhóm cá thể
|
||||
'''
|
||||
|
||||
diet_plan_name = fields.Char(string='Diet Plan Name',
|
||||
help='Tên định danh cho công thức',
|
||||
required=True)
|
||||
|
||||
creature_id = fields.Many2one(comodel_name='zoo.creature',
|
||||
string="Creature",
|
||||
required=True)
|
||||
|
||||
diet_type = fields.Selection([
|
||||
('standard', 'Standard'),
|
||||
('clinical', 'Clinical'),
|
||||
('breeding', 'Breeding'),
|
||||
], string='Diet Type',
|
||||
default='standard',
|
||||
required=True)
|
||||
|
||||
nutritionist_id = fields.Many2one(comodel_name='res.partner',
|
||||
string='Nutritionist',
|
||||
help='Đảm bảo người có chuyên môn và kinh nghiệm ',
|
||||
required=True)
|
||||
|
||||
next_review_date = fields.Date(string='Next Review Date',
|
||||
help='Ngày đánh giá định kỳ tiếp theo',
|
||||
required=True)
|
||||
|
||||
diet_status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('under_review', 'Under Review'),
|
||||
('deprecated','Deprecated')
|
||||
], string='Status',
|
||||
default='draft',
|
||||
required=True,
|
||||
tracking=True)
|
||||
|
||||
is_active = fields.Boolean(string='Is Active',
|
||||
default=False,
|
||||
required=False)
|
||||
|
||||
ingredient_ids = fields.One2many(comodel_name='zoo.diet.line',
|
||||
inverse_name='diet_plan_id',
|
||||
string='Ingredient',
|
||||
help='Danh sách thành phần',
|
||||
required=True)
|
||||
|
||||
notes = fields.Html(
|
||||
string='Other Notes',
|
||||
required=False,
|
||||
help='Hướng dẫn pha chế và lưu trữ',
|
||||
sanitize=True, # Bảo mật: Lọc bỏ các mã độc JS/XSS (Mặc định True)
|
||||
strip_style=False, # False: Cho phép giữ lại màu sắc/font chữ khi copy-paste từ Word/Excel
|
||||
translate=False, # True nếu bạn muốn hỗ trợ đa ngôn ngữ cho trường này
|
||||
)
|
||||
|
||||
# Validate kỹ hơn vì required=True của HTML đôi khi vẫn lọt lưới nếu chỉ nhập dấu cách
|
||||
@api.constrains('notes')
|
||||
def _check_notes_content(self):
|
||||
for record in self:
|
||||
if record.notes:
|
||||
# Chuyển HTML sang text thuần để kiểm tra độ dài thực
|
||||
text_content = html2plaintext(record.notes).strip()
|
||||
if not text_content:
|
||||
raise ValidationError("Vui lòng nhập nội dung chi tiết về điều chế và lưu trữ thức ăn cho động vật.")
|
||||
|
||||
# --- Các hàm chuyển trạng thái (State Transition Actions) ---
|
||||
def action_activate(self):
|
||||
"""Chuyển trạng thái từ Draft sang Active"""
|
||||
for record in self:
|
||||
if record.diet_status == 'draft':
|
||||
record.diet_status = 'active'
|
||||
record.is_active = True
|
||||
|
||||
def action_under_review(self):
|
||||
"""Chuyển trạng thái sang Under Review để đánh giá lại"""
|
||||
for record in self:
|
||||
if record.diet_status == 'active':
|
||||
record.diet_status = 'under_review'
|
||||
|
||||
def action_approve(self):
|
||||
"""Approve và chuyển về Active sau khi review"""
|
||||
for record in self:
|
||||
if record.diet_status == 'under_review':
|
||||
record.diet_status = 'active'
|
||||
record.is_active = True
|
||||
|
||||
def action_deprecate(self):
|
||||
"""Đánh dấu diet plan là deprecated (không còn sử dụng)"""
|
||||
for record in self:
|
||||
if record.diet_status != 'deprecated':
|
||||
record.diet_status = 'deprecated'
|
||||
record.is_active = False
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Reset về Draft (từ deprecated hoặc under_review)"""
|
||||
for record in self:
|
||||
if record.diet_status in ('deprecated', 'under_review'):
|
||||
record.diet_status = 'draft'
|
||||
record.is_active = False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
133
addons/zoo/models/zoo_health_record.py
Normal file
133
addons/zoo/models/zoo_health_record.py
Normal file
@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import html2plaintext
|
||||
|
||||
|
||||
class ZooHealthRecord(models.Model):
|
||||
_name = "zoo.health.record"
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_description = '''
|
||||
ghi nhận mọi sự kiện y tế, thủ tục phòng ngừa (preventive medicine), và kết quả chẩn đoán liên quan đến một cá thể Thú cụ thể.
|
||||
'''
|
||||
|
||||
record_name = fields.Char(
|
||||
string='Health Records',
|
||||
compute='_compute_record_name',
|
||||
store=True, # store=True để lưu vào DB, giúp tìm kiếm và filter nhanh hơn
|
||||
required=True, # Bắt buộc (nhưng vì là compute nên hệ thống sẽ tự điền)
|
||||
readonly=True # Thường field compute sẽ không cho sửa tay
|
||||
)
|
||||
|
||||
animal_id = fields.Many2one(comodel_name='zoo.animal',
|
||||
string='Animal',
|
||||
required=True)
|
||||
|
||||
veterinarian_id = fields.Many2one(comodel_name='res.partner',
|
||||
string='Veterinarian',
|
||||
required=True)
|
||||
|
||||
date_occurrence = fields.Date(string='Date Occurrence',
|
||||
default=fields.Date.context_today,
|
||||
required=True,
|
||||
copy=False)
|
||||
|
||||
record_type = fields.Selection([
|
||||
('exam', 'Exam'),
|
||||
('vaccination', 'Vaccination'),
|
||||
('treatment', 'Treatment'),
|
||||
('surgery', 'Surgery'),
|
||||
('quarantine_check', 'Quarantine Check'),
|
||||
], string='Record Type',
|
||||
default='exam',
|
||||
required=True)
|
||||
|
||||
record_state = fields.Selection(
|
||||
selection=[
|
||||
('draft', 'Draft'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled')
|
||||
],
|
||||
string='Status', # Nhãn hiển thị
|
||||
default='draft', # Giá trị mặc định khi tạo mới
|
||||
required=True, # Bắt buộc phải có giá trị
|
||||
copy=False, # Không sao chép trạng thái khi Duplicate record
|
||||
tracking=True, # Ghi log vào chatter khi trạng thái đổi (Cần mail.thread)
|
||||
index=True # Đánh index để tìm kiếm/lọc nhanh hơn
|
||||
)
|
||||
diagnosis = fields.Text(string='Diagnosis',
|
||||
required=True)
|
||||
|
||||
treatment_details = fields.Html(
|
||||
string='Treatment Details',
|
||||
required=True,
|
||||
# Help text hiển thị khi hover chuột vào tiêu đề trường
|
||||
help='Cực kỳ quan trọng. Thiếu tài liệu chi tiết về các thủ tục và liều lượng thuốc là một "recordkeeping deficiencies" phổ biến.',
|
||||
sanitize=True, # Bảo mật: Lọc bỏ các mã độc JS/XSS (Mặc định True)
|
||||
strip_style=False, # False: Cho phép giữ lại màu sắc/font chữ khi copy-paste từ Word/Excel
|
||||
translate=False, # True nếu bạn muốn hỗ trợ đa ngôn ngữ cho trường này
|
||||
)
|
||||
|
||||
is_preventive = fields.Boolean(string='Is Preventive',
|
||||
default=False,
|
||||
required=False)
|
||||
|
||||
next_follow_up_date = fields.Date(string='Next Follow Up Date',
|
||||
required=False,
|
||||
copy=False,
|
||||
tracking=True,
|
||||
help="Ngày dự kiến để theo dõi và đánh giá lại tình trạng sức khỏe."
|
||||
)
|
||||
|
||||
related_cage_id = fields.Many2one(comodel_name='zoo.cage',
|
||||
string='Related Cage',
|
||||
required=False)
|
||||
|
||||
# --- Các hàm tính toán ---
|
||||
@api.depends('animal_id.name', 'date_occurrence')
|
||||
def _compute_record_name(self):
|
||||
for record in self:
|
||||
# Kiểm tra xem đã có dữ liệu chưa để tránh lỗi
|
||||
if record.animal_id and record.date_occurrence:
|
||||
# Format: [Tên Con Vật] - [Ngày]
|
||||
# Ví dụ: Lion King - 2024-11-21
|
||||
record.record_name = f"{record.animal_id.name} - {record.date_occurrence}"
|
||||
else:
|
||||
# Giá trị tạm thời khi chưa nhập đủ thông tin
|
||||
record.record_name = "New Health Record"
|
||||
|
||||
# Validate kỹ hơn vì required=True của HTML đôi khi vẫn lọt lưới nếu chỉ nhập dấu cách
|
||||
@api.constrains('treatment_details')
|
||||
def _check_treatment_details_content(self):
|
||||
for record in self:
|
||||
if record.treatment_details:
|
||||
# Chuyển HTML sang text thuần để kiểm tra độ dài thực
|
||||
text_content = html2plaintext(record.treatment_details).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).")
|
||||
|
||||
# --- Các hàm chuyển trạng thái (State Transition Actions) ---
|
||||
def action_in_progress(self):
|
||||
"""Chuyển trạng thái sang 'In Progress'"""
|
||||
for record in self:
|
||||
if record.record_state == 'draft':
|
||||
record.record_state = 'in_progress'
|
||||
|
||||
def action_completed(self):
|
||||
"""Chuyển trạng thái sang 'Completed'"""
|
||||
for record in self:
|
||||
if record.record_state == 'in_progress':
|
||||
record.record_state = 'completed'
|
||||
|
||||
def action_cancel(self):
|
||||
"""Chuyển trạng thái sang 'Cancelled'"""
|
||||
for record in self:
|
||||
if record.record_state not in ('completed', 'cancelled'):
|
||||
record.record_state = 'cancelled'
|
||||
|
||||
def action_draft(self):
|
||||
"""Reset trạng thái về 'Draft'"""
|
||||
for record in self:
|
||||
if record.record_state == 'cancelled':
|
||||
record.record_state = 'draft'
|
||||
259
addons/zoo/models/zoo_husbandry_task.py
Normal file
259
addons/zoo/models/zoo_husbandry_task.py
Normal file
@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, Command, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import html2plaintext
|
||||
|
||||
|
||||
class ZooHusbandryTask(models.Model):
|
||||
_name = 'zoo.husbandry.task'
|
||||
_description = 'Daily Husbandry & Enrichment Task'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_rec_name = 'display_name_custom'
|
||||
|
||||
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',
|
||||
compute='_compute_display_name_custom',
|
||||
store=True
|
||||
)
|
||||
|
||||
cage_id = fields.Many2one(
|
||||
comodel_name='zoo.cage',
|
||||
domain="[('active', '=', True)]",
|
||||
string='Cage/Enclosure',
|
||||
required=True,
|
||||
tracking=True)
|
||||
|
||||
date = fields.Date(
|
||||
string='Date',
|
||||
default=fields.Date.context_today,
|
||||
required=True)
|
||||
|
||||
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'),
|
||||
('maintenance', 'Minor Maintenance'),
|
||||
('vet_check', 'Visual Vet Check'),
|
||||
('observation', 'Observation'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Task Type',
|
||||
default='routine',
|
||||
required=True)
|
||||
|
||||
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',
|
||||
help='Ghi chú của Keeper',
|
||||
sanitize=True,
|
||||
strip_style=False,
|
||||
translate=False)
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'To Do'),
|
||||
('in_progress', 'In Progress'),
|
||||
('to_approve', 'Waiting Approval'),
|
||||
('done', 'Done'),
|
||||
('cancel', 'Cancelled')
|
||||
],
|
||||
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):
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
# 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):
|
||||
for record in self:
|
||||
cage_name = record.cage_id.name or 'Unknown Cage'
|
||||
date_str = record.date.strftime('%d/%m') if record.date else ''
|
||||
# Kết quả: "Lion Cage - 25/11"
|
||||
record.display_name_custom = f"{cage_name} - {date_str}"
|
||||
|
||||
# --- Kanban Grouping (Để Kanban hiển thị đủ cột Draft/Done dù không có data) ---
|
||||
@api.model
|
||||
def _expand_groups(self, states, domain, order=None):
|
||||
"""Force display all state columns in Kanban, even if empty"""
|
||||
return ['draft', 'in_progress', 'to_approve', 'done', 'cancel']
|
||||
|
||||
# --- ACTIONS ---
|
||||
def action_start(self):
|
||||
self.state = 'in_progress'
|
||||
|
||||
def action_cancel(self):
|
||||
self.state = 'cancel'
|
||||
|
||||
def action_draft(self):
|
||||
self.state = 'draft'
|
||||
|
||||
# --- Validate kỹ hơn vì required=True của HTML đôi khi vẫn lọt lưới nếu chỉ nhập dấu cách
|
||||
@api.constrains('keeper_note')
|
||||
def _check_keeper_note_content(self):
|
||||
for record in self:
|
||||
if record.keeper_note:
|
||||
text_content = html2plaintext(record.keeper_note).strip()
|
||||
if not text_content:
|
||||
raise ValidationError(_("Please enter meaningful content (not just spaces)."))
|
||||
|
||||
# Load template task mỗi khi chọn Chuồng
|
||||
@api.onchange('cage_id')
|
||||
def _onchange_cage_id(self):
|
||||
"""
|
||||
Khi chọn Chuồng:
|
||||
1. Xóa sạch checklist cũ (nếu có).
|
||||
2. Copy checklist mẫu từ cấu hình Chuồng sang Task này.
|
||||
"""
|
||||
if not self.cage_id:
|
||||
return
|
||||
|
||||
# Bước 1: Chuẩn bị danh sách lệnh
|
||||
# Command.clear(): Xóa hết dòng cũ (tránh bị double khi user chọn lại chuồng)
|
||||
lines_commands = [Command.clear()]
|
||||
|
||||
# Bước 2: Lấy mẫu từ zoo.cage
|
||||
if self.cage_id.checklist_template_ids:
|
||||
for template in self.cage_id.checklist_template_ids:
|
||||
# Command.create(values): Tạo dòng mới trong RAM (chưa lưu DB)
|
||||
lines_commands.append(Command.create({
|
||||
'name': template.name,
|
||||
'required': template.required,
|
||||
'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',
|
||||
string='Task',
|
||||
required=True,
|
||||
ondelete='cascade')
|
||||
|
||||
name = fields.Char(
|
||||
string='Description',
|
||||
required=True)
|
||||
|
||||
is_done = fields.Boolean(
|
||||
string='Done',
|
||||
default=False)
|
||||
|
||||
required = fields.Boolean(
|
||||
string='Required',
|
||||
default=True,
|
||||
help="If checked, this item must be done to finish the task.")
|
||||
|
||||
remark = fields.Char(
|
||||
string='Remark',
|
||||
help="Quick note issues here (e.g. Broken lock)")
|
||||
102
addons/zoo/models/zoo_keeper.py
Normal file
102
addons/zoo/models/zoo_keeper.py
Normal file
@ -0,0 +1,102 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 1. MAIN MODEL: KEEPER (Inherit from HR Employee)
|
||||
# ---------------------------------------------------------
|
||||
class ZooKeeper(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
_description = 'Zoo Keeper Management'
|
||||
|
||||
# flag để phân biệt Employee nào là Zoo Keepers
|
||||
is_zoo_keeper = fields.Boolean(string='Is Zoo Keeper',
|
||||
default=False)
|
||||
|
||||
# Quan hệ với class con: Speciality (Many2many vì có thể share giữa nhiều keepers)
|
||||
speciality_ids = fields.Many2many(
|
||||
comodel_name='zoo.keeper.speciality',
|
||||
relation='zoo_keeper_speciality_rel',
|
||||
column1='employee_id',
|
||||
column2='speciality_id',
|
||||
string='Specialities',
|
||||
help='Species or families this keeper specializes in.')
|
||||
|
||||
# Quan hệ với class con: Certification (One2many vì mỗi cert là unique)
|
||||
certification_ids = fields.One2many(
|
||||
comodel_name='zoo.keeper.certification',
|
||||
inverse_name='employee_id',
|
||||
string='Certifications',
|
||||
help='Certifications this keeper has.')
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. SATELLITE MODELS (Master Data)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
class ZooKeeperSpeciality(models.Model):
|
||||
_name = 'zoo.keeper.speciality'
|
||||
_description = 'Zoo Keeper Speciality'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(string='Name',
|
||||
required=True)
|
||||
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
# Quan hệ ngược: Many2many với hr.employee
|
||||
employee_ids = fields.Many2many(
|
||||
comodel_name='hr.employee',
|
||||
relation='zoo_keeper_speciality_rel',
|
||||
column1='speciality_id',
|
||||
column2='employee_id',
|
||||
string='Keepers',
|
||||
help='Keepers who have this speciality')
|
||||
|
||||
class ZooKeeperCertification(models.Model):
|
||||
_name = 'zoo.keeper.certification'
|
||||
_description = 'Zoo Keeper Certification'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
# Tạo số chứng chỉ
|
||||
certificate_code = fields.Char(
|
||||
string='Certificate Code',
|
||||
required=True,
|
||||
copy=False, # Không copy khi duplicate
|
||||
help='Unique certificate number')
|
||||
|
||||
name = fields.Char(string='Name',
|
||||
required=True)
|
||||
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
# Link ngược về Employee
|
||||
employee_id = fields.Many2one(comodel_name='hr.employee',
|
||||
string='Keeper',
|
||||
required=True,
|
||||
ondelete='cascade')
|
||||
|
||||
issue_date = fields.Date(string='Issue Date')
|
||||
|
||||
expiry_date = fields.Date(string='Expiry Date')
|
||||
|
||||
# File scan chứng chỉ
|
||||
attachment = fields.Binary(string='Certificate Document')
|
||||
|
||||
# Computed field để cảnh báo hết hạn
|
||||
is_expired = fields.Boolean(compute='_compute_is_expired',
|
||||
string='Expired Date')
|
||||
|
||||
# Computed field để cảnh báo hết hạn
|
||||
@api.depends('expiry_date')
|
||||
def _compute_is_expired(self):
|
||||
today = fields.Date.today()
|
||||
for record in self:
|
||||
if record.expiry_date and record.expiry_date < today:
|
||||
record.is_expired = True
|
||||
else:
|
||||
record.is_expired = False
|
||||
|
||||
# SQL Constraint để đảm bảo Certificate code là unique
|
||||
_sql_constraints = [
|
||||
('certificate_code_unique',
|
||||
'UNIQUE(certificate_code)',
|
||||
'Certificate Code must be unique!')
|
||||
]
|
||||
16
addons/zoo/report/zoo_report_action.xml
Normal file
16
addons/zoo/report/zoo_report_action.xml
Normal 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>
|
||||
80
addons/zoo/report/zoo_report_template.xml
Normal file
80
addons/zoo/report/zoo_report_template.xml
Normal 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>
|
||||
17
addons/zoo/security/ir.model.access.csv
Normal file
17
addons/zoo/security/ir.model.access.csv
Normal file
@ -0,0 +1,17 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_zoo_animal,access_zoo_animal,model_zoo_animal,base.group_user,1,1,1,1
|
||||
access_zoo_creature,access_zoo_creature,model_zoo_creature,base.group_user,1,1,1,1
|
||||
access_zoo_cage,access_zoo_cage,model_zoo_cage,base.group_user,1,1,1,1
|
||||
access_zoo_health_record,access_zoo_health_record,model_zoo_health_record,base.group_user,1,1,1,1
|
||||
access_zoo_diet_plan,access_zoo_diet_plan,model_zoo_diet_plan,base.group_user,1,1,1,1
|
||||
access_zoo_diet_line,access_zoo_diet_line,model_zoo_diet_line,base.group_user,1,1,1,1
|
||||
access_zoo_toy_add_wizard,access_zoo_toy_add_wizard,model_zoo_toy_add_wizard,base.group_user,1,1,1,1
|
||||
access_zoo_cage_update_wizard,access_zoo_cage_update_wizard,model_zoo_cage_update_wizard,base.group_user,1,1,1,1
|
||||
access_zoo_animal_meal,access_zoo_animal_meal,model_zoo_animal_meal,base.group_user,1,1,1,1
|
||||
access_zoo_animal_feeding_wizard,access_zoo_animal_feeding_wizard,model_zoo_animal_feeding_wizard,base.group_user,1,1,1,1
|
||||
access_zoo_animal_feeding_wizard_line,access_zoo_animal_feeding_wizard_line,model_zoo_animal_feeding_wizard_line,base.group_user,1,1,1,1
|
||||
access_zoo_husbandry_task,access_zoo_husbandry_task,model_zoo_husbandry_task,base.group_user,1,1,1,1
|
||||
access_zoo_husbandry_task_line,access_zoo_husbandry_task_line,model_zoo_husbandry_task_line,base.group_user,1,1,1,1
|
||||
access_zoo_cage_checklist_template,access_zoo_cage_checklist_template,model_zoo_cage_checklist_template,base.group_user,1,1,1,1
|
||||
access_zoo_keeper_speciality,access_zoo_keeper_speciality,model_zoo_keeper_speciality,base.group_user,1,1,1,1
|
||||
access_zoo_keeper_certification,access_zoo_keeper_certification,model_zoo_keeper_certification,base.group_user,1,1,1,1
|
||||
|
39
addons/zoo/security/zoo_security.xml
Normal file
39
addons/zoo/security/zoo_security.xml
Normal 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>
|
||||
BIN
addons/zoo/static/description/icon.png
Normal file
BIN
addons/zoo/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
200
addons/zoo/views/zoo_animal_meal_views.xml
Normal file
200
addons/zoo/views/zoo_animal_meal_views.xml
Normal file
@ -0,0 +1,200 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_animal_meal_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.animal.meal.form.view</field>
|
||||
<field name="model">zoo.animal.meal</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Feeding Batch">
|
||||
<header>
|
||||
<button name="action_load_all_animals" string="Load All Animals"
|
||||
type="object" class="btn-secondary"
|
||||
invisible="state != 'draft' or not creature_id"/>
|
||||
|
||||
<button name="action_done" string="Confirm Feed"
|
||||
type="object" class="oe_highlight"
|
||||
invisible="state != 'draft'"/>
|
||||
|
||||
<button name="action_draft" string="Set to Draft"
|
||||
type="object"
|
||||
invisible="state != 'done'"/>
|
||||
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Fed" bg_color="bg-success" invisible="state != 'done'"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<label for="record_name" string="Batch Reference"/>
|
||||
<h1><field name="record_name" placeholder="e.g. Lion - Beef - 22/11/2025"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="Planning">
|
||||
<field name="creature_id" readonly="state == 'done'"/>
|
||||
|
||||
<field name="allowed_product_ids" invisible="1"/>
|
||||
|
||||
<field name="product_id" readonly="state == 'done'"
|
||||
options="{'no_create': True, 'no_open': True}"/>
|
||||
|
||||
<field name="meal_date" readonly="state == 'done'"/>
|
||||
</group>
|
||||
<group string="Quantities">
|
||||
<field name="qty_per_animal" readonly="state == 'done'"/>
|
||||
|
||||
<label for="total_qty"/>
|
||||
<div class="o_row">
|
||||
<field name="total_qty" class="fw-bold fs-4"/>
|
||||
<field name="uom_id"/>
|
||||
</div>
|
||||
<field name="staff_id"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Animals Fed">
|
||||
<field name="animal_ids" readonly="state == 'done'"
|
||||
widget="many2many"
|
||||
domain="[('creature_id', '=', creature_id), ('is_alive', '=', True)]">
|
||||
<list string="Animals" decoration-danger="is_alive == False">
|
||||
<field name="name"/>
|
||||
<field name="age"/> <field name="is_alive" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_animal_meal_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.animal.meal.list.view</field>
|
||||
<field name="model">zoo.animal.meal</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Feeding Batches" multi_edit="1"
|
||||
decoration-muted="state == 'done'"
|
||||
decoration-info="state == 'draft'">
|
||||
|
||||
<field name="record_name" decoration-bf="1"/>
|
||||
<field name="meal_date"/>
|
||||
<field name="creature_id"/>
|
||||
<field name="product_id"/>
|
||||
|
||||
<field name="animal_ids" widget="many2many_tags" optional="hide"/>
|
||||
|
||||
<field name="qty_per_animal" optional="show"/>
|
||||
|
||||
<field name="total_qty" sum="Total Consumed" decoration-bf="1"/>
|
||||
<field name="uom_id" nolabel="1"/>
|
||||
|
||||
<field name="staff_id"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-info="state == 'draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Kanban View -->
|
||||
<record id="zoo_animal_meal_kanban_view" model="ir.ui.view">
|
||||
<field name="name">zoo.animal.meal.kanban.view</field>
|
||||
<field name="model">zoo.animal.meal</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state" sample="1" records_draggable="false">
|
||||
<field name="id"/>
|
||||
<field name="record_name"/>
|
||||
<field name="creature_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="total_qty"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="meal_date"/>
|
||||
<field name="animal_ids"/>
|
||||
<field name="state"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click oe_kanban_card d-flex flex-column">
|
||||
<div class="o_kanban_record_top mb-2">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="record_name"/>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="o_kanban_top_right">
|
||||
<widget name="web_ribbon" title="Done" bg_color="bg-success" invisible="state != 'done'"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_content flex-grow-1">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<span class="text-muted small">Species</span><br/>
|
||||
<strong><field name="creature_id"/></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-muted fst-italic">
|
||||
<i class="fa fa-cutlery me-1"/> <field name="product_id"/>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="text-muted small">Total Food</span><br/>
|
||||
<span class="badge text-bg-primary fs-6">
|
||||
<field name="total_qty"/> <field name="uom_id"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_record_bottom mt-2 pt-2 border-top">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<field name="staff_id" widget="many2one_avatar_user"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_animal_meal" model="ir.actions.act_window">
|
||||
<field name="name">Animal Meals</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.animal.meal</field>
|
||||
<field name="view_mode">list,form,kanban</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_animal_meal_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_animal_meal_form_view')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('zoo_animal_meal_kanban_view')}),
|
||||
]"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new Batch Feeding Record
|
||||
</p>
|
||||
<p>
|
||||
Track feeding sessions for multiple animals of the same species.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Menu -->
|
||||
<menuitem id="menu_zoo_animal_nutrition"
|
||||
name="Animal Nutrition"
|
||||
sequence="50"
|
||||
parent="menu_zoo"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<menuitem id="menu_zoo_animal_meal"
|
||||
name="Animal Meals"
|
||||
sequence="10"
|
||||
parent="menu_zoo_animal_nutrition"
|
||||
action="action_zoo_animal_meal"
|
||||
groups="base.group_user"/>
|
||||
</data>
|
||||
</odoo>
|
||||
184
addons/zoo/views/zoo_animal_views.xml
Normal file
184
addons/zoo/views/zoo_animal_views.xml
Normal file
@ -0,0 +1,184 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_animal_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.animal.form.view</field>
|
||||
<field name="model">zoo.animal</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Peter White Tiger"/></h1>
|
||||
<label for="is_alive"/>
|
||||
<field name="is_alive"/>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field name="nickname" string="Nickname"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group name="basic_information">
|
||||
<group>
|
||||
<field name="image" widget="image"/>
|
||||
<field name="creature_id"/>
|
||||
<field name="cage_id"/>
|
||||
<field name="gender"/>
|
||||
<field name="dob"/>
|
||||
<field name="age"/>
|
||||
<field name="weight"/>
|
||||
<field name="weight_pound"/>
|
||||
<!-- <field name="nickname"/> -->
|
||||
</group>
|
||||
<group>
|
||||
<field name="feed_time"/>
|
||||
<field name="mother_id"/>
|
||||
<field name="mother_name"/>
|
||||
<field name="father_id"/>
|
||||
<field name="father_name"/>
|
||||
<field name="veterinarian_id"/>
|
||||
<field name="is_purchased"/>
|
||||
<field name="purchase_price" invisible="is_purchased == False"/>
|
||||
<!-- <field name="purchase_price" attrs="{'invisible': [('is_purchased', '=', False)]}"/> -->
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook name="misc">
|
||||
<page name="additional_information" string="Additional Information">
|
||||
<group>
|
||||
<group>
|
||||
<label for="introduction" colspan="2"/>
|
||||
<field name="introduction" colspan="2" nolabel="1" widget="html"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="introduction_vn" colspan="2"/>
|
||||
<field name="introduction_vn" colspan="2" nolabel="1" widget="html"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="description" colspan="2"/>
|
||||
<field name="description" colspan="2" nolabel="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page name="children" string="Children">
|
||||
<group>
|
||||
<field name="number_of_children"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="female_children_ids" colspan="2"/>
|
||||
<field name="female_children_ids" colspan="2" nolabel="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="male_children_ids" colspan="2"/>
|
||||
<field name="male_children_ids" colspan="2" nolabel="1"/>
|
||||
</group>
|
||||
</page>
|
||||
<page name="others" string="Others">
|
||||
<group>
|
||||
<group>
|
||||
<field name="toy_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
<group>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_animal_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.animal.list.view</field>
|
||||
<field name="model">zoo.animal</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Animals" default_order="create_date desc">
|
||||
<field name="image" widget="image" options='{"size": [64, 64]}'/>
|
||||
<field name="name"/>
|
||||
<!-- <field name="nickname"/> -->
|
||||
<field name="creature_id"/>
|
||||
<field name="dob"/>
|
||||
<field name="age"/>
|
||||
<field name="gender"/>
|
||||
<field name="weight"/>
|
||||
<!-- <field name="mother_id"/> -->
|
||||
<!-- <field name="father_id"/> -->
|
||||
<!-- <field name="is_purchased"/> -->
|
||||
<!-- <field name="number_of_children"/> -->
|
||||
<field name="is_alive"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Kanban view -->
|
||||
<record model="ir.ui.view" id="zoo_animal_kanban_view">
|
||||
<field name="name">zoo.animal.kanban.view</field>
|
||||
<field name="model">zoo.animal</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="feed_time"/>
|
||||
<field name="gender"/>
|
||||
<field name="veterinarian_id"/>
|
||||
<field name="cage_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="oe_kanban_global_click o_kanban_record_has_image_fill">
|
||||
<div class="o_kanban_image_fill_left o_kanban_image_full" t-attf-style="background-image: url(#{kanban_image('zoo.animal', 'image', record.id.raw_value, placeholder)})" role="img"/>
|
||||
|
||||
<div class="oe_kanban_details">
|
||||
<strong class="o_kanban_record_title"><field name="name"/></strong> (<t t-esc="record.gender.value"/>)
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<i class="fa fa-home" role="img" aria-label="Cage" title="Cage"/>
|
||||
<field name="cage_id"/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<i class="fa fa-clock-o" role="img" aria-label="Feed Time" title="Feed Time"/>
|
||||
<t t-esc="record.feed_time.value"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_animal" model="ir.actions.act_window">
|
||||
<field name="name">Zoo Animal</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.animal</field>
|
||||
<field name="view_mode">list,form,kanban</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_animal_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_animal_form_view')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('zoo_animal_kanban_view')})]"/>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Menu -->
|
||||
<menuitem id="menu_zoo"
|
||||
name="Zoo"
|
||||
sequence="10"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<menuitem id="menu_zoo_animal"
|
||||
name="Animal"
|
||||
action="action_zoo_animal"
|
||||
sequence="10"
|
||||
parent="menu_zoo"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
147
addons/zoo/views/zoo_cage_views.xml
Normal file
147
addons/zoo/views/zoo_cage_views.xml
Normal file
@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_cage_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.cage.form.view</field>
|
||||
<field name="model">zoo.cage</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Cage Name"/>
|
||||
<h1><field name="name" placeholder="e.g. Lion Enclosure A"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field name="location"/>
|
||||
<field name="cage_type"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="capacity"/>
|
||||
<field name="area"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Description">
|
||||
<field name="description" placeholder="Cage details..."/>
|
||||
</page>
|
||||
|
||||
<page string="Standard Checklist" name="checklist_template">
|
||||
<field name="checklist_template_ids">
|
||||
<list editable="bottom">
|
||||
<field name="name" placeholder="Task description (e.g. Check water)"/>
|
||||
<field name="required"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Kanban View -->
|
||||
<record id="zoo_cage_kanban_view" model="ir.ui.view">
|
||||
<field name="name">zoo.cage.kanban.view</field>
|
||||
<field name="model">zoo.cage</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="cage_type" records_draggable="false" sample="1">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="location"/>
|
||||
<field name="capacity"/>
|
||||
<field name="area"/>
|
||||
<field name="active"/>
|
||||
<field name="cage_type"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click oe_kanban_card o_kanban_record_has_image_fill">
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
|
||||
|
||||
<div class="oe_kanban_details d-flex flex-column">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="name"/>
|
||||
</strong>
|
||||
|
||||
<div class="o_kanban_tags_section"/>
|
||||
<div class="text-muted">
|
||||
<i class="fa fa-map-marker me-1" title="Location"/>
|
||||
<field name="location"/>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<i class="fa fa-arrows-alt me-1" title="Area"/>
|
||||
<span><field name="area"/> m²</span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<i class="fa fa-building me-1" title="Cage Type"/>
|
||||
<field name="cage_type"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_record_bottom mt-auto">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<span class="badge rounded-pill text-bg-info">
|
||||
<i class="fa fa-users me-1"/> <field name="capacity"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_cage_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.cage.list.view</field>
|
||||
<field name="model">zoo.cage</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Cages" default_order="name">
|
||||
<field name="name"/>
|
||||
<field name="cage_type"/>
|
||||
<field name="location"/>
|
||||
<field name="capacity"/>
|
||||
<field name="area"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_cage" model="ir.actions.act_window">
|
||||
<field name="name">Cage</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.cage</field>
|
||||
<field name="view_mode">list,form,kanban</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_cage_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_cage_form_view')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('zoo_cage_kanban_view')})
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Menu -->
|
||||
<menuitem id="menu_zoo_cage"
|
||||
name="Cage"
|
||||
action="action_zoo_cage"
|
||||
sequence="20"
|
||||
parent="menu_zoo_configuration"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
129
addons/zoo/views/zoo_creature_views.xml
Normal file
129
addons/zoo/views/zoo_creature_views.xml
Normal file
@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa From view -->
|
||||
<record id="zoo_creature_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.creature.form.view</field>
|
||||
<field name="model">zoo.creature</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Creature" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Tiger"/></h1>
|
||||
</div>
|
||||
|
||||
<group name="basic_information">
|
||||
<group>
|
||||
<field name="is_rare"/>
|
||||
<field name="environment"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="animal_ids"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Feeding Information">
|
||||
<field name="allowed_product_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_creature_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.creature.list.view</field>
|
||||
<field name="model">zoo.creature</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Creatures" default_order="create_date desc">
|
||||
<field name="name"/>
|
||||
<field name="is_rare"/>
|
||||
<field name="environment"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Kanban View -->
|
||||
<record id="zoo_creature_kanban_view" model="ir.ui.view">
|
||||
<field name="name">zoo.creature.kanban.view</field>
|
||||
<field name="model">zoo.creature</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="environment" records_draggable="false" sample="1">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="is_rare"/>
|
||||
<field name="environment"/>
|
||||
<field name="animal_count"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click oe_kanban_card d-flex flex-column">
|
||||
|
||||
<widget name="web_ribbon" title="Rare" bg_color="bg-danger" invisible="not is_rare"/>
|
||||
|
||||
<div class="o_kanban_content">
|
||||
<div class="o_kanban_record_title fw-bold fs-5 mb-1">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
|
||||
<div class="text-muted">
|
||||
<i class="fa fa-globe me-1" title="Environment"/>
|
||||
<field name="environment"/>
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_record_bottom mt-auto pt-2">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<span class="badge rounded-pill text-bg-success">
|
||||
<i class="fa fa-paw me-1"/> <field name="animal_count"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_creature" model="ir.actions.act_window">
|
||||
<field name="name">Creature</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.creature</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_creature_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_creature_form_view')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('zoo_creature_kanban_view')})
|
||||
]"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new Creature Species
|
||||
</p>
|
||||
<p>
|
||||
Define species like Lion, Shark, or Eagle and assign their environments.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Menu -->
|
||||
<menuitem id="menu_zoo_configuration"
|
||||
name="Configuration"
|
||||
sequence="100"
|
||||
parent="menu_zoo"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<menuitem id="menu_zoo_configuration_creature"
|
||||
name="Creature"
|
||||
sequence="10"
|
||||
parent="menu_zoo_configuration"
|
||||
action="action_zoo_creature"
|
||||
groups="base.group_user"/>
|
||||
</data>
|
||||
</odoo>
|
||||
143
addons/zoo/views/zoo_diet_plans.xml
Normal file
143
addons/zoo/views/zoo_diet_plans.xml
Normal file
@ -0,0 +1,143 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa inline list view cho Diet Line (embedded trong Diet Plan form) -->
|
||||
<record id="zoo_diet_line_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.diet.line.list.view</field>
|
||||
<field name="model">zoo.diet.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Ingredients" editable="bottom">
|
||||
<field name="product_id"/>
|
||||
<field name="quantity_per_day"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="cost_per_unit" readonly="1"/>
|
||||
<field name="is_supplement"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Form view cho Diet Plan -->
|
||||
<record id="zoo_diet_plan_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.diet.plan.form.view</field>
|
||||
<field name="model">zoo.diet.plan</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Diet Plan Details">
|
||||
|
||||
<!-- Thanh trạng thái (Statusbar) -->
|
||||
<header>
|
||||
<!-- Các nút bấm chuyển đổi trạng thái -->
|
||||
<button name="action_activate" string="Activate" type="object"
|
||||
class="oe_highlight"
|
||||
invisible="diet_status != 'draft'"/>
|
||||
<button name="action_under_review" string="Send to Review" type="object"
|
||||
class="oe_highlight"
|
||||
invisible="diet_status != 'active'"/>
|
||||
<button name="action_approve" string="Approve & Activate" type="object"
|
||||
class="oe_highlight"
|
||||
invisible="diet_status != 'under_review'"/>
|
||||
<button name="action_deprecate" string="Deprecate" type="object"
|
||||
invisible="diet_status == 'deprecated'"/>
|
||||
<button name="action_reset_draft" string="Reset to Draft" type="object"
|
||||
invisible="diet_status not in ('deprecated', 'under_review')"/>
|
||||
|
||||
<!-- Trường diet_status với widget statusbar -->
|
||||
<field name="diet_status" widget="statusbar" statusbar_visible="draft,active,under_review,deprecated"/>
|
||||
</header>
|
||||
|
||||
<!-- Thân Form -->
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<!-- Trường diet_plan_name -->
|
||||
<h1><field name="diet_plan_name" placeholder="e.g. Lion Standard Diet"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<!-- Cột Trái -->
|
||||
<group>
|
||||
<field name="creature_id"/>
|
||||
<field name="diet_type"/>
|
||||
<field name="nutritionist_id"/>
|
||||
</group>
|
||||
<!-- Cột Phải -->
|
||||
<group>
|
||||
<field name="next_review_date"/>
|
||||
<field name="is_active"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<!-- Tab 1: Ingredients (Embedded List) -->
|
||||
<page string="Ingredients" name="ingredients">
|
||||
<field name="ingredient_ids">
|
||||
<list editable="bottom">
|
||||
<field name="product_id"/>
|
||||
<field name="quantity_per_day"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="cost_per_unit" readonly="1"/>
|
||||
<field name="is_supplement"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- Tab 2: Notes -->
|
||||
<page string="Preparation Notes" name="notes">
|
||||
<field name="notes"
|
||||
widget="html"
|
||||
placeholder="Ghi rõ hướng dẫn pha chế, bảo quản, và lưu ý đặc biệt..."
|
||||
/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
|
||||
<!-- Chatter -->
|
||||
<!-- <div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div> -->
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view cho Diet Plan -->
|
||||
<record id="zoo_diet_plan_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.diet.plan.list.view</field>
|
||||
<field name="model">zoo.diet.plan</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Diet Plans" default_order="diet_plan_name">
|
||||
<field name="diet_plan_name"/>
|
||||
<field name="creature_id"/>
|
||||
<field name="diet_type"/>
|
||||
<field name="nutritionist_id"/>
|
||||
<field name="next_review_date"/>
|
||||
<field name="diet_status"
|
||||
decoration-success="diet_status == 'active'"
|
||||
decoration-warning="diet_status == 'under_review'"
|
||||
decoration-info="diet_status == 'draft'"
|
||||
decoration-muted="diet_status == 'deprecated'"/>
|
||||
<field name="is_active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_diet_plan" model="ir.actions.act_window">
|
||||
<field name="name">Diet Plans</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.diet.plan</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_diet_plan_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_diet_plan_form_view')})]"/>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Menu -->
|
||||
<menuitem id="menu_zoo_diet_plan"
|
||||
name="Animal Diet Plans"
|
||||
action="action_zoo_diet_plan"
|
||||
sequence="20"
|
||||
parent="menu_zoo_animal_nutrition"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
113
addons/zoo/views/zoo_health_records.xml
Normal file
113
addons/zoo/views/zoo_health_records.xml
Normal file
@ -0,0 +1,113 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_health_record_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.health.record.form.view</field>
|
||||
<field name="model">zoo.health.record</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Health Record Details">
|
||||
|
||||
<!-- Thanh trạng thái (Statusbar) -->
|
||||
<header>
|
||||
<!-- Các nút bấm chuyển đổi trạng thái (Giả định bạn đã code hàm Python) -->
|
||||
<button name="action_in_progress" string="Start Procedure" type="object" class="oe_highlight" invisible="record_state != 'draft'"/>
|
||||
<button name="action_completed" string="Procedure Done" type="object" class="oe_highlight" invisible="record_state != 'in_progress'"/>
|
||||
<button name="action_cancel" string="Cancel Record" type="object" invisible="record_state in ('completed', 'cancelled')"/>
|
||||
<button name="action_draft" string="Reset to Draft" type="object" invisible="record_state in ('completed', 'in_progress', 'draft')"/>
|
||||
|
||||
<!-- Trường record_state với widget statusbar -->
|
||||
<field name="record_state" widget="statusbar" statusbar_visible="draft,in_progress,completed, cancelled"/>
|
||||
</header>
|
||||
|
||||
<!-- Thân Form -->
|
||||
<sheet>
|
||||
<!-- <div class="oe_button_box" name="button_box"/> -->
|
||||
<div class="oe_title">
|
||||
<!-- Trường record_name (Computed Field) -->
|
||||
<h1><field name="record_name" placeholder="Health Record Name"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<!-- Cột Trái: Các thông tin liên kết và người chịu trách nhiệm -->
|
||||
<group>
|
||||
<field name="animal_id"/>
|
||||
<field name="veterinarian_id"/>
|
||||
<field name="related_cage_id"/>
|
||||
</group>
|
||||
<!-- Cột Phải: Thông tin sự kiện và phân loại -->
|
||||
<group>
|
||||
<field name="date_occurrence"/>
|
||||
<field name="record_type"/>
|
||||
<field name="is_preventive"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Diagnosis and Treatment Details" name="diagnosis_treatment">
|
||||
<!-- Chẩn đoán và Chi tiết Điều trị -->
|
||||
<group>
|
||||
<group>
|
||||
<field name="diagnosis" placeholder="Mô tả kết quả chẩn đoán..."/>
|
||||
</group>
|
||||
<group>
|
||||
<!-- Trường điều kiện: next_follow_up_date -->
|
||||
<field name="next_follow_up_date" invisible="record_state == 'in_progress'"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Trường HTML (treatment_details) -->
|
||||
<separator string="Chi tiết Thủ tục và Liều lượng Thuốc"/>
|
||||
<field name="treatment_details"
|
||||
widget="html"
|
||||
placeholder="Ghi rõ chi tiết thủ tục, liều lượng, và các biến chứng (nếu có)."
|
||||
/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<!-- <div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div> -->
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_health_record_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.health.record.list.view</field>
|
||||
<field name="model">zoo.health.record</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Health Records" default_order="record_name">
|
||||
<field name="record_name"/>
|
||||
<field name="animal_id"/>
|
||||
<field name="date_occurrence"/>
|
||||
<field name="record_type"/>
|
||||
<field name="veterinarian_id"/>
|
||||
<field name="record_state" decoration-success="record_state == 'completed'"
|
||||
decoration-warning="record_state == 'in_progress'"
|
||||
decoration-danger="record_state == 'cancelled'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_health_record" model="ir.actions.act_window">
|
||||
<field name="name">Health Records</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.health.record</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_health_record_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_health_record_form_view')})]"/>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Menu -->
|
||||
<menuitem id="menu_zoo_health_record"
|
||||
name="Health Records"
|
||||
action="action_zoo_health_record"
|
||||
sequence="40"
|
||||
parent="menu_zoo"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
257
addons/zoo/views/zoo_husbandry_task_views.xml
Normal file
257
addons/zoo/views/zoo_husbandry_task_views.xml
Normal file
@ -0,0 +1,257 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- Định nghĩa Search view -->
|
||||
<record id="zoo_husbandry_task_search_view" model="ir.ui.view">
|
||||
<field name="name">zoo.husbandry.task.search</field>
|
||||
<field name="model">zoo.husbandry.task</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Tasks">
|
||||
<field name="name"/>
|
||||
<field name="cage_id"/>
|
||||
<field name="user_id"/>
|
||||
|
||||
<separator/>
|
||||
<filter string="My Tasks" name="my_tasks" domain="[('user_id', '=', uid)]"/>
|
||||
<!-- <filter string="Today" name="today" domain="[('date', '=', context_today().strftime('%Y-%m-%d'))]"/> -->
|
||||
<filter string="Today" name="today"
|
||||
domain="[('date', '=', context_today())]"/>
|
||||
|
||||
<separator/>
|
||||
<filter string="To Do" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="In Progress" name="progress" domain="[('state', '=', 'in_progress')]"/>
|
||||
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Cage" name="group_cage" context="{'group_by': 'cage_id'}"/>
|
||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Task Type" name="group_type" context="{'group_by': 'task_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_daily_task_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.husbandry.task.form</field>
|
||||
<field name="model">zoo.husbandry.task</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Husbandry Task">
|
||||
<header>
|
||||
<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"
|
||||
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,to_approve,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Done" bg_color="bg-success" invisible="state != 'done'"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<h1><field name="display_name_custom"/></h1>
|
||||
<h3 class="text-muted"><field name="name"/></h3>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field name="cage_id" readonly="state != 'draft'"/>
|
||||
<field name="date" readonly="state == 'done'"/>
|
||||
<field name="task_type" widget="radio" options="{'horizontal': true}" readonly="state == 'done'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="user_id" widget="many2one_avatar_user" readonly="state == 'done'"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Checklist" name="checklist">
|
||||
<field name="task_line_ids" readonly="state == 'done'">
|
||||
<list editable="bottom" decoration-success="is_done">
|
||||
<field name="is_done" widget="boolean_toggle" string="Done?"/>
|
||||
<field name="required" column_invisible="True"/>
|
||||
|
||||
<field name="name" decoration-bf="required"/>
|
||||
|
||||
<field name="remark" placeholder="Note issues..."/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page string="Notes & Issues" name="notes">
|
||||
<field name="keeper_note" placeholder="Describe any health issues, maintenance needs, or behavioral observations..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<!-- Cú pháp mới để load thẻ chatter -->
|
||||
<chatter reload_on_post="True"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_daily_task_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.husbandry.task.list</field>
|
||||
<field name="model">zoo.husbandry.task</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Daily Tasks"
|
||||
decoration-muted="state in ('done', 'cancel')"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-warning="state == 'in_progress'">
|
||||
|
||||
<field name="name" optional="hide"/>
|
||||
<field name="display_name_custom" string="Subject"/>
|
||||
<field name="cage_id"/>
|
||||
<field name="task_type" widget="badge" decoration-info="task_type == 'routine'"/>
|
||||
<field name="date"/>
|
||||
<field name="user_id" widget="many2one_avatar_user"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-warning="state == 'in_progress'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Kanban view -->
|
||||
<record id="zoo_daily_task_kanban_view" model="ir.ui.view">
|
||||
<field name="name">zoo.husbandry.task.kanban</field>
|
||||
<field name="model">zoo.husbandry.task</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state" sample="1" class="o_kanban_small_column">
|
||||
<field name="id"/>
|
||||
<field name="display_name_custom"/>
|
||||
<field name="name"/>
|
||||
<field name="cage_id"/>
|
||||
<field name="task_type"/>
|
||||
<field name="state"/>
|
||||
<field name="user_id"/>
|
||||
<field name="date"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_record_top mb-2">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="display_name_custom"/>
|
||||
</strong>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<i class="fa fa-map-marker me-1"/> <field name="cage_id"/>
|
||||
</small>
|
||||
</div>
|
||||
<div class="o_kanban_record_top_right">
|
||||
<field name="state" widget="label_selection"
|
||||
options="{'classes': {'draft': 'default', 'in_progress': 'warning', 'done': 'success', 'cancel': 'danger'}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_record_body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge rounded-pill text-bg-info">
|
||||
<field name="task_type"/>
|
||||
</span>
|
||||
<span class="text-muted small">
|
||||
<field name="date" widget="date"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_record_bottom mt-2 pt-2 border-top">
|
||||
<div class="oe_kanban_bottom_left text-muted">
|
||||
<small><field name="name"/></small>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<field name="user_id" widget="many2one_avatar_user"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_daily_tasks" model="ir.actions.act_window">
|
||||
<field name="name">Keeper Daily Tasks</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">zoo.husbandry.task</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="context">{'search_default_my_tasks': 1}</field>
|
||||
<field name="search_view_id" ref="zoo_husbandry_task_search_view"/>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('zoo_daily_task_kanban_view')}),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('zoo_daily_task_list_view')}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('zoo_daily_task_form_view')})]"/>
|
||||
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No tasks found for today!
|
||||
</p>
|
||||
<p>
|
||||
Tasks are usually generated automatically based on the Cage schedule.
|
||||
You can also create an ad-hoc task manually.
|
||||
</p>
|
||||
</field>
|
||||
|
||||
</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"
|
||||
sequence="60"
|
||||
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"
|
||||
parent="menu_zoo_husbandry_and_care"
|
||||
action="action_zoo_daily_tasks"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
97
addons/zoo/views/zoo_keeper_certificate_views.xml
Normal file
97
addons/zoo/views/zoo_keeper_certificate_views.xml
Normal file
@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_keeper_certification_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.keeper.certification.form.view</field>
|
||||
<field name="model">zoo.keeper.certification</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="is_expired" invisible="1"/>
|
||||
<button string="Expired" class="oe_read_only btn-danger"
|
||||
invisible="not is_expired"
|
||||
icon="fa-exclamation-triangle"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Wildlife Safety Certification"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field name="certificate_code" placeholder="e.g. CERT-2024-001"/>
|
||||
<field name="employee_id"
|
||||
domain="[('is_zoo_keeper', '=', True)]"
|
||||
context="{'default_is_zoo_keeper': True}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="issue_date"/>
|
||||
<field name="expiry_date"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="description" placeholder="Additional certification details..."/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="attachment"
|
||||
widget="binary"
|
||||
filename="name"
|
||||
string="Upload Certificate Document"/>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
<chatter reload_on_post="True"/>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_keeper_certification_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.keeper.certification.list.view</field>
|
||||
<field name="model">zoo.keeper.certification</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Certifications" default_order="expiry_date asc">
|
||||
<field name="certificate_code"/>
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="is_expired"
|
||||
widget="boolean_toggle"
|
||||
decoration-danger="is_expired == True"
|
||||
readonly="1"/>
|
||||
<field name="attachment" widget="binary" filename="name"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_keeper_certification" model="ir.actions.act_window">
|
||||
<field name="name">Certifications</field>
|
||||
<field name="res_model">zoo.keeper.certification</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new Certification
|
||||
</p>
|
||||
<p>
|
||||
Track keeper certifications, issue and expiry dates.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa menu item Certification -->
|
||||
<menuitem id="menu_zoo_keeper_certification"
|
||||
name="Certifications"
|
||||
action="action_zoo_keeper_certification"
|
||||
sequence="20"
|
||||
parent="menu_zoo_keepers_management"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
80
addons/zoo/views/zoo_keeper_speciality_views.xml
Normal file
80
addons/zoo/views/zoo_keeper_speciality_views.xml
Normal file
@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="zoo_keeper_speciality_form_view" model="ir.ui.view">
|
||||
<field name="name">zoo.keeper.speciality.form.view</field>
|
||||
<field name="model">zoo.keeper.speciality</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Reptile Specialist"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<field name="description"
|
||||
placeholder="Description of this speciality..."
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Keepers with this Speciality" name="keepers">
|
||||
<field name="employee_ids"
|
||||
domain="[('is_zoo_keeper', '=', True)]"
|
||||
context="{'default_is_zoo_keeper': True}">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="work_email"/>
|
||||
<field name="mobile_phone"/>
|
||||
<field name="department_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
|
||||
<chatter reload_on_post="True"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa List view -->
|
||||
<record id="zoo_keeper_speciality_list_view" model="ir.ui.view">
|
||||
<field name="name">zoo.keeper.speciality.list.view</field>
|
||||
<field name="model">zoo.keeper.speciality</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Specialities">
|
||||
<field name="name"/>
|
||||
<field name="description"/>
|
||||
<field name="employee_ids" widget="many2many_tags"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action -->
|
||||
<record id="action_zoo_keeper_speciality" model="ir.actions.act_window">
|
||||
<field name="name">Specialities</field>
|
||||
<field name="res_model">zoo.keeper.speciality</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new Speciality
|
||||
</p>
|
||||
<p>
|
||||
Create keeper specialities like Reptile Care, Mammal Care, etc.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa menu item Speciality -->
|
||||
<menuitem id="menu_zoo_keeper_speciality"
|
||||
name="Specialities"
|
||||
action="action_zoo_keeper_speciality"
|
||||
sequence="30"
|
||||
parent="menu_zoo_keepers_management"
|
||||
groups="base.group_user"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
95
addons/zoo/views/zoo_keeper_views.xml
Normal file
95
addons/zoo/views/zoo_keeper_views.xml
Normal file
@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Định nghĩa Form view -->
|
||||
<record id="view_employee_form_zoo_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.zoo.inherit</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<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">
|
||||
<group>
|
||||
<group string="Expertise">
|
||||
<field name="speciality_ids" widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Select specialities..."/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Certifications"/>
|
||||
<field name="certification_ids">
|
||||
<list editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="certificate_code"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="is_expired" widget="boolean_toggle" readonly="1"/>
|
||||
<field name="attachment" widget="binary" filename="name" string="Upload"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_employee_filter_zoo_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.employee.search.zoo.inherit</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='message_needaction']" position="before">
|
||||
<filter string="Zoo Keepers" name="is_zoo_keeper" domain="[('is_zoo_keeper', '=', True)]"/>
|
||||
<separator/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa Action: Zoo Keepers -->
|
||||
<record id="action_zoo_keepers" model="ir.actions.act_window">
|
||||
<field name="name">Keepers</field>
|
||||
<field name="res_model">hr.employee</field>
|
||||
<field name="view_mode">list,form,kanban</field>
|
||||
<field name="domain">[('is_zoo_keeper', '=', True)]</field>
|
||||
<field name="context">{'default_is_zoo_keeper': True}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new Zoo Keeper
|
||||
</p>
|
||||
<p>
|
||||
Manage keeper profiles, specialities, and certifications.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Định nghĩa menu item Keepers -->
|
||||
<menuitem id="menu_zoo_keepers_management"
|
||||
name="Keepers Management"
|
||||
parent="menu_zoo"
|
||||
sequence="20"/>
|
||||
|
||||
|
||||
<!-- Định nghĩa menu item Keepers -->
|
||||
<menuitem id="menu_zoo_keepers_list"
|
||||
name="Keepers"
|
||||
parent="menu_zoo_keepers_management"
|
||||
action="action_zoo_keepers"
|
||||
sequence="10"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
3
addons/zoo/wizard/__init__.py
Normal file
3
addons/zoo/wizard/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import toy_add
|
||||
from . import cage_update
|
||||
from . import animal_feeding
|
||||
BIN
addons/zoo/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/zoo/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/wizard/__pycache__/animal_feeding.cpython-312.pyc
Normal file
BIN
addons/zoo/wizard/__pycache__/animal_feeding.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/wizard/__pycache__/cage_update.cpython-312.pyc
Normal file
BIN
addons/zoo/wizard/__pycache__/cage_update.cpython-312.pyc
Normal file
Binary file not shown.
BIN
addons/zoo/wizard/__pycache__/toy_add.cpython-312.pyc
Normal file
BIN
addons/zoo/wizard/__pycache__/toy_add.cpython-312.pyc
Normal file
Binary file not shown.
131
addons/zoo/wizard/animal_feeding.py
Normal file
131
addons/zoo/wizard/animal_feeding.py
Normal file
@ -0,0 +1,131 @@
|
||||
from odoo import models, fields, api, Command, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AnimalFeedingWizard(models.TransientModel):
|
||||
_name = 'zoo.animal.feeding.wizard'
|
||||
_description = 'Bulk Feeding Wizard'
|
||||
|
||||
def _default_animals(self):
|
||||
"""Lấy danh sách thú được chọn từ màn hình List View"""
|
||||
return self.env.context.get('active_ids', [])
|
||||
|
||||
# 1. Context Fields
|
||||
animal_ids = fields.Many2many(
|
||||
comodel_name='zoo.animal',
|
||||
string='Animals to Feed',
|
||||
required=True,
|
||||
default=_default_animals
|
||||
)
|
||||
|
||||
# Tự động tính loài (dùng để validate và lọc thức ăn)
|
||||
creature_id = fields.Many2one(
|
||||
comodel_name='zoo.creature',
|
||||
string='Species',
|
||||
compute='_compute_creature_info',
|
||||
store=True,
|
||||
readonly=True
|
||||
)
|
||||
|
||||
meal_date = fields.Datetime(
|
||||
string="Feeding Time",
|
||||
required=True,
|
||||
default=fields.Datetime.now
|
||||
)
|
||||
|
||||
staff_id = fields.Many2one(
|
||||
comodel_name="res.users",
|
||||
string="Staff",
|
||||
default=lambda self: self.env.user,
|
||||
required=True
|
||||
)
|
||||
|
||||
allowed_product_ids = fields.Many2many(
|
||||
related='creature_id.allowed_product_ids',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# 2. Feeding Lines (Bảng tạm để chọn nhiều món)
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='zoo.animal.feeding.wizard.line',
|
||||
inverse_name='wizard_id',
|
||||
string='Food Lines'
|
||||
)
|
||||
|
||||
# --- Compute & Validation ---
|
||||
@api.depends('animal_ids')
|
||||
def _compute_creature_info(self):
|
||||
for record in self:
|
||||
if not record.animal_ids:
|
||||
record.creature_id = False
|
||||
continue
|
||||
|
||||
# Lấy loài của con đầu tiên
|
||||
first_creature = record.animal_ids[0].creature_id
|
||||
|
||||
# Validate: Tất cả thú được chọn phải cùng 1 loài
|
||||
# Nếu có con nào khác loài với con đầu tiên -> Báo lỗi hoặc False
|
||||
if any(animal.creature_id != first_creature for animal in record.animal_ids):
|
||||
raise UserError(_("You can only feed animals of the SAME species in a single batch!"))
|
||||
|
||||
record.creature_id = first_creature
|
||||
|
||||
# --- Main Action ---
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
|
||||
if not self.line_ids:
|
||||
raise UserError(_("Please select at least one food item to feed."))
|
||||
|
||||
MealObj = self.env['zoo.animal.meal']
|
||||
created_meals = []
|
||||
|
||||
# LOOP: Với mỗi dòng thức ăn trong Wizard -> Tạo 1 phiếu Batch Meal thật
|
||||
for line in self.line_ids:
|
||||
vals = {
|
||||
'creature_id': self.creature_id.id,
|
||||
'meal_date': self.meal_date,
|
||||
'staff_id': self.staff_id.id,
|
||||
'product_id': line.product_id.id,
|
||||
'qty_per_animal': line.qty_per_animal,
|
||||
'state': 'draft', # Mặc định là Draft để Staff check lại lần cuối
|
||||
# Link toàn bộ thú đang chọn vào phiếu
|
||||
'animal_ids': [Command.set(self.animal_ids.ids)],
|
||||
}
|
||||
new_meal = MealObj.create(vals)
|
||||
created_meals.append(new_meal.id)
|
||||
|
||||
# Return Action: Mở danh sách các phiếu vừa tạo
|
||||
return {
|
||||
'name': _('Created Feeding Batches'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'zoo.animal.meal',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', created_meals)],
|
||||
'context': {'create': False}, # Tùy chọn: Không cho tạo thêm ở màn hình kết quả
|
||||
}
|
||||
|
||||
class AnimalFeedingWizardLine(models.TransientModel):
|
||||
_name = 'zoo.animal.feeding.wizard.line'
|
||||
_description = 'Feeding Line Detail'
|
||||
|
||||
wizard_id = fields.Many2one('zoo.animal.feeding.wizard',
|
||||
required=True)
|
||||
|
||||
# Fields kỹ thuật để lấy domain từ cha
|
||||
creature_id = fields.Many2one(related='wizard_id.creature_id')
|
||||
allowed_product_ids = fields.Many2many(related='creature_id.allowed_product_ids')
|
||||
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Food Item',
|
||||
required=True
|
||||
)
|
||||
|
||||
uom_id = fields.Many2one(related='product_id.uom_id',
|
||||
readonly=True)
|
||||
|
||||
qty_per_animal = fields.Float(
|
||||
string='Qty / Animal',
|
||||
default=1.0,
|
||||
required=True
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user