diff --git a/addons/epr/__manifest__.py b/addons/epr/__manifest__.py index 46ba70e..ea7d2bd 100644 --- a/addons/epr/__manifest__.py +++ b/addons/epr/__manifest__.py @@ -26,15 +26,15 @@ 'security/epr_security.xml', 'security/ir.model.access.csv', 'security/epr_record_rules.xml', - 'data/epr_sequence_data.xml', - # 'data/epr_approval_default_data.xml', - # 'data/mail_template_data.xml', + 'data/epr_pr_sequence_data.xml', + 'data/epr_rfq_sequence_data.xml', 'views/epr_purchase_request_views.xml', - # 'views/epr_request_line_views.xml', - # 'views/epr_approval_matrix_views.xml', - # 'views/res_config_settings_views.xml', + 'views/epr_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_po_views.xml', ], 'assets': { # 'web.assets_backend': [ diff --git a/addons/epr/data/epr_sequence_data.xml b/addons/epr/data/epr_pr_sequence_data.xml similarity index 100% rename from addons/epr/data/epr_sequence_data.xml rename to addons/epr/data/epr_pr_sequence_data.xml diff --git a/addons/epr/data/epr_rfq_sequence_data.xml b/addons/epr/data/epr_rfq_sequence_data.xml new file mode 100644 index 0000000..2e0a695 --- /dev/null +++ b/addons/epr/data/epr_rfq_sequence_data.xml @@ -0,0 +1,39 @@ + + + + + + + ePR Request for Quotation + + epr.rfq + + + RFQ/%(year)s/ + + + 5 + + + 1 + 1 + + + + + + standard + + + + \ No newline at end of file diff --git a/addons/epr/models/__init__.py b/addons/epr/models/__init__.py index 4c375e4..e0b799a 100644 --- a/addons/epr/models/__init__.py +++ b/addons/epr/models/__init__.py @@ -1 +1,4 @@ from . import epr_purchase_request +from . import epr_rfq +from . import epr_approval +from . import epr_po \ No newline at end of file diff --git a/addons/epr/models/__pycache__/__init__.cpython-312.pyc b/addons/epr/models/__pycache__/__init__.cpython-312.pyc index c2ec60e..ba68796 100644 Binary files a/addons/epr/models/__pycache__/__init__.cpython-312.pyc and b/addons/epr/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_approval.cpython-312.pyc b/addons/epr/models/__pycache__/epr_approval.cpython-312.pyc new file mode 100644 index 0000000..3971fdf Binary files /dev/null and b/addons/epr/models/__pycache__/epr_approval.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_po.cpython-312.pyc b/addons/epr/models/__pycache__/epr_po.cpython-312.pyc new file mode 100644 index 0000000..ee7ee5f Binary files /dev/null and b/addons/epr/models/__pycache__/epr_po.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc b/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc index c596ccf..a3d9732 100644 Binary files a/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc and b/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc differ diff --git a/addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc b/addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc new file mode 100644 index 0000000..8c16d87 Binary files /dev/null and b/addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc differ diff --git a/addons/epr/models/epr_approval.py b/addons/epr/models/epr_approval.py new file mode 100644 index 0000000..043864a --- /dev/null +++ b/addons/epr/models/epr_approval.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +# ============================================================================== +# MODEL CẤU HÌNH (TEMPLATE) +# Admin thiết lập: Tại bước (Sequence) này, ai cần duyệt và duyệt theo kiểu gì? +# ============================================================================== +class EprApprovalConfig(models.Model): + _name = 'epr.approval.config' + _description = 'EPR Approval Configuration' + _order = 'sequence, min_amount' + + name = fields.Char( + string='Rule Name', + required=True, + translate=True + ) + + active = fields.Boolean(default=True) + + company_id = fields.Many2one( + comodel_name='res.company', + string='Company', + default=lambda self: self.env.company + ) + + # 1. Điều kiện kích hoạt + sequence = fields.Integer( + string='Sequence', + default=10, + required=True, + help="Thứ tự thực hiện. Các Rule cùng Sequence sẽ chạy song song." + ) + + min_amount = fields.Monetary( + string='Minimum Amount', + currency_field='currency_id', + default=0.0, + help="Rule này chỉ áp dụng nếu tổng tiền RFQ lớn hơn số này." + ) + + currency_id = fields.Many2one( + comodel_name='res.currency', + string='Currency', + default=lambda self: self.env.company.currency_id + ) + + # 2. Ai duyệt? + user_ids = fields.Many2many( + comodel_name='res.users', + string='Approvers', + required=True, + domain="[('share', '=', False)]", # Chỉ lấy user nội bộ + help="Danh sách những người có quyền duyệt rule này." + ) + + # 3. Logic duyệt: 1 người hay tất cả? + approval_type = fields.Selection( + selection=[ + ('any', 'Any Approver (One pass)'), + ('all', 'All Approvers (Everyone must sign)') + ], + string='Approval Type', + default='any', + required=True + ) + + +# ============================================================================== +# MODEL VẬN HÀNH (INSTANCE) +# Sinh ra khi RFQ -> 'to_approve'. +# Đây là snapshot của Config tại thời điểm submit. +# ============================================================================== +class EprApprovalEntry(models.Model): + _name = 'epr.approval.entry' + _description = 'RFQ Approval Entry' + _order = 'sequence, id' + + # Link về RFQ + rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='RFQ Reference', + required=True, + ondelete='cascade' + ) + + config_id = fields.Many2one( + comodel_name='epr.approval.config', + string='Source Rule', + readonly=True, + ondelete='set null' # Giữ entry dù config bị xóa + ) + + # Thông tin snapshot từ Config + name = fields.Char( + string='Summary', + required=True + ) + + sequence = fields.Integer( + string='Sequence', + required=True + ) + + approval_type = fields.Selection( + selection=[ + ('any', 'Any'), + ('all', 'All') + ], + required=True + ) + + # Trạng thái dòng duyệt này + status = fields.Selection( + selection=[ + ('new', 'To Process'), # Chưa đến lượt (do sequence cao hơn) + ('pending', 'Pending'), # Đang chờ duyệt + ('approved', 'Approved'), # Đã xong + ('rejected', 'Rejected') # Bị từ chối + ], + string='Status', + default='new', + index=True, + readonly=True + ) + + # Danh sách người CẦN duyệt + required_user_ids = fields.Many2many( + comodel_name='res.users', + relation='epr_approval_req_users_rel', + string='Required Approvers', + readonly=True + ) + + # Danh sách người ĐÃ duyệt (quan trọng cho logic 'All') + actual_user_ids = fields.Many2many( + comodel_name='res.users', + relation='epr_approval_act_users_rel', + string='Approved By', + readonly=True + ) + + approval_date = fields.Datetime( + string='Last Action Date', + readonly=True + ) + + # Field hỗ trợ UI: User hiện tại có được nút Approve không? + can_approve = fields.Boolean(compute='_compute_can_approve') + + @api.depends('status', 'required_user_ids', 'actual_user_ids', 'approval_type') + @api.depends_context('uid') + def _compute_can_approve(self): + current_user = self.env.user + for record in self: + # 1. Phải đang ở trạng thái Pending + if record.status != 'pending': + record.can_approve = False + continue + + # 2. User phải nằm trong danh sách được phép + if current_user not in record.required_user_ids: + record.can_approve = False + continue + + # 3. Nếu là 'All', user này chưa được duyệt trước đó + if record.approval_type == 'all' and current_user in record.actual_user_ids: + record.can_approve = False + else: + record.can_approve = True + + # ------------------------------------------------------------------------- + # ACTION METHODS + # ------------------------------------------------------------------------- + def action_approve_line(self): + """User bấm nút Approve trên dòng này""" + self.ensure_one() + if not self.can_approve: + raise UserError(_("You either don't have permission to approve or you've already approved it.")) + + # Ghi nhận người duyệt + self.write({ + 'actual_user_ids': [(4, self.env.user.id)], + 'approval_date': fields.Datetime.now() + }) + + # Kiểm tra điều kiện hoàn thành dòng này + is_done = False + if self.approval_type == 'any': + # Chỉ cần 1 người -> Done + is_done = True + elif self.approval_type == 'all': + # Phải đủ tất cả required users + # Dùng set để so sánh ID cho nhanh + required_set = set(self.required_user_ids.ids) + actual_set = set(self.actual_user_ids.ids) + if required_set.issubset(actual_set): + is_done = True + + if is_done: + self.status = 'approved' + # Gọi về RFQ cha để check xem có mở tiếp tầng sau (Sequence kế) không? + self.rfq_id._check_approval_progression() + + def action_refuse_line(self): + """User từ chối -> Từ chối TOÀN BỘ RFQ""" + self.ensure_one() + if not self.can_approve: + raise UserError(_("You don't have permission to reject this RFQ.")) + + self.write({ + 'status': 'rejected', + 'approval_date': fields.Datetime.now() + }) + + # Đẩy RFQ về trạng thái 'rejected' hoặc 'cancel' + # Gọi hàm xử lý từ chối bên RFQ + self.rfq_id.action_reject_approval() diff --git a/addons/epr/models/epr_po.py b/addons/epr/models/epr_po.py new file mode 100644 index 0000000..1251839 --- /dev/null +++ b/addons/epr/models/epr_po.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + # === FIELD LIÊN KẾT 1-1 TỚI EPR RFQ === + # Dùng cho action_create_po() trong epr.rfq + epr_rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='Original ePR RFQ', + readonly=True, + copy=False, + ondelete='set null', + help="Tham chiếu đến phiếu báo giá nội bộ (ePR) đã tạo ra PO này." + ) + + # === COMPUTED FIELDS CHO SMART BUTTON (Line-Level Linking) === + # Tìm tất cả các RFQ gốc dựa trên các dòng PO lines + epr_rfq_ids = fields.Many2many( + comodel_name='epr.rfq', + string='Source RFQs', + compute='_compute_epr_rfq_data', + help="Các phiếu yêu cầu báo giá (EPR RFQ) liên quan đến đơn mua hàng này." + ) + + epr_rfq_count = fields.Integer( + string='RFQ Count', + compute='_compute_epr_rfq_data' + ) + + @api.depends('order_line.epr_rfq_line_id') + def _compute_epr_rfq_data(self): + for po in self: + # Logic: Quét qua tất cả line của PO -> lấy epr_rfq_line -> lấy rfq_id -> unique + # Mapped tự động loại bỏ các giá trị trùng lặp (set) + rfqs = po.order_line.mapped('epr_rfq_line_id.rfq_id') + po.epr_rfq_ids = rfqs + po.epr_rfq_count = len(rfqs) + + # === ACTION SMART BUTTON === + def action_view_epr_rfqs(self): + """Mở danh sách các EPR RFQ nguồn""" + self.ensure_one() + return { + 'name': _('Source RFQs'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.rfq', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.epr_rfq_ids.ids)], + 'context': {'create': False}, # Không cho tạo mới RFQ từ đây để tránh mất quy trình + } + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + # === FIELD LIÊN KẾT QUAN TRỌNG NHẤT === + # Field này sẽ được Wizard 'epr.create.po.wizard' ghi dữ liệu vào + epr_rfq_line_id = fields.Many2one( + comodel_name='epr.rfq.line', + string='Source RFQ Line', + readonly=True, + copy=False, + index=True, + help="Dòng yêu cầu báo giá gốc đã tạo ra dòng đơn mua hàng này." + ) diff --git a/addons/epr/models/epr_purchase_request.py b/addons/epr/models/epr_purchase_request.py index 3d93f25..3bcca60 100644 --- a/addons/epr/models/epr_purchase_request.py +++ b/addons/epr/models/epr_purchase_request.py @@ -43,7 +43,7 @@ class EprPurchaseRequest(models.Model): compute='_compute_is_owner', store=False ) - + date_required = fields.Date( string='Date Required', required=True, @@ -74,13 +74,6 @@ class EprPurchaseRequest(models.Model): group_expand='_expand_groups' ) - # approver_ids = fields.Many2many( - # 'res.users', - # string='Approvers', - # compute='_compute_approvers', - # store=True - # ) - # Approvers: Nên là field thường (không compute) để lưu cố định người duyệt lúc Submit approver_ids = fields.Many2many( comodel_name='res.users', @@ -93,17 +86,20 @@ class EprPurchaseRequest(models.Model): readonly=True, tracking=True ) + currency_id = fields.Many2one( 'res.currency', string='Currency', default=lambda self: self.env.company.currency_id, required=True ) + line_ids = fields.One2many( 'epr.purchase.request.line', 'request_id', string='Products' ) + estimated_total = fields.Monetary( string='Estimated Total', compute='_compute_estimated_total', @@ -111,6 +107,22 @@ class EprPurchaseRequest(models.Model): 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 # ========================================================================== @@ -196,6 +208,12 @@ class EprPurchaseRequest(models.Model): # 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) # ========================================================================== @@ -371,6 +389,26 @@ class EprPurchaseRequest(models.Model): # 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', # Odoo 18 ưu tiên dùng 'list' thay vì 'tree' + '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): """ diff --git a/addons/epr/models/epr_rfq.py b/addons/epr/models/epr_rfq.py new file mode 100644 index 0000000..ddf3436 --- /dev/null +++ b/addons/epr/models/epr_rfq.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +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á + ('confirmed', 'Confirmed'), # Đã duyệt -> Sẵn sàng tạo PO + ('cancel', 'Cancelled') + ], + string='Status', + readonly=True, + index=True, + copy=False, + default='draft', + tracking=True + ) + + approval_ids = fields.One2many( + comodel_name='epr.approval.entry', + inverse_name='rfq_id', + string='Approval Steps' + ) + + # 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' + ) + + # === 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')]", + readonly=True + ) + + 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 + readonly=True + ) + + 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, + readonly=True + ) + + # Link sang Purchase Order gốc của Odoo + purchase_ids = fields.One2many( + comodel_name='purchase.order', + inverse_name='epr_rfq_id', + string='Purchase Orders' + ) + + purchase_count = fields.Integer( + compute='_compute_purchase_count', + string='PO Count' + ) + + # === 5. COMPUTE METHODS === + @api.depends('purchase_ids') + def _compute_purchase_count(self): + for rfq in self: + rfq.purchase_count = len(rfq.purchase_ids) + + # === 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_rfq(self): + """Chốt RFQ, chuyển sang Confirmed""" + for rfq in self: + if rfq.state != 'received': + raise UserError(_("Vui lòng chuyển sang trạng thái 'Đã nhận' trước khi xác nhận.")) + 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_rfq_id': self.id, # Link ngược lại RFQ này + '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'}) + + # ------------------------------------------------------------------------- + # RFQ APPROVAL PROCESS + # ------------------------------------------------------------------------- + def action_submit_approval(self): + """Nút bấm Submit for Approval""" + 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. Tìm các Rules phù hợp + configs = self.env['epr.approval.config'].search([ + ('active', '=', True), + ('company_id', '=', self.company_id.id), + ('min_amount', '<=', self.amount_total) + ], order='sequence asc') + + if not configs: + # Nếu không có rule nào -> Auto Approve + self.write({'state': 'received'}) # Hoặc trạng thái tiếp theo bạn muốn + return + + # 2. Xóa dữ liệu duyệt cũ (nếu submit lại) + self.approval_ids.unlink() + + # 3. Tạo Approval Entries (Snapshot) + approval_vals = [] + for conf in configs: + approval_vals.append({ + 'rfq_id': self.id, + 'config_id': conf.id, # Nếu bạn muốn link lại + 'name': conf.name, + 'sequence': conf.sequence, + 'approval_type': conf.approval_type, + 'required_user_ids': [(6, 0, conf.user_ids.ids)], + 'status': 'new', # Mặc định là new + }) + + self.env['epr.approval.entry'].create(approval_vals) + + # 4. Chuyển trạng thái và Kích hoạt tầng đầu tiên + self.write({'state': 'to_approve'}) + self._check_approval_progression() + + # ------------------------------------------------------------------------- + # APPROVAL LOGIC: LINEARIZATION + # ------------------------------------------------------------------------- + def _check_approval_progression(self): + """Hàm này được gọi mỗi khi có 1 dòng được Approved hoặc khi mới Submit""" + self.ensure_one() + + # Lấy tất cả entries + all_entries = self.approval_ids + + # 1. Tìm các Sequence đang 'pending' (Đang chạy) + current_pending = all_entries.filtered(lambda x: x.status == 'pending') + + if current_pending: + # Nếu còn dòng đang pending -> Chưa làm gì cả, đợi user khác duyệt tiếp + return + + # 2. Nếu không còn pending, tìm Sequence nhỏ nhất đang là 'new' (Chưa chạy) + next_new_entries = all_entries.filtered(lambda x: x.status == 'new') + + if next_new_entries: + # Tìm số sequence nhỏ nhất tiếp theo + min_seq = min(next_new_entries.mapped('sequence')) + + # Kích hoạt TOÀN BỘ các rule có cùng sequence đó (Parallel Approval) + to_activate = next_new_entries.filtered(lambda x: x.sequence == min_seq) + to_activate.write({'status': 'pending'}) + + # Gửi thông báo/Email cho những người vừa được kích hoạt (Optional) + # self._notify_approvers(to_activate) + else: + # 3. Không còn 'new', cũng không còn 'pending' -> Tất cả đã Approved + # Chuyển RFQ sang bước tiếp theo + self.action_mark_approved() + + def action_mark_approved(self): + """Hoàn tất quy trình duyệt""" + # Chuyển sang trạng thái 'Confirmed' + self.write({'state': 'confirmed'}) + self.message_post(body=_("Tất cả các cấp phê duyệt đã hoàn tất.")) + + def action_reject_approval(self): + """Xử lý khi bị từ chối""" + self.write({'state': 'draft'}) # Quay về nháp để sửa + self.message_post(body=_("Yêu cầu phê duyệt đã bị từ chối.")) + +# ============================================================================== +# 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' + ) + + # Sản phẩm (Odoo Product) + product_id = fields.Many2one( + comodel_name='product.product', + string='Product', + required=True + ) + + description = fields.Text( + string='Description' + ) + + # Số lượng & Đơn giá (Có thể khác với PR ban đầu do đàm phán) + quantity = fields.Float( + string='Quantity', + required=True, + default=1.0 + ) + + uom_id = fields.Many2one( + comodel_name='uom.uom', + string='UoM' + ) + + 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' + ) + + # Tổng tiền trên RFQ line + subtotal = fields.Monetary( + compute='_compute_subtotal', + string='Subtotal', + store=True + ) + + currency_id = fields.Many2one( + related='rfq_id.currency_id' + ) + + @api.depends('quantity', 'price_unit') + def _compute_subtotal(self): + for line in self: + line.subtotal = line.quantity * line.price_unit + + @api.onchange('product_id') + def _onchange_product_id(self): + if self.product_id: + self.description = self.product_id.display_name + self.uom_id = self.product_id.uom_po_id or self.product_id.uom_id + + # ============================================================================== + # LINE-LEVEL LINKING LOGIC (From RFQs to POs) + # ============================================================================== + + # Link tới dòng của Purchase Order chuẩn Odoo + purchase_line_id = fields.Many2one( + 'purchase.order.line', + string='Purchase Order Line', + readonly=True, + copy=False + ) + + # Tiện ích để xem nhanh trạng thái + po_id = fields.Many2one( + related='purchase_line_id.order_id', + string='Purchase Order', + store=True, + readonly=True + ) diff --git a/addons/epr/security/ir.model.access.csv b/addons/epr/security/ir.model.access.csv index 1cea9e9..ee07cf3 100644 --- a/addons/epr/security/ir.model.access.csv +++ b/addons/epr/security/ir.model.access.csv @@ -1,12 +1,27 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_epr_purchase_request_user,ePR Request User,model_epr_purchase_request,group_epr_user,1,1,1,0 -access_epr_purchase_request_line_user,ePR Line User,model_epr_purchase_request_line,group_epr_user,1,1,1,0 -access_epr_reject_wizard_user,ePR Reject Wizard User,model_epr_reject_wizard,group_epr_user,1,0,0,0 +access_epr_purchase_request_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 Line Manager,model_epr_purchase_request_line,group_epr_manager,1,1,1,0 -access_epr_reject_wizard_manager,ePR Reject Wizard Manager,model_epr_reject_wizard,group_epr_manager,1,1,1,1 +access_epr_purchase_request_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 Line Officer,model_epr_purchase_request_line,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 Line Admin,model_epr_purchase_request_line,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_approval_config_officer,ePR Approval Config Officer (read only),model_epr_approval_config,group_epr_purchasing_officer,1,0,0,0 +access_epr_approval_config_admin,ePR Approval Config Admin,model_epr_approval_config,group_epr_admin,1,1,1,1 +access_epr_approval_entry_user,ePR Approval Entry User (read only),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,0,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 (read only),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 diff --git a/addons/epr/views/epr_approval_views.xml b/addons/epr/views/epr_approval_views.xml new file mode 100644 index 0000000..81b6b4e --- /dev/null +++ b/addons/epr/views/epr_approval_views.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + epr.approval.config.list + epr.approval.config + + + + + + + + + + + + + + + + + epr.approval.config.form + epr.approval.config + +
+ +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + epr.approval.config.search + epr.approval.config + + + + + + + + + + + + Approval Rules + epr.approval.config + list,form + +

+ Define your Purchase Request approval rules here. +

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

+ No Purchase Orders created from EPR RFQs yet. +

+
+
+ +
+
\ No newline at end of file diff --git a/addons/epr/views/epr_purchase_request_views.xml b/addons/epr/views/epr_purchase_request_views.xml index 67f3a4a..2966bb4 100644 --- a/addons/epr/views/epr_purchase_request_views.xml +++ b/addons/epr/views/epr_purchase_request_views.xml @@ -173,6 +173,7 @@ epr.purchase.request
+
+ + diff --git a/addons/epr/views/epr_rfq_views.xml b/addons/epr/views/epr_rfq_views.xml new file mode 100644 index 0000000..b82f9ba --- /dev/null +++ b/addons/epr/views/epr_rfq_views.xml @@ -0,0 +1,221 @@ + + + + + + + + + epr.rfq.list + epr.rfq + + + + + + + + + + + + + + + + + + + + + epr.rfq.form + epr.rfq + + + +
+ +
+ + + + + + +
+ Request for Quotation +

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + epr.rfq.search + epr.rfq + + + + + + + + + + + + + + + + + + + + + + + + + + + Requests for Quotation + epr.rfq + list,form + + + +

+ Create your first Request for Quotation (RFQ) +

+

+ Manage your vendor negotiations and create Purchase Orders directly from RFQs. +

+
+
+ + \ No newline at end of file diff --git a/addons/epr/wizards/__init__.py b/addons/epr/wizards/__init__.py index a798f2d..0f3b969 100644 --- a/addons/epr/wizards/__init__.py +++ b/addons/epr/wizards/__init__.py @@ -1 +1,2 @@ from . import epr_reject_wizard +from . import epr_create_po diff --git a/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc b/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc index b86063f..e198c2d 100644 Binary files a/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc and b/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc differ diff --git a/addons/epr/wizards/__pycache__/epr_create_po.cpython-312.pyc b/addons/epr/wizards/__pycache__/epr_create_po.cpython-312.pyc new file mode 100644 index 0000000..1f6f8fc Binary files /dev/null and b/addons/epr/wizards/__pycache__/epr_create_po.cpython-312.pyc differ diff --git a/addons/epr/wizards/epr_create_po.py b/addons/epr/wizards/epr_create_po.py new file mode 100644 index 0000000..4144070 --- /dev/null +++ b/addons/epr/wizards/epr_create_po.py @@ -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 + ) diff --git a/addons/epr/wizards/epr_create_po_views.xml b/addons/epr/wizards/epr_create_po_views.xml new file mode 100644 index 0000000..020ca55 --- /dev/null +++ b/addons/epr/wizards/epr_create_po_views.xml @@ -0,0 +1,53 @@ + + + + epr.create.po.wizard.form + epr.create.po.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + Create Purchase Order + epr.create.po.wizard + form + new + + list + + +
\ No newline at end of file