Đã thành công trong việc gom PRs thành RFQ:

+ Lưu ý cực kỳ quan trong: Lý do trường "Source Requests" (request_ids) bị NULL chính là do vấn đề định nghĩa trong View XML của Wizard.
    Đây là một "bẫy" kinh điển (Common Pitfall) trong Odoo khi làm việc với Wizard (TransientModel):
    Nguyên tắc: Trong Wizard, nếu một trường có dữ liệu (được set từ default_get) nhưng:
        1. Không xuất hiện trong file XML.
        2. Hoặc có xuất hiện nhưng là readonly="1" và thiếu force_save="1".
        => Thì khi bạn bấm nút "Create RFQ", Odoo Web Client sẽ không gửi giá trị của trường đó về server. Kết quả là code Python nhận được giá trị False hoặc Null.
This commit is contained in:
mtpc4s9 2025-12-22 12:32:25 +07:00
parent 7555d2ca65
commit fc8141542a
7 changed files with 295 additions and 132 deletions

View File

@ -54,7 +54,7 @@ class EprRfq(models.Model):
# 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
relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian
column1='rfq_id',
column2='request_id',
string='Source Requests',
@ -363,29 +363,48 @@ class EprRfqLine(models.Model):
comodel_name='epr.rfq',
string='RFQ Reference',
required=True,
ondelete='cascade'
ondelete='cascade',
index=True
)
# Sản phẩm (Odoo Product)
# === 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'
string='Product',
required=True
)
description = fields.Text(
string='Description'
)
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
default=1.0,
digits='Product Unit of Measure'
)
uom_id = fields.Many2one(
comodel_name='uom.uom',
string='UoM'
string='UoM',
required=True
)
price_unit = fields.Float(
@ -398,30 +417,40 @@ class EprRfqLine(models.Model):
relation='epr_rfq_line_taxes_rel',
column1='line_id',
column2='tax_id',
string='Taxes'
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
)
# Tổng tiền trên RFQ line
subtotal = fields.Monetary(
compute='_compute_subtotal',
string='Subtotal',
store=True
store=True,
currency_field='currency_id'
)
currency_id = fields.Many2one(
related='rfq_id.currency_id'
)
@api.depends('quantity', 'price_unit')
@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:
line.subtotal = line.quantity * line.price_unit
# === 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.description = self.product_id.display_name
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)

View File

@ -151,7 +151,7 @@
<!-- 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">
<!-- Lưu ý: Cần thêm field amount_total compute trong python nếu muốn hiện tổng ở đây -->
<field name="amount_total" widget="monetary" options="{'currency_field': 'currency_id'}"/>
</group>
<div class="oe_clear"/>
</group>

View File

@ -14,65 +14,78 @@ class EprCreateRfqWizard(models.TransientModel):
string='PR Lines to Process'
)
# -------------------------------------------------------------------------
# 1. LOAD DATA (BẮT BUỘC CÓ)
# -------------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
"""
Khi mở Wizard, tự động load các PR đã chọn từ màn hình danh sách.
Lấy dữ liệu từ các PR được chọn (active_ids) đ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
# Lấy danh sách PR gốc
# Đọc dữ liệu PR
requests = self.env['epr.purchase.request'].browse(active_ids)
# Prepare dữ liệu cho dòng Wizard
# 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:
# Chỉ xử lý các PR đã được duyệt (Ví dụ trạng thái 'approved')
# Bạn có thể bỏ comment dòng dưới nếu có field state
if pr.state != 'approved':
raise UserError(_("PR %s chưa được duyệt.", pr.name))
for line in pr.line_ids:
# 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({
'pr_line_id': line.id, # Link tới PR Line
# Link dữ liệu để truy vết sau này
'request_id': pr.id,
'final_vendor_id': line.final_vendor_id.id if line.final_vendor_id else False,
'suggested_vendor_name': line.suggested_vendor_name,
'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):
"""
Logic hardened with .sudo():
1. Access PR data using sudo to bypass security rules.
2. Group by Vendor.
3. Create RFQ Header and Lines using sudo() to ensure data persistence.
4. Redirect to the newly created RFQ(s).
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. Validation
missing_vendor_lines = self.line_ids.filtered(lambda l: not l.final_vendor_id)
if missing_vendor_lines:
raise UserError(_("Please select a Final Vendor for all lines."))
# 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))
missing_product_lines = self.line_ids.filtered(lambda l: not l.final_product_id)
if missing_product_lines:
raise UserError(_("Please select a Final Product for all lines."))
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:
# Sync back to PR line (Sudo to ensure write permission if needed)
if wiz_line.pr_line_id.final_vendor_id != wiz_line.final_vendor_id:
wiz_line.pr_line_id.sudo().write({'final_vendor_id': wiz_line.final_vendor_id.id})
vendor = wiz_line.final_vendor_id
if vendor not in grouped_lines:
grouped_lines[vendor] = self.env['epr.create.rfq.line']
@ -82,64 +95,52 @@ class EprCreateRfqWizard(models.TransientModel):
# 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 = []
source_pr_ids = []
for wiz_line in wiz_lines:
# DÙNG SUDO: Để chắc chắn lấy được dữ liệu PR line
pr_line_sudo = wiz_line.pr_line_id.sudo()
request_sudo = wiz_line.request_id.sudo()
# Collect unique source PR ids
if request_sudo and request_sudo.id not in source_pr_ids:
source_pr_ids.append(request_sudo.id)
# Determine UoM (Directly from Product or PR Line)
uom_id = False
if wiz_line.final_product_id:
uom_id = wiz_line.final_product_id.uom_po_id.id or wiz_line.final_product_id.uom_id.id
if not uom_id and pr_line_sudo.product_id:
uom_id = pr_line_sudo.product_id.uom_po_id.id or pr_line_sudo.product_id.uom_id.id
# Create line command - DÙNG GIÁ TRỊ TỪ SUDO RECORD
rfq_line_commands.append(Command.create({
'product_id': wiz_line.final_product_id.id,
'description': pr_line_sudo.name or '',
'quantity': pr_line_sudo.quantity or 1.0,
'uom_id': uom_id,
'price_unit': 0.0,
'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
}))
# Create RFQ header - SỬ DỤNG SUDO ĐỂ TẠO
rfq = self.env['epr.rfq'].sudo().create({
# Tạo RFQ Header
rfq_vals = {
'partner_id': vendor.id,
'state': 'draft',
'date_order': fields.Datetime.now(),
'request_ids': [Command.set(source_pr_ids)],
'request_ids': [Command.set(source_requests.ids)],
'line_ids': rfq_line_commands,
})
}
rfq = self.env['epr.rfq'].create(rfq_vals)
created_rfqs |= rfq
# 4. Result Action
# 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:
return {
'name': _('Request for Quotation'),
'type': 'ir.actions.act_window',
'res_model': 'epr.rfq',
'view_mode': 'form',
'res_id': created_rfqs.id,
'target': 'current',
}
action['view_mode'] = 'form'
action['res_id'] = created_rfqs.id
else:
return {
'name': _('Requests for Quotation'),
'type': 'ir.actions.act_window',
'res_model': 'epr.rfq',
'view_mode': 'list,form',
'domain': [('id', 'in', created_rfqs.ids)],
'target': 'current',
}
action['view_mode'] = 'list,form' # Odoo 18 dùng 'list'
action['domain'] = [('id', 'in', created_rfqs.ids)]
return action
class EprCreateRfqLine(models.TransientModel):
@ -148,62 +149,52 @@ class EprCreateRfqLine(models.TransientModel):
wizard_id = fields.Many2one('epr.create.rfq.wizard', string='Wizard')
# Link tới PR gốc (Readonly)
# Dữ liệu PR gốc
request_id = fields.Many2one(
comodel_name='epr.purchase.request',
string='Purchase Request',
'epr.purchase.request',
string='PR',
readonly=True
)
# Link tới PR Line gốc
pr_line_id = fields.Many2one(
comodel_name='epr.purchase.request.line',
'epr.purchase.request.line',
string='PR Line',
readonly=True
)
# Cột hiển thị text gợi ý (Readonly) -> Giúp Officer tham chiếu
suggested_vendor_name = fields.Char(
string='Suggested Vendor (Text)',
readonly=True,
help="Tên nhà cung cấp do người yêu cầu nhập tay (tham khảo)."
)
suggested_vendor_name = fields.Char(string='Suggested Vendor', readonly=True)
# Cột Final Vendor (Editable) -> Đây là nơi Officer thao tác chính
# Cho phép User chọn/sửa trong Wizard
final_vendor_id = fields.Many2one(
comodel_name='res.partner',
'res.partner',
string='Final Vendor',
required=True,
# domain="[('supplier_rank', '>', 0)]", # Chỉ lấy nhà cung cấp
help="Chọn nhà cung cấp chính thức trong hệ thống để tạo RFQ."
required=True
)
# Lấy thông tin sản phẩm cần mua
product_description = fields.Char(
related='pr_line_id.name',
string='Product / Description',
readonly=True
)
# Purchasing Officer chọn sản phẩm tương ứng với mô tả của User
final_product_id = fields.Many2one(
comodel_name='product.product',
'product.product',
string='Final Product',
required=True,
# domain="[('purchase_ok', '=', True)]",
help="Chọn sản phẩm tương ứng với mô tả của người yêu cầu."
required=True
)
# Lấy số lượng
product_description = fields.Char(string='Description')
quantity = fields.Float(
related='pr_line_id.quantity',
string='Qty',
digits='Product Unit of Measure'
)
uom_id = fields.Many2one(
'uom.uom',
string='UoM'
)
uom_name = fields.Char(
string='PR UoM',
readonly=True
)
# Lấy đơn vị tính
uom_name = fields.Char(
related='pr_line_id.uom_name',
string='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

View File

@ -21,10 +21,14 @@
<list editable="bottom" create="0" delete="0"
decoration-danger="not final_vendor_id">
<!-- PR Reference (readonly) -->
<field name="request_id" string="PR Ref"/>
<field name="request_id"
string="Source PR"
readonly="1"
force_save="1"
options="{'no_open': True}"/>
<!-- Product Description -->
<field name="product_description" readonly="1"/>
<!-- Product Description (now editable as requested) -->
<field name="product_description"/>
<!-- Final Product (editable - main action field) -->
<field name="final_product_id"
@ -37,7 +41,8 @@
<field name="quantity"/>
<!-- UOM -->
<field name="uom_name" optional="show"/>
<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"

View File

@ -0,0 +1,138 @@
# -*- 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'
line_ids = fields.One2many('epr.create.rfq.line', 'wizard_id', string='Lines')
@api.model
def default_get(self, fields_list):
"""
Load dữ liệu từ PRs được chọn vào Wizard để User review trước khi tạo RFQ.
"""
res = super().default_get(fields_list)
active_ids = self.env.context.get('active_ids', [])
if not active_ids:
return res
# Lấy các Purchase Requests được chọn
requests = self.env['epr.purchase.request'].browse(active_ids)
lines_vals = []
for pr in requests:
# Giả định PR có field line_ids chứa sản phẩm chi tiết
# Ta cần loop qua từng dòng PR để đưa vào Wizard
for pr_line in pr.line_ids:
lines_vals.append(Command.create({
'request_id': pr.id, # Link để truy vết PR gốc
'pr_line_id': pr_line.id, # Link dòng gốc
'suggested_vendor_name': pr.suggested_vendor_name, # Hoặc pr_line.suggested_vendor
'final_vendor_id': pr.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, # <--- QUAN TRỌNG: Lấy số lượng từ PR
'uom_name': pr_line.uom_id.name, # Chỉ để hiển thị
}))
res['line_ids'] = lines_vals
return res
def action_create_rfqs(self):
self.ensure_one()
# 1. Validate: Kiểm tra xem đã chọn Vendor chưa
if any(not l.final_vendor_id for l in self.line_ids):
raise UserError(_("Vui lòng chọn 'Final Vendor' cho tất cả các dòng trước khi tạo RFQ."))
# 2. Gom nhóm theo Vendor (Dictionary: {vendor_id: [lines]})
grouped_by_vendor = {}
for line in self.line_ids:
vendor = line.final_vendor_id
if vendor not in grouped_by_vendor:
grouped_by_vendor[vendor] = []
grouped_by_vendor[vendor].append(line)
new_rfqs = self.env['epr.rfq']
# 3. Tạo RFQ cho từng Vendor
for vendor, w_lines in grouped_by_vendor.items():
# A. Thu thập tất cả Source Requests (Many2many) cho RFQ Header
# Dùng set() để loại bỏ các ID trùng lặp nếu nhiều dòng cùng thuộc 1 PR
source_request_ids = list(set(l.request_id.id for l in w_lines))
# B. Chuẩn bị dữ liệu line cho RFQ (One2many)
rfq_lines_commands = []
for w_line in w_lines:
rfq_lines_commands.append(Command.create({
'product_id': w_line.final_product_id.id,
'description': w_line.product_description,
'quantity': w_line.quantity, # <--- FIX LỖI: Map đúng số lượng
# 'uom_id': ..., # Nếu bạn có field uom_id
# 'price_unit': 0.0, # RFQ thường để giá 0 để xin báo giá
}))
# C. Tạo RFQ (Header + Lines)
# Dùng .sudo() nếu user hiện tại không có quyền tạo RFQ nhưng được phép chạy wizard này
rfq_vals = {
'vendor_id': vendor.id,
'state': 'draft',
'date_order': fields.Datetime.now(),
# FIX LỖI: Dùng Command.set() để gán danh sách IDs vào Many2many
'request_ids': [Command.set(source_request_ids)],
# Tạo các dòng con
'line_ids': rfq_lines_commands,
}
new_rfq = self.env['epr.rfq'].sudo().create(rfq_vals)
new_rfqs += new_rfq
# 4. Redirect người dùng đến (các) RFQ vừa tạo
if not new_rfqs:
return {'type': 'ir.actions.act_window_close'}
action = {
'name': _('Generated RFQs'),
'type': 'ir.actions.act_window',
'res_model': 'epr.rfq',
'domain': [('id', 'in', new_rfqs.ids)],
'context': {'create': False},
}
if len(new_rfqs) == 1:
action.update({
'view_mode': 'form',
'res_id': new_rfqs.id,
})
else:
action.update({
'view_mode': 'list,form', # Odoo 18 dùng 'list'
})
return action
class EprCreateRfqLine(models.TransientModel):
_name = 'epr.create.rfq.line'
_description = 'Wizard Line: PR Details'
wizard_id = fields.Many2one('epr.create.rfq.wizard')
# Các field dữ liệu để chuyển từ PR -> RFQ
request_id = fields.Many2one('epr.purchase.request', readonly=True)
pr_line_id = fields.Many2one('epr.purchase.request.line', readonly=True) # Lưu tham chiếu dòng gốc
suggested_vendor_name = fields.Char(readonly=True)
final_vendor_id = fields.Many2one('res.partner', string='Final Vendor', required=True)
final_product_id = fields.Many2one('product.product', string='Product', required=True)
product_description = fields.Char(string='Description')
# Field Quantity cần editable trong wizard để user có thể điều chỉnh nếu muốn
quantity = fields.Float(string='Quantity', digits='Product Unit of Measure')
uom_name = fields.Char(readonly=True)