# 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)