430 lines
16 KiB
Python
Executable File
430 lines
16 KiB
Python
Executable File
# Copyright 2019-21 ForgeFlow S.L.
|
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
|
|
|
from datetime import datetime
|
|
|
|
from odoo import exceptions, fields
|
|
from odoo.tests import Form, SingleTransactionCase
|
|
|
|
|
|
class TestPurchaseUnreconciled(SingleTransactionCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
|
|
cls.po_obj = cls.env["purchase.order"]
|
|
cls.product_obj = cls.env["product.product"]
|
|
cls.category_obj = cls.env["product.category"]
|
|
cls.partner_obj = cls.env["res.partner"]
|
|
cls.acc_obj = cls.env["account.account"]
|
|
cls.account_move_obj = cls.env["account.move"]
|
|
cls.company = cls.env.ref("base.main_company")
|
|
cls.company.anglo_saxon_accounting = True
|
|
expense_type = "expense"
|
|
equity_type = "equity"
|
|
asset_type = "asset_current"
|
|
# Create partner:
|
|
cls.partner = cls.partner_obj.create({"name": "Test Vendor"})
|
|
# Create product that uses a reconcilable stock input account.
|
|
cls.account = cls.acc_obj.create(
|
|
{
|
|
"name": "Test stock input account",
|
|
"code": 9999,
|
|
"account_type": asset_type,
|
|
"reconcile": True,
|
|
"company_ids": [(6, 0, [cls.company.id])],
|
|
}
|
|
)
|
|
cls.writeoff_acc = cls.acc_obj.create(
|
|
{
|
|
"name": "Write-offf account",
|
|
"code": 8888,
|
|
"account_type": expense_type,
|
|
"reconcile": True,
|
|
"company_ids": [(6, 0, [cls.company.id])],
|
|
}
|
|
)
|
|
cls.stock_journal = cls.env["account.journal"].create(
|
|
{"name": "Stock Journal", "code": "STJTEST", "type": "general"}
|
|
)
|
|
# Create account for Goods Received Not Invoiced
|
|
name = "Goods Received Not Invoiced"
|
|
code = "grni"
|
|
acc_type = equity_type
|
|
cls.account_grni = cls._create_account(
|
|
acc_type, name, code, cls.company, reconcile=True
|
|
)
|
|
# Create account for Cost of Goods Sold
|
|
name = "Cost of Goods Sold"
|
|
code = "cogs"
|
|
acc_type = expense_type
|
|
cls.account_cogs = cls._create_account(acc_type, name, code, cls.company)
|
|
# Create account for Goods Delivered Not Invoiced
|
|
name = "Goods Delivered Not Invoiced"
|
|
code = "gdni"
|
|
acc_type = expense_type
|
|
cls.account_gdni = cls._create_account(
|
|
acc_type, name, code, cls.company, reconcile=True
|
|
)
|
|
# Create account for Inventory
|
|
name = "Inventory"
|
|
code = "inventory"
|
|
acc_type = asset_type
|
|
cls.account_inventory = cls._create_account(acc_type, name, code, cls.company)
|
|
cls.product_categ = cls.category_obj.create(
|
|
{
|
|
"name": "Test Category",
|
|
"property_cost_method": "standard",
|
|
"property_stock_valuation_account_id": cls.account_inventory.id,
|
|
"property_stock_account_input_categ_id": cls.account_grni.id,
|
|
"property_account_expense_categ_id": cls.account_cogs.id,
|
|
"property_stock_account_output_categ_id": cls.account_gdni.id,
|
|
"property_valuation": "real_time",
|
|
"property_stock_journal": cls.stock_journal.id,
|
|
}
|
|
)
|
|
cls.product_to_reconcile = cls.product_obj.create(
|
|
{
|
|
"name": "Purchased Product (To reconcile)",
|
|
"type": "consu",
|
|
"is_storable": True,
|
|
"standard_price": 100.0,
|
|
"categ_id": cls.product_categ.id,
|
|
}
|
|
)
|
|
cls.product_to_reconcile2 = cls.product_obj.create(
|
|
{
|
|
"name": "Purchased Product 2 (To reconcile)",
|
|
"type": "consu",
|
|
"is_storable": True,
|
|
"standard_price": 100.0,
|
|
"categ_id": cls.product_categ.id,
|
|
}
|
|
)
|
|
# Create PO's:
|
|
cls.po = cls.po_obj.create(
|
|
{
|
|
"partner_id": cls.partner.id,
|
|
"order_line": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"product_id": cls.product_to_reconcile.id,
|
|
"name": cls.product_to_reconcile.name,
|
|
"product_qty": 5.0,
|
|
"price_unit": 100.0,
|
|
"product_uom": cls.product_to_reconcile.uom_id.id,
|
|
"date_planned": fields.Datetime.now(),
|
|
},
|
|
)
|
|
],
|
|
}
|
|
)
|
|
cls.po_2 = cls.po_obj.create(
|
|
{
|
|
"partner_id": cls.partner.id,
|
|
"order_line": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"product_id": cls.product_to_reconcile.id,
|
|
"name": cls.product_to_reconcile.name,
|
|
"product_qty": 5.0,
|
|
"price_unit": 100.0,
|
|
"product_uom": cls.product_to_reconcile.uom_id.id,
|
|
"date_planned": fields.Datetime.now(),
|
|
},
|
|
)
|
|
],
|
|
}
|
|
)
|
|
# company settings for automated valuation
|
|
cls.company.purchase_lock_auto_reconcile = True
|
|
cls.company.purchase_reconcile_account_id = cls.writeoff_acc
|
|
cls.company.purchase_reconcile_journal_id = cls.stock_journal
|
|
|
|
@classmethod
|
|
def _create_account(cls, acc_type, name, code, company, reconcile=False):
|
|
"""Create an account."""
|
|
account = cls.acc_obj.create(
|
|
{
|
|
"name": name,
|
|
"code": code,
|
|
"account_type": acc_type,
|
|
"company_ids": [(6, 0, [company.id])],
|
|
"reconcile": reconcile,
|
|
}
|
|
)
|
|
return account
|
|
|
|
def _create_delivery(
|
|
self,
|
|
product,
|
|
qty,
|
|
):
|
|
return self.env["stock.picking"].create(
|
|
{
|
|
"name": self.product_to_reconcile.name,
|
|
"partner_id": self.partner.id,
|
|
"picking_type_id": self.env.ref("stock.picking_type_out").id,
|
|
"location_id": self.env.ref("stock.stock_location_stock").id,
|
|
"location_dest_id": self.env.ref("stock.stock_location_customers").id,
|
|
"move_ids": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"name": self.product_to_reconcile.name,
|
|
"product_id": self.product_to_reconcile.id,
|
|
"product_uom": self.product_to_reconcile.uom_id.id,
|
|
"product_uom_qty": qty,
|
|
"location_id": self.env.ref(
|
|
"stock.stock_location_stock"
|
|
).id,
|
|
"location_dest_id": self.env.ref(
|
|
"stock.stock_location_customers"
|
|
).id,
|
|
"procure_method": "make_to_stock",
|
|
},
|
|
)
|
|
],
|
|
}
|
|
)
|
|
|
|
def _do_picking(self, picking, date):
|
|
"""Do picking with only one move on the given date."""
|
|
picking.action_confirm()
|
|
picking.action_assign()
|
|
for move in picking.move_ids:
|
|
move.quantity = move.product_uom_qty
|
|
move.date = date
|
|
picking.button_validate()
|
|
|
|
def test_01_nothing_to_reconcile(self):
|
|
po = self.po
|
|
self.assertEqual(po.state, "draft")
|
|
po.button_confirm()
|
|
self._do_picking(po.picking_ids, fields.Datetime.now())
|
|
self.assertTrue(po.unreconciled)
|
|
# Invoice created and validated:
|
|
po.action_create_invoice()
|
|
invoice_ids = po.invoice_ids.filtered(lambda i: i.move_type == "in_invoice")
|
|
invoice_ids.invoice_date = datetime.now()
|
|
invoice_ids.action_post()
|
|
self.assertEqual(po.state, "purchase")
|
|
# odoo does it automatically
|
|
po._compute_unreconciled()
|
|
self.assertFalse(po.unreconciled)
|
|
|
|
def test_03_search_unreconciled(self):
|
|
"""Test searching unreconciled PO's."""
|
|
po = self.po_2
|
|
po.button_confirm()
|
|
self._do_picking(po.picking_ids, fields.Datetime.now())
|
|
res = self.po_obj.search([("unreconciled", "=", True)])
|
|
po._compute_unreconciled()
|
|
self.assertIn(po, res)
|
|
self.assertNotIn(self.po, res)
|
|
# Test value error:
|
|
with self.assertRaises(ValueError):
|
|
self.po_obj.search([("unreconciled", "=", "true")])
|
|
|
|
def test_04_action_reconcile(self):
|
|
"""Test reconcile."""
|
|
# Invoice created and validated:
|
|
po = self.po_2
|
|
self.assertTrue(po.unreconciled)
|
|
po.action_create_invoice()
|
|
invoice_form = Form(
|
|
po.invoice_ids.filtered(lambda i: i.move_type == "in_invoice")[0]
|
|
)
|
|
# v14 reconciles automatically so here we force discrepancy
|
|
# with invoice_form.edit(0) as inv_form:
|
|
invoice_form.invoice_date = datetime.now()
|
|
with invoice_form.invoice_line_ids.edit(0) as line_form:
|
|
line_form.price_unit = 99
|
|
invoice = invoice_form.save()
|
|
invoice.action_post()
|
|
self.assertTrue(po.unreconciled)
|
|
po.action_reconcile()
|
|
po._compute_unreconciled()
|
|
self.assertFalse(po.unreconciled)
|
|
|
|
def test_05_button_done_reconcile(self):
|
|
"""Test auto reconcile when locking po."""
|
|
po = self.po_2.copy()
|
|
po.company_id.purchase_reconcile_account_id = self.writeoff_acc
|
|
po.button_confirm()
|
|
self._do_picking(po.picking_ids, fields.Datetime.now())
|
|
# Invoice created and validated:
|
|
# Odoo reconciles automatically so here we force discrepancy
|
|
po.action_create_invoice()
|
|
invoice_form = Form(
|
|
po.invoice_ids.filtered(lambda i: i.move_type == "in_invoice")[0]
|
|
)
|
|
invoice_form.invoice_date = datetime.now()
|
|
with invoice_form.invoice_line_ids.edit(0) as line_form:
|
|
line_form.price_unit = 99
|
|
invoice = invoice_form.save()
|
|
invoice.action_post()
|
|
self.assertTrue(po.unreconciled)
|
|
# check error if raised if not write-off account
|
|
with self.assertRaises(exceptions.ValidationError):
|
|
self.company.purchase_reconcile_account_id = False
|
|
po.button_done()
|
|
# restore the write off account
|
|
self.company.purchase_reconcile_account_id = self.writeoff_acc
|
|
po.button_done()
|
|
po._compute_unreconciled()
|
|
self.assertFalse(po.unreconciled)
|
|
|
|
def test_06_dropship_not_reconcile_sale_journal_items(self):
|
|
"""
|
|
Create a fake dropship and lock the PO before receiving the customer
|
|
invoice. The PO should not close the stock interim output account
|
|
"""
|
|
# to create the fake dropship we create a delivery and attach the
|
|
# journals to the purchase order craeted later
|
|
self.env["stock.quant"].create(
|
|
{
|
|
"product_id": self.product_to_reconcile.id,
|
|
"location_id": self.env.ref("stock.stock_location_stock").id,
|
|
"quantity": 1.0,
|
|
}
|
|
)
|
|
delivery = self._create_delivery(self.product_to_reconcile, 1)
|
|
self._do_picking(delivery, fields.Datetime.now())
|
|
# We create the PO now and receive it
|
|
po = self.po_2.copy()
|
|
po.button_confirm()
|
|
self._do_picking(po.picking_ids, fields.Datetime.now())
|
|
self.assertTrue(po.unreconciled)
|
|
# as long stock_dropshipping is not dependency, I force the PO to be in
|
|
# the journal items of the delivery
|
|
delivery_name = delivery.name
|
|
delivery_ji = self.env["account.move.line"].search(
|
|
[("move_id.ref", "=", delivery_name)]
|
|
)
|
|
delivery_ji.write(
|
|
{"purchase_line_id": po.order_line[0], "purchase_order_id": po.id}
|
|
)
|
|
# then I lock the po to force reconciliation
|
|
po.button_done()
|
|
po._compute_unreconciled()
|
|
self.assertFalse(po.unreconciled)
|
|
# the PO is reconciled and the stock interim deliverd account is not
|
|
# reconciled yet
|
|
for jii in delivery_ji:
|
|
self.assertFalse(jii.reconciled)
|
|
|
|
def test_07_multicompany(self):
|
|
"""
|
|
Force the company in the vendor bill to be wrong. The system will
|
|
write-off the journals for the shipment because those are the only ones
|
|
with the correct company
|
|
"""
|
|
po = self.po.copy()
|
|
po.button_confirm()
|
|
self._do_picking(po.picking_ids, fields.Datetime.now())
|
|
# Invoice created and validated:
|
|
f = Form(self.account_move_obj.with_context(default_move_type="in_invoice"))
|
|
f.partner_id = po.partner_id
|
|
f.invoice_date = fields.Date().today()
|
|
f.purchase_vendor_bill_id = self.env["purchase.bill.union"].browse(-po.id)
|
|
invoice = f.save()
|
|
chicago_journal = self.env["account.journal"].create(
|
|
{
|
|
"name": "chicago",
|
|
"code": "ref",
|
|
"type": "purchase",
|
|
"company_id": self.ref("stock.res_company_1"),
|
|
}
|
|
)
|
|
invoice.write(
|
|
{
|
|
"name": "/",
|
|
}
|
|
)
|
|
invoice.write(
|
|
{
|
|
"company_id": self.ref("stock.res_company_1"),
|
|
"journal_id": chicago_journal.id,
|
|
}
|
|
)
|
|
invoice.action_post()
|
|
self.assertEqual(po.state, "purchase")
|
|
# The bill is wrong so this is unreconciled
|
|
self.assertTrue(po.unreconciled)
|
|
po.button_done()
|
|
po._compute_unreconciled()
|
|
self.assertFalse(po.unreconciled)
|
|
# we check all the journals for the po have the same company
|
|
ji = self.env["account.move.line"].search(
|
|
[("purchase_order_id", "=", po.id), ("move_id", "!=", invoice.id)]
|
|
)
|
|
self.assertEqual(po.company_id, ji.mapped("company_id"))
|
|
|
|
def test_08_reconcile_by_product(self):
|
|
"""
|
|
Create a write-off by product
|
|
"""
|
|
po = self.po.copy()
|
|
po.write(
|
|
{
|
|
"order_line": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"product_id": self.product_to_reconcile2.id,
|
|
"name": self.product_to_reconcile2.name,
|
|
"product_qty": 5.0,
|
|
"price_unit": 100.0,
|
|
"product_uom": self.product_to_reconcile.uom_id.id,
|
|
"date_planned": fields.Datetime.now(),
|
|
},
|
|
)
|
|
],
|
|
}
|
|
)
|
|
po.button_confirm()
|
|
self._do_picking(po.picking_ids, fields.Datetime.now())
|
|
# Invoice created and validated:
|
|
f = Form(self.account_move_obj.with_context(default_move_type="in_invoice"))
|
|
f.partner_id = po.partner_id
|
|
f.invoice_date = fields.Date().today()
|
|
f.purchase_vendor_bill_id = self.env["purchase.bill.union"].browse(-po.id)
|
|
invoice = f.save()
|
|
# force discrepancies
|
|
with f.invoice_line_ids.edit(0) as line_form:
|
|
line_form.price_unit = 99
|
|
with f.invoice_line_ids.edit(0) as line_form:
|
|
line_form.price_unit = 99
|
|
invoice = f.save()
|
|
invoice._post()
|
|
# The bill is different price so this is unreconciled
|
|
po._compute_unreconciled()
|
|
self.assertTrue(po.unreconciled)
|
|
po.button_done()
|
|
po._compute_unreconciled()
|
|
self.assertFalse(po.unreconciled)
|
|
# we check all the journals are balanced by product
|
|
ji_p1 = self.env["account.move.line"].search(
|
|
[
|
|
("purchase_order_id", "=", po.id),
|
|
("product_id", "=", self.product_to_reconcile.id),
|
|
("account_id", "=", self.account_grni.id),
|
|
]
|
|
)
|
|
ji_p2 = self.env["account.move.line"].search(
|
|
[
|
|
("purchase_order_id", "=", po.id),
|
|
("product_id", "=", self.product_to_reconcile2.id),
|
|
("account_id", "=", self.account_grni.id),
|
|
]
|
|
)
|
|
self.assertEqual(sum(ji_p1.mapped("balance")), 0.0)
|
|
self.assertEqual(sum(ji_p2.mapped("balance")), 0.0)
|