batch_picking_create_one_bill: major rework
Fixes an issue where creating the bills from the purchase orders could create a bill with quantities vastly different from the received batch quantities. This would happen when the purchase orders had lines with quantities to invoice that did not match the receipt quantities. Instead of creating the bills from the POs, we now generate the bill and its line values directly from the quantities on the move lines related to the batch.
This commit is contained in:
parent
d16304e57b
commit
99e3ed03a7
10 changed files with 246 additions and 555 deletions
|
|
@ -24,9 +24,6 @@
|
|||
"account_reports",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"wizard/create_bill_wizard_views.xml",
|
||||
"wizard/merge_bill_wizard_views.xml",
|
||||
"wizard/stock_picking_to_batch_views.xml",
|
||||
"views/stock_picking_batch_views.xml",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
from odoo import models, fields, api, Command, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class StockPickingBatch(models.Model):
|
||||
_inherit = "stock.picking.batch"
|
||||
|
||||
purchase_order_ids = fields.Many2many(
|
||||
"purchase.order",
|
||||
string="Bons de commande",
|
||||
compute="_compute_purchase_orders",
|
||||
store=True,
|
||||
)
|
||||
|
||||
partner_ids = fields.Many2many(
|
||||
"res.partner", string="Fournisseur", compute="_compute_partner", store=True
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# Handle zero_quantity_default from context if not explicitly set in vals
|
||||
|
|
@ -37,16 +48,30 @@ class StockPickingBatch(models.Model):
|
|||
compute_sudo=True,
|
||||
)
|
||||
|
||||
# Field computation methods
|
||||
|
||||
@api.depends("picking_ids.move_ids.purchase_line_id.order_id")
|
||||
def _compute_purchase_orders(self):
|
||||
for batch in self:
|
||||
batch.purchase_order_ids = (
|
||||
batch.picking_ids.move_ids.purchase_line_id.order_id
|
||||
)
|
||||
|
||||
@api.depends("purchase_order_ids")
|
||||
def _compute_partner(self):
|
||||
for wizard in self:
|
||||
wizard.partner_ids = wizard.purchase_order_ids.mapped("partner_id")
|
||||
|
||||
def _prepare_move_line_vals(self, **kwargs):
|
||||
vals = super()._prepare_move_line_vals(**kwargs)
|
||||
if self.zero_quantity_default:
|
||||
vals["quantity"] = 0.0
|
||||
return vals
|
||||
|
||||
@api.depends("picking_ids.move_ids.purchase_line_id.order_id.invoice_ids")
|
||||
@api.depends("move_line_ids.move_id.purchase_line_id.order_id.invoice_ids")
|
||||
def _compute_invoice_ids(self):
|
||||
for batch in self:
|
||||
purchase_orders = batch.picking_ids.move_ids.purchase_line_id.order_id
|
||||
purchase_orders = batch.move_line_ids.move_id.purchase_line_id.order_id
|
||||
batch.invoice_ids = purchase_orders.invoice_ids
|
||||
|
||||
@api.depends("invoice_ids")
|
||||
|
|
@ -54,6 +79,8 @@ class StockPickingBatch(models.Model):
|
|||
for batch in self:
|
||||
batch.invoice_count = len(batch.invoice_ids)
|
||||
|
||||
# Actions
|
||||
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
|
|
@ -66,17 +93,72 @@ class StockPickingBatch(models.Model):
|
|||
action["domain"] = [("id", "in", self.invoice_ids.ids)]
|
||||
return action
|
||||
|
||||
@api.constrains("picking_ids")
|
||||
def _check_purchase_orders(self):
|
||||
for batch in self:
|
||||
partners = batch.picking_ids.purchase_id.partner_id
|
||||
if len(partners) > 1:
|
||||
raise ValidationError(
|
||||
"Tous les bons de commande du batch doivent provenir du même fournisseur."
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to set zero quantity on move lines at confirmation of the batch"""
|
||||
res = super().action_confirm()
|
||||
self.filtered("zero_quantity_default").move_line_ids.write({"quantity": 0})
|
||||
return res
|
||||
|
||||
def action_create_bill(self):
|
||||
self.ensure_one()
|
||||
if not self.purchase_order_ids:
|
||||
raise ValidationError(_("No purchase orders found in this batch."))
|
||||
|
||||
if len(self.partner_ids) > 1:
|
||||
raise ValidationError(_("The batch must have only one supplier."))
|
||||
|
||||
bill = self.env["account.move"].create(self._get_bill_values())
|
||||
return self._get_view_bill_action(bill)
|
||||
|
||||
# Helpers
|
||||
|
||||
def _get_view_bill_action(self, bill):
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"account.action_move_in_invoice_type"
|
||||
)
|
||||
action["views"] = [(False, "form")]
|
||||
action["res_id"] = self.invoice_ids[-1].id
|
||||
return action
|
||||
|
||||
def _get_currency_id(self):
|
||||
currency_id = self.purchase_order_ids.mapped("currency_id")
|
||||
if len(currency_id) > 1:
|
||||
raise UserError(_("The selected receipts do not have the same currency."))
|
||||
return currency_id.id
|
||||
|
||||
def _get_bill_values(self):
|
||||
"""Build a dictionary of the values for the vendor bill to create."""
|
||||
|
||||
company_id = self.company_id.id
|
||||
partner_id = self.partner_ids.id
|
||||
invoice_date = self.scheduled_date
|
||||
invoice_origin = ", ".join(self.purchase_order_ids.mapped("name"))
|
||||
move_line_ids = self._get_line_values()
|
||||
currency_id = self._get_currency_id()
|
||||
return {
|
||||
"company_id": company_id,
|
||||
"partner_id": partner_id,
|
||||
"move_type": "in_invoice",
|
||||
"invoice_date": invoice_date,
|
||||
"invoice_origin": invoice_origin,
|
||||
"currency_id": currency_id,
|
||||
"line_ids": move_line_ids,
|
||||
}
|
||||
|
||||
def _get_line_values(self):
|
||||
"""For each of the stock.move.line in the batch, build a dictionary of values for the invoice line to match."""
|
||||
line_vals = []
|
||||
for move_line in self.move_line_ids:
|
||||
purchase_line_id = move_line.move_id.purchase_line_id
|
||||
line_vals.append(
|
||||
Command.create(
|
||||
{
|
||||
"product_id": move_line.product_id.id,
|
||||
"quantity": move_line.quantity,
|
||||
"price_unit": purchase_line_id.price_unit,
|
||||
"discount": purchase_line_id.discount,
|
||||
"purchase_line_id": purchase_line_id.id,
|
||||
}
|
||||
)
|
||||
)
|
||||
return line_vals
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_create_bill_wizard,create_bill_wizard_access,model_create_bill_wizard,base.group_user,1,1,1,0
|
||||
access_merge_bill_wizard,merge_bill_wizard_access,model_merge_bill_wizard,base.group_user,1,1,1,0
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo import fields
|
||||
|
||||
|
||||
|
|
@ -118,201 +118,169 @@ class TestBatchPickingBill(TransactionCase):
|
|||
for move in picking.move_ids:
|
||||
move.quantity = move.product_qty
|
||||
|
||||
def test_01_warehouse_user_flow(self):
|
||||
"""Test the complete flow with warehouse user"""
|
||||
# Switch to warehouse user
|
||||
self.env = self.env(user=self.warehouse_user)
|
||||
|
||||
# Create batch picking
|
||||
batch = self.env["stock.picking.batch"].create(
|
||||
# Create a batch picking with the pickings
|
||||
cls.batch = cls.env["stock.picking.batch"].create(
|
||||
{
|
||||
"picking_ids": [
|
||||
(6, 0, (self.po1.picking_ids + self.po2.picking_ids).ids)
|
||||
],
|
||||
"zero_quantity_default": True,
|
||||
"name": "Test Batch",
|
||||
"company_id": cls.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
"picking_ids": [(6, 0, (cls.po1 + cls.po2).picking_ids.ids)],
|
||||
"zero_quantity_default": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Check batch was created successfully
|
||||
self.assertTrue(batch, "Batch picking should be created")
|
||||
self.assertEqual(len(batch.picking_ids), 2, "Batch should have 2 pickings")
|
||||
|
||||
# Process the pickings
|
||||
for picking in batch.picking_ids:
|
||||
for move_line in picking.move_line_ids:
|
||||
move_line.quantity = move_line.move_id.product_qty
|
||||
|
||||
# Validate batch
|
||||
batch.action_done()
|
||||
self.assertEqual(batch.state, "done", "Batch should be done")
|
||||
|
||||
# Try to create bill (should work)
|
||||
wizard = (
|
||||
self.env["create.bill.wizard"]
|
||||
.with_context(default_batch_id=batch.id)
|
||||
.create({})
|
||||
)
|
||||
|
||||
# Execute the create bill action
|
||||
action = wizard.action_create_bill()
|
||||
self.assertTrue(action, "Should get action to merge bills")
|
||||
self.assertEqual(
|
||||
action["res_model"], "merge.bill.wizard", "Should open merge bill wizard"
|
||||
)
|
||||
|
||||
def test_02_bill_creation_and_access(self):
|
||||
"""Test bill creation and access rights"""
|
||||
# Create and process batch as warehouse user
|
||||
self.env = self.env(user=self.warehouse_user)
|
||||
|
||||
batch = self.env["stock.picking.batch"].create(
|
||||
# Create a batch with multiple vendors for testing validation
|
||||
cls.vendor2 = cls.env["res.partner"].create(
|
||||
{
|
||||
"picking_ids": [
|
||||
(6, 0, (self.po1.picking_ids + self.po2.picking_ids).ids)
|
||||
],
|
||||
"zero_quantity_default": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Process pickings as warehouse user
|
||||
for picking in batch.picking_ids:
|
||||
for move_line in picking.move_line_ids:
|
||||
move_line.quantity = move_line.move_id.product_qty
|
||||
batch.action_done()
|
||||
|
||||
# Create bill as warehouse user
|
||||
wizard = (
|
||||
self.env["create.bill.wizard"]
|
||||
.with_context(default_batch_id=batch.id)
|
||||
.create({})
|
||||
)
|
||||
action = wizard.action_create_bill()
|
||||
|
||||
# Execute merge wizard as warehouse user
|
||||
merge_wizard = (
|
||||
self.env["merge.bill.wizard"].with_context(**action["context"]).create({})
|
||||
)
|
||||
|
||||
draft_invoices = merge_wizard.invoice_ids
|
||||
self.assertTrue(draft_invoices, "Invoices should be created")
|
||||
|
||||
bill = self.env["account.move"].browse(merge_wizard.action_process()["res_id"])
|
||||
self.assertTrue(bill, "Bill should be created")
|
||||
self.assertEqual(
|
||||
bill.mapped("state"),
|
||||
["draft"],
|
||||
"Bill should be draft",
|
||||
)
|
||||
|
||||
# Validate invoice as accountant (should work)
|
||||
bill.with_user(self.accountant).invoice_date = fields.Date.today()
|
||||
bill.with_user(self.accountant).action_post()
|
||||
self.assertEqual(
|
||||
bill.mapped("state"),
|
||||
["posted"],
|
||||
"Bill should be posted",
|
||||
)
|
||||
|
||||
def test_03_multi_vendor_constraint(self):
|
||||
"""Test constraint preventing batch with multiple vendors"""
|
||||
# Create another vendor and PO
|
||||
other_vendor = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Other Vendor",
|
||||
"email": "other@test.com",
|
||||
"name": "Second Vendor",
|
||||
"email": "vendor2@test.com",
|
||||
"supplier_rank": 1,
|
||||
}
|
||||
)
|
||||
|
||||
other_po = self.env["purchase.order"].create(
|
||||
# Create a purchase order with a different vendor
|
||||
po_vals_vendor2 = {
|
||||
"partner_id": cls.vendor2.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": cls.product_a.id,
|
||||
"name": cls.product_a.name,
|
||||
"product_qty": 2.0,
|
||||
"product_uom": cls.product_a.uom_po_id.id,
|
||||
"price_unit": 100.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
cls.po3 = cls.env["purchase.order"].create(po_vals_vendor2)
|
||||
cls.po3.button_confirm()
|
||||
|
||||
# Process the picking for the second vendor
|
||||
for picking in cls.po3.picking_ids:
|
||||
for move in picking.move_ids:
|
||||
move.quantity = move.product_qty
|
||||
|
||||
# Create a batch with multiple vendors
|
||||
cls.multi_vendor_batch = cls.env["stock.picking.batch"].create(
|
||||
{
|
||||
"partner_id": other_vendor.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product_a.id,
|
||||
"product_qty": 1,
|
||||
"price_unit": 100,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
other_po.button_confirm()
|
||||
|
||||
# Try to create batch with multiple vendors (should fail)
|
||||
with self.assertRaises(Exception):
|
||||
self.env["stock.picking.batch"].create(
|
||||
{
|
||||
"picking_ids": [
|
||||
(6, 0, (self.po1.picking_ids + other_po.picking_ids).ids)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_zero_quantity_default_from_wizard(self):
|
||||
"""Test that zero_quantity_default is properly set when creating a batch from wizard"""
|
||||
# Switch to warehouse user like in the parent test
|
||||
self.env = self.env(user=self.warehouse_user)
|
||||
|
||||
pickings = (self.po1 + self.po2).picking_ids
|
||||
self.assertTrue(pickings, "Purchase orders should have created pickings")
|
||||
|
||||
# Create batch through wizard
|
||||
wizard = (
|
||||
self.env["stock.picking.to.batch"]
|
||||
.with_context(active_ids=pickings.ids, active_model="stock.picking")
|
||||
.create(
|
||||
{
|
||||
"mode": "new",
|
||||
"zero_quantity_default": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = wizard.attach_pickings()
|
||||
batch = self.env["stock.picking.batch"].browse(result["res_id"])
|
||||
|
||||
# Check that zero_quantity_default was properly set on the batch
|
||||
self.assertTrue(
|
||||
batch.zero_quantity_default,
|
||||
"zero_quantity_default should be True on the created batch",
|
||||
)
|
||||
|
||||
# Check that all move lines have quantity 0
|
||||
for move_line in batch.move_line_ids:
|
||||
self.assertEqual(
|
||||
move_line.quantity,
|
||||
0.0,
|
||||
f"Move line for {move_line.product_id.name} should have quantity 0",
|
||||
)
|
||||
|
||||
def test_zero_quantity_default_from_picking(self):
|
||||
"""Test that zero_quantity_default works when adding picking to existing batch"""
|
||||
# Switch to warehouse user like in the parent test
|
||||
self.env = self.env(user=self.warehouse_user)
|
||||
|
||||
pickings = (self.po1 + self.po2).picking_ids
|
||||
|
||||
# Create batch directly
|
||||
batch = self.env["stock.picking.batch"].create(
|
||||
{
|
||||
"zero_quantity_default": True,
|
||||
"name": "Multi Vendor Batch",
|
||||
"company_id": cls.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
"picking_ids": [(6, 0, (cls.po1 + cls.po3).picking_ids.ids)],
|
||||
"zero_quantity_default": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Add pickings to batch
|
||||
pickings.write({"batch_id": batch.id})
|
||||
def test_action_create_bill_success(self):
|
||||
"""Test that a bill is successfully created from a batch picking"""
|
||||
# Ensure the batch has no invoices initially
|
||||
self.assertEqual(len(self.batch.invoice_ids), 0)
|
||||
self.assertEqual(self.batch.invoice_count, 0)
|
||||
|
||||
# Confirm the batch to get the move lines set up
|
||||
batch.action_confirm()
|
||||
# Execute the action to create a bill
|
||||
action = self.batch.action_create_bill()
|
||||
|
||||
# Check that all move lines have quantity 0
|
||||
for move_line in batch.move_line_ids:
|
||||
self.assertEqual(
|
||||
move_line.quantity,
|
||||
0.0,
|
||||
f"Move line for {move_line.product_id.name} should have quantity 0",
|
||||
# Verify that an invoice was created
|
||||
self.assertEqual(len(self.batch.invoice_ids), 1)
|
||||
self.assertEqual(self.batch.invoice_count, 1)
|
||||
|
||||
# Verify the action returns the correct view
|
||||
self.assertEqual(action.get("res_id"), self.batch.invoice_ids[-1].id)
|
||||
self.assertEqual(action.get("views")[0][1], "form")
|
||||
|
||||
# Verify the bill content
|
||||
bill = self.batch.invoice_ids[-1]
|
||||
self.assertEqual(bill.partner_id, self.vendor)
|
||||
self.assertEqual(bill.move_type, "in_invoice")
|
||||
self.assertEqual(bill.invoice_date, self.batch.scheduled_date.date())
|
||||
|
||||
# Verify that the bill contains the correct lines
|
||||
expected_products = self.batch.move_line_ids.mapped("product_id")
|
||||
bill_products = bill.invoice_line_ids.mapped("product_id")
|
||||
self.assertEqual(set(bill_products.ids), set(expected_products.ids))
|
||||
|
||||
# Verify the quantities match
|
||||
for line in bill.invoice_line_ids:
|
||||
# Find matching move lines
|
||||
move_lines = self.batch.move_line_ids.filtered(
|
||||
lambda ml: ml.product_id == line.product_id
|
||||
)
|
||||
self.assertEqual(line.quantity, sum(move_lines.mapped("quantity")))
|
||||
|
||||
# Verify price from purchase order line
|
||||
purchase_line = move_lines[0].move_id.purchase_line_id
|
||||
self.assertEqual(line.price_unit, purchase_line.price_unit)
|
||||
|
||||
def test_action_create_bill_no_purchase_orders(self):
|
||||
"""Test that an error is raised when there are no purchase orders"""
|
||||
# Create an empty batch
|
||||
empty_batch = self.env["stock.picking.batch"].create(
|
||||
{
|
||||
"name": "Empty Batch",
|
||||
"company_id": self.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
}
|
||||
)
|
||||
|
||||
# Try to create a bill and expect a ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
empty_batch.action_create_bill()
|
||||
|
||||
def test_action_create_bill_multiple_vendors(self):
|
||||
"""Test that an error is raised when there are multiple vendors"""
|
||||
# Try to create a bill from a batch with multiple vendors
|
||||
with self.assertRaises(ValidationError):
|
||||
self.multi_vendor_batch.action_create_bill()
|
||||
|
||||
def test_action_create_bill_access_rights(self):
|
||||
"""Test access rights for creating bills from batch pickings"""
|
||||
# Test with warehouse user (should have access)
|
||||
self.batch.with_user(self.warehouse_user).action_create_bill()
|
||||
|
||||
# Verify that a bill was created
|
||||
self.assertEqual(len(self.batch.invoice_ids), 1)
|
||||
|
||||
# Create a user without invoice creation rights
|
||||
no_invoice_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "No Invoice User",
|
||||
"login": "no_invoice_user",
|
||||
"email": "no_invoice@test.com",
|
||||
"groups_id": [(6, 0, [self.env.ref("stock.group_stock_user").id])],
|
||||
}
|
||||
)
|
||||
|
||||
# Create a new batch for testing with the limited user
|
||||
new_batch = self.env["stock.picking.batch"].create(
|
||||
{
|
||||
"name": "New Test Batch",
|
||||
"company_id": self.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
"picking_ids": [(6, 0, self.po2.picking_ids.ids)],
|
||||
}
|
||||
)
|
||||
|
||||
# Try to create a bill with a user that doesn't have invoice creation rights
|
||||
with self.assertRaises(AccessError):
|
||||
new_batch.with_user(no_invoice_user).action_create_bill()
|
||||
|
||||
def test_bill_values_calculation(self):
|
||||
"""Test the helper methods that calculate bill values"""
|
||||
# Test _get_bill_values method
|
||||
bill_values = self.batch._get_bill_values()
|
||||
|
||||
self.assertEqual(bill_values["company_id"], self.batch.company_id.id)
|
||||
self.assertEqual(bill_values["partner_id"], self.batch.partner_ids.id)
|
||||
self.assertEqual(bill_values["move_type"], "in_invoice")
|
||||
self.assertEqual(bill_values["invoice_date"], self.batch.scheduled_date)
|
||||
|
||||
# The invoice_origin should contain the purchase order names
|
||||
for po_name in self.batch.purchase_order_ids.mapped("name"):
|
||||
self.assertIn(po_name, bill_values["invoice_origin"])
|
||||
|
||||
# Test currency consistency
|
||||
currency_id = self.batch._get_currency_id()
|
||||
self.assertEqual(currency_id, self.batch.purchase_order_ids[0].currency_id.id)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="id" invisible="1"/>
|
||||
<button name="%(batch_picking_create_one_bill.action_create_batch_bill_wizard)d" string="Créer Facture" type="action" class="btn-primary" context="{'default_batch_id': id}" invisible="state != 'done'"/>
|
||||
<button name="action_create_bill" string="Create Bill" type="object" class="btn-primary" context="{'default_batch_id': id}" invisible="state != 'done'"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button" name="action_view_invoices" type="object" icon="fa-pencil-square-o">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import create_bill_wizard
|
||||
from . import merge_bill_wizard
|
||||
from . import stock_picking_to_batch
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
class CreateBillWizard(models.TransientModel):
|
||||
_name = 'create.bill.wizard'
|
||||
_description = 'Wizard pour créer une facture groupée'
|
||||
|
||||
batch_id = fields.Many2one(
|
||||
'stock.picking.batch',
|
||||
required=True,
|
||||
string='Batch de transferts',
|
||||
default=lambda self: self._context.get('default_batch_id')
|
||||
)
|
||||
|
||||
purchase_order_ids = fields.Many2many(
|
||||
'purchase.order',
|
||||
string='Bons de commande',
|
||||
compute='_compute_purchase_orders',
|
||||
store=True
|
||||
)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Fournisseur',
|
||||
compute='_compute_partner',
|
||||
store=True
|
||||
)
|
||||
|
||||
invoice_ids = fields.Many2many(
|
||||
'account.move',
|
||||
string='Factures temporaires',
|
||||
compute='_compute_invoice_ids',
|
||||
)
|
||||
|
||||
merged_invoice_id = fields.Many2one(
|
||||
'account.move',
|
||||
string='Facture fusionnée'
|
||||
)
|
||||
|
||||
@api.depends('batch_id')
|
||||
def _compute_purchase_orders(self):
|
||||
for wizard in self:
|
||||
wizard.purchase_order_ids = wizard.batch_id.picking_ids.move_ids.purchase_line_id.order_id
|
||||
|
||||
@api.depends('purchase_order_ids')
|
||||
def _compute_partner(self):
|
||||
for wizard in self:
|
||||
partners = wizard.purchase_order_ids.mapped('partner_id')
|
||||
if len(partners) > 1:
|
||||
raise ValidationError(_('Les bons de commande doivent provenir du même fournisseur'))
|
||||
wizard.partner_id = partners and partners[0] or False
|
||||
|
||||
@api.depends('purchase_order_ids')
|
||||
def _compute_invoice_ids(self):
|
||||
for wizard in self:
|
||||
wizard.invoice_ids = self.env['account.move'].search([
|
||||
('id', 'in', wizard.purchase_order_ids.mapped('invoice_ids').ids),
|
||||
('state', '=', 'draft')
|
||||
])
|
||||
|
||||
def _create_invoices_from_pos(self):
|
||||
"""Crée les factures pour chaque PO en utilisant la méthode standard d'Odoo"""
|
||||
created_invoices = self.env['account.move']
|
||||
for po in self.purchase_order_ids:
|
||||
# Vérifie si le PO a des quantités reçues non facturées
|
||||
if not any(line.qty_received > line.qty_invoiced for line in po.order_line):
|
||||
continue
|
||||
|
||||
# Crée la facture en utilisant la méthode standard
|
||||
invoice = po.action_create_invoice()
|
||||
if isinstance(invoice, dict):
|
||||
invoice = self.env['account.move'].browse(invoice.get('res_id'))
|
||||
created_invoices |= invoice
|
||||
|
||||
return created_invoices
|
||||
|
||||
def _merge_invoices(self, invoices):
|
||||
"""Fusionne plusieurs factures en une seule"""
|
||||
if not invoices:
|
||||
return False
|
||||
|
||||
# Crée une nouvelle facture
|
||||
merged_invoice = self.env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': self.partner_id.id,
|
||||
'invoice_date': fields.Date.context_today(self),
|
||||
'invoice_origin': ', '.join(self.purchase_order_ids.mapped('name')),
|
||||
})
|
||||
|
||||
# Regroupe les lignes par produit
|
||||
product_lines = {}
|
||||
for invoice in invoices:
|
||||
for line in invoice.invoice_line_ids:
|
||||
key = (line.product_id.id, line.price_unit, tuple(line.tax_ids.ids))
|
||||
if key not in product_lines:
|
||||
product_lines[key] = {
|
||||
'product_id': line.product_id.id,
|
||||
'name': line.name,
|
||||
'quantity': 0,
|
||||
'price_unit': line.price_unit,
|
||||
'tax_ids': line.tax_ids.ids,
|
||||
'purchase_line_ids': [],
|
||||
}
|
||||
product_lines[key]['quantity'] += line.quantity
|
||||
if line.purchase_line_id:
|
||||
product_lines[key]['purchase_line_ids'].append(line.purchase_line_id.id)
|
||||
|
||||
# Crée les lignes dans la facture fusionnée
|
||||
for line_vals in product_lines.values():
|
||||
# Prépare les valeurs pour la ligne de facture
|
||||
invoice_line_vals = {
|
||||
'move_id': merged_invoice.id,
|
||||
'product_id': line_vals['product_id'],
|
||||
'name': line_vals['name'],
|
||||
'quantity': line_vals['quantity'],
|
||||
'price_unit': line_vals['price_unit'],
|
||||
'tax_ids': [(6, 0, line_vals['tax_ids'])],
|
||||
}
|
||||
self.env['account.move.line'].create(invoice_line_vals)
|
||||
|
||||
# Supprime les factures originales
|
||||
invoices.unlink()
|
||||
|
||||
return merged_invoice
|
||||
|
||||
def action_create_bill(self):
|
||||
self.ensure_one()
|
||||
if not self.purchase_order_ids:
|
||||
raise ValidationError(_('Aucun bon de commande trouvé dans ce batch'))
|
||||
|
||||
# Vérifie que tous les PO ont le même fournisseur
|
||||
partners = self.purchase_order_ids.mapped('partner_id')
|
||||
if len(partners) > 1:
|
||||
raise ValidationError(_(
|
||||
'Les bons de commande sélectionnés ont des fournisseurs différents:\n%s'
|
||||
) % '\n'.join(['- ' + p.name for p in partners]))
|
||||
|
||||
# Crée les factures individuelles
|
||||
invoices = self._create_invoices_from_pos()
|
||||
if not invoices:
|
||||
raise ValidationError(_('Aucune quantité à facturer trouvée dans les bons de commande'))
|
||||
|
||||
# Ouvre le wizard de fusion
|
||||
return {
|
||||
'name': _('Fusion des factures'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'merge.bill.wizard',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_invoice_ids': [(6, 0, invoices.ids)],
|
||||
'default_partner_id': self.partner_id.id,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_create_bill_wizard_form" model="ir.ui.view">
|
||||
<field name="name">create.bill.wizard.form</field>
|
||||
<field name="model">create.bill.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Créer une facture groupée">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="batch_id" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Bons de commande" name="purchase_orders">
|
||||
<field
|
||||
name="purchase_order_ids"
|
||||
readonly="1"
|
||||
widget="many2many"
|
||||
options="{'no_create': True}">
|
||||
</field>
|
||||
</page>
|
||||
<page string="Factures temporaires" name="temp_invoices">
|
||||
<field
|
||||
name="invoice_ids"
|
||||
readonly="1"
|
||||
widget="many2many"
|
||||
options="{'no_create': True}"
|
||||
invisible="not invoice_ids">
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
<group>
|
||||
<field
|
||||
name="merged_invoice_id"
|
||||
readonly="1"
|
||||
invisible="not merged_invoice_id"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_create_bill"
|
||||
string="Créer et Fusionner les Factures"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="merged_invoice_id"/>
|
||||
<button special="cancel"
|
||||
string="Fermer"
|
||||
class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_create_batch_bill_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Créer Facture Groupée</field>
|
||||
<field name="res_model">create.bill.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="stock_picking_batch.model_stock_picking_batch"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class MergeBillWizard(models.TransientModel):
|
||||
_name = "merge.bill.wizard"
|
||||
_description = "Assistant de fusion des factures"
|
||||
|
||||
invoice_ids = fields.Many2many(
|
||||
"account.move", string="Factures à fusionner", required=True
|
||||
)
|
||||
partner_id = fields.Many2one("res.partner", string="Fournisseur", required=True)
|
||||
merge_invoices = fields.Boolean(
|
||||
string="Fusionner les factures",
|
||||
default=True,
|
||||
help="Si coché, les factures seront fusionnées en une seule",
|
||||
)
|
||||
|
||||
def _merge_invoices(self):
|
||||
"""Fusionne plusieurs factures en une seule"""
|
||||
if not self.invoice_ids:
|
||||
return False
|
||||
|
||||
# Collecte les informations des factures existantes
|
||||
invoice_origin = ", ".join(self.invoice_ids.mapped("invoice_origin"))
|
||||
journal = self.env["account.journal"].search(
|
||||
[("type", "=", "purchase")], limit=1
|
||||
)
|
||||
if not journal:
|
||||
raise ValidationError(_("Aucun journal de factures fournisseur trouvé"))
|
||||
|
||||
# Regroupe les lignes par produit
|
||||
product_lines = {}
|
||||
for invoice in self.invoice_ids:
|
||||
for line in invoice.invoice_line_ids:
|
||||
key = (line.product_id.id, line.price_unit, tuple(line.tax_ids.ids))
|
||||
if key not in product_lines:
|
||||
product_lines[key] = {
|
||||
"product_id": line.product_id.id,
|
||||
"name": line.name,
|
||||
"quantity": 0,
|
||||
"price_unit": line.price_unit,
|
||||
"tax_ids": line.tax_ids.ids,
|
||||
"purchase_line_ids": [],
|
||||
}
|
||||
product_lines[key]["quantity"] += line.quantity
|
||||
if line.purchase_line_id:
|
||||
product_lines[key]["purchase_line_ids"].append(
|
||||
line.purchase_line_id.id
|
||||
)
|
||||
|
||||
# Crée les lignes de la nouvelle facture
|
||||
invoice_lines = []
|
||||
for values in product_lines.values():
|
||||
line_vals = {
|
||||
"product_id": values["product_id"],
|
||||
"name": values["name"],
|
||||
"quantity": values["quantity"],
|
||||
"price_unit": values["price_unit"],
|
||||
"tax_ids": [(6, 0, values["tax_ids"])],
|
||||
}
|
||||
if values["purchase_line_ids"]:
|
||||
line_vals["purchase_line_id"] = values["purchase_line_ids"][0]
|
||||
invoice_lines.append((0, 0, line_vals))
|
||||
|
||||
# Supprime les factures originales
|
||||
self.invoice_ids.unlink()
|
||||
|
||||
# Crée la nouvelle facture
|
||||
vals = {
|
||||
"move_type": "in_invoice",
|
||||
"partner_id": self.partner_id.id,
|
||||
"invoice_date": fields.Date.context_today(self),
|
||||
"invoice_origin": invoice_origin,
|
||||
"journal_id": journal.id,
|
||||
"state": "draft",
|
||||
"invoice_line_ids": invoice_lines,
|
||||
}
|
||||
|
||||
# Crée la facture
|
||||
merged_invoice = self.env["account.move"].create(vals)
|
||||
|
||||
return merged_invoice
|
||||
|
||||
def action_process(self):
|
||||
"""Traite les factures selon l'option choisie"""
|
||||
self.ensure_one()
|
||||
|
||||
if self.merge_invoices and len(self.invoice_ids) > 1:
|
||||
# Fusionne les factures et affiche la nouvelle facture
|
||||
merged_invoice = self._merge_invoices()
|
||||
if not merged_invoice:
|
||||
raise ValidationError(_("Erreur lors de la fusion des factures"))
|
||||
invoice = merged_invoice
|
||||
else:
|
||||
invoice = self.invoice_ids[0]
|
||||
|
||||
# Retourne la vue de la facture
|
||||
return {
|
||||
"name": _("Facture fournisseur"),
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"res_id": invoice.id,
|
||||
"view_mode": "form",
|
||||
"target": "current",
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_merge_bill_wizard_form" model="ir.ui.view">
|
||||
<field name="name">merge.bill.wizard.form</field>
|
||||
<field name="model">merge.bill.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Fusion des factures">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="partner_id" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="merge_invoices"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Factures à fusionner"/>
|
||||
<field name="invoice_ids" readonly="1" nolabel="1" options="{'no_create': True}" context="{'tree_view_ref': 'account.view_in_invoice_tree'}"/>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string="Confirmer" name="action_process" type="object" class="btn-primary"/>
|
||||
<button string="Annuler" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue