# -*- coding: utf-8 -*- from odoo.tests import tagged from odoo.tests.common import TransactionCase from hypothesis import given, strategies as st, settings import time @tagged('post_install', '-at_install') class TestExpenseAccountDetermination(TransactionCase): """ Property-based tests for expense account determination logic (COGS). Tests Properties 6, 7, and 8 from the design document. """ @classmethod def setUpClass(cls): super().setUpClass() cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) # Get or create test company cls.company = cls.env.company # Create unique timestamp for account codes timestamp = str(int(time.time() * 1000))[-6:] # Create customer-specific expense account cls.customer_expense_account = cls.env['account.account'].create({ 'name': 'Customer Specific COGS', 'code': f'CUSTEXP{timestamp}', 'account_type': 'expense', }) # Create category expense account cls.category_expense_account = cls.env['account.account'].create({ 'name': 'Category COGS', 'code': f'CATEXP{timestamp}', 'account_type': 'expense', }) # Create income account for invoicing cls.income_account = cls.env['account.account'].create({ 'name': 'Income Account', 'code': f'INC{timestamp}', 'account_type': 'income', }) # Create stock valuation account cls.stock_valuation_account = cls.env['account.account'].create({ 'name': 'Stock Valuation', 'code': f'STVAL{timestamp}', 'account_type': 'asset_current', }) # Create stock input account cls.stock_input_account = cls.env['account.account'].create({ 'name': 'Stock Input', 'code': f'STIN{timestamp}', 'account_type': 'asset_current', }) # Create stock output account cls.stock_output_account = cls.env['account.account'].create({ 'name': 'Stock Output', 'code': f'STOUT{timestamp}', 'account_type': 'asset_current', }) # Create product category with default expense account cls.product_category = cls.env['product.category'].create({ 'name': 'Test Category', 'property_account_expense_categ_id': cls.category_expense_account.id, 'property_account_income_categ_id': cls.income_account.id, 'property_stock_account_input_categ_id': cls.stock_input_account.id, 'property_stock_account_output_categ_id': cls.stock_output_account.id, 'property_stock_valuation_account_id': cls.stock_valuation_account.id, 'property_valuation': 'real_time', }) # Create payment term cls.payment_term = cls.env.ref('account.account_payment_term_immediate') # Get warehouse and stock locations cls.warehouse = cls.env['stock.warehouse'].search([('company_id', '=', cls.company.id)], limit=1) cls.stock_location = cls.warehouse.lot_stock_id cls.customer_location = cls.env.ref('stock.stock_location_customers') @settings(max_examples=10, deadline=None) @given( customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), product_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), quantity=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False), price=st.floats(min_value=10.0, max_value=100.0, allow_nan=False, allow_infinity=False), ) def test_property_6_customer_expense_account_precedence(self, customer_name, product_name, quantity, price): """ **Feature: customer-cogs-expense-account, Property 6: Customer expense account precedence** For any sales order with a customer that has a defined expense account, all COGS journal entries should use the customer's expense account regardless of product category. **Validates: Requirements 2.3, 2.7** """ # Create customer WITH expense account customer = self.env['res.partner'].create({ 'name': customer_name, 'property_account_expense_customer_id': self.customer_expense_account.id, }) # Create storable product with category that has different expense account product = self.env['product.product'].create({ 'name': product_name, 'type': 'consu', # Odoo 18 uses 'consu' for goods 'is_storable': True, # Enable inventory tracking to trigger COGS 'categ_id': self.product_category.id, 'list_price': price, 'standard_price': price * 0.6, # Cost price 'invoice_policy': 'order', }) # Add stock to the product self.env['stock.quant'].create({ 'product_id': product.id, 'location_id': self.stock_location.id, 'quantity': quantity * 2, # Ensure enough stock }) # Create sales order sale_order = self.env['sale.order'].create({ 'partner_id': customer.id, 'partner_invoice_id': customer.id, 'partner_shipping_id': customer.id, 'payment_term_id': self.payment_term.id, 'warehouse_id': self.warehouse.id, 'order_line': [(0, 0, { 'product_id': product.id, 'product_uom_qty': quantity, 'price_unit': price, })], }) # Confirm sales order sale_order.action_confirm() # Process delivery picking = sale_order.picking_ids self.assertTrue(picking, f"Sales order should create a delivery picking for product {product_name}") # Set quantities done and validate for move in picking.move_ids: move.quantity = move.product_uom_qty picking.button_validate() # Get the stock move and check accounting entries stock_move = picking.move_ids.filtered(lambda m: m.product_id == product) self.assertTrue(stock_move, f"Should have stock move for product {product_name}") # Get account move lines created by stock move account_moves = stock_move.account_move_ids if account_moves: # Find COGS expense line expense_lines = account_moves.line_ids.filtered( lambda l: l.account_id.account_type == 'expense' ) if expense_lines: # Verify COGS uses customer expense account (not category account) for expense_line in expense_lines: self.assertEqual( expense_line.account_id.id, self.customer_expense_account.id, f"COGS entry should use customer-specific expense account (not category account) " f"for customer '{customer_name}' and product '{product_name}'. " f"Expected account: {self.customer_expense_account.code}, " f"Got: {expense_line.account_id.code}" ) @settings(max_examples=10, deadline=None) @given( customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), product_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), quantity=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False), price=st.floats(min_value=10.0, max_value=100.0, allow_nan=False, allow_infinity=False), ) def test_property_7_expense_account_fallback(self, customer_name, product_name, quantity, price): """ **Feature: customer-cogs-expense-account, Property 7: Expense account fallback** For any sales order with a customer that has no defined expense account, all COGS journal entries should use the product category's expense account. **Validates: Requirements 2.4** """ # Create customer WITHOUT expense account customer = self.env['res.partner'].create({ 'name': customer_name, # No property_account_expense_customer_id set }) # Create storable product with category expense account product = self.env['product.product'].create({ 'name': product_name, 'type': 'consu', 'is_storable': True, 'categ_id': self.product_category.id, 'list_price': price, 'standard_price': price * 0.6, 'invoice_policy': 'order', }) # Add stock to the product self.env['stock.quant'].create({ 'product_id': product.id, 'location_id': self.stock_location.id, 'quantity': quantity * 2, }) # Create sales order sale_order = self.env['sale.order'].create({ 'partner_id': customer.id, 'partner_invoice_id': customer.id, 'partner_shipping_id': customer.id, 'payment_term_id': self.payment_term.id, 'warehouse_id': self.warehouse.id, 'order_line': [(0, 0, { 'product_id': product.id, 'product_uom_qty': quantity, 'price_unit': price, })], }) # Confirm sales order sale_order.action_confirm() # Process delivery picking = sale_order.picking_ids self.assertTrue(picking, f"Sales order should create a delivery picking for product {product_name}") # Set quantities done and validate for move in picking.move_ids: move.quantity = move.product_uom_qty picking.button_validate() # Get the stock move and check accounting entries stock_move = picking.move_ids.filtered(lambda m: m.product_id == product) self.assertTrue(stock_move, f"Should have stock move for product {product_name}") # Get account move lines created by stock move account_moves = stock_move.account_move_ids if account_moves: # Find COGS expense line expense_lines = account_moves.line_ids.filtered( lambda l: l.account_id.account_type == 'expense' ) if expense_lines: # Verify COGS uses category expense account (fallback) for expense_line in expense_lines: self.assertEqual( expense_line.account_id.id, self.category_expense_account.id, f"COGS entry should fallback to category expense account when customer has no custom account. " f"Customer: '{customer_name}', Product: '{product_name}'. " f"Expected account: {self.category_expense_account.code}, " f"Got: {expense_line.account_id.code}" ) @settings(max_examples=10, deadline=None) @given( customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), product_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), has_customer_account=st.booleans(), quantity=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False), price=st.floats(min_value=10.0, max_value=100.0, allow_nan=False, allow_infinity=False), ) def test_property_8_expense_account_determination_consistency(self, customer_name, product_name, has_customer_account, quantity, price): """ **Feature: customer-cogs-expense-account, Property 8: Account determination consistency** For any sales order, the account determination logic should check customer-specific expense accounts before product category expense accounts. **Validates: Requirements 2.5, 2.8** """ # Create customer with or without expense account based on random boolean customer_vals = {'name': customer_name} if has_customer_account: customer_vals['property_account_expense_customer_id'] = self.customer_expense_account.id customer = self.env['res.partner'].create(customer_vals) # Create storable product with category expense account product = self.env['product.product'].create({ 'name': product_name, 'type': 'consu', 'is_storable': True, 'categ_id': self.product_category.id, 'list_price': price, 'standard_price': price * 0.6, 'invoice_policy': 'order', }) # Add stock to the product self.env['stock.quant'].create({ 'product_id': product.id, 'location_id': self.stock_location.id, 'quantity': quantity * 2, }) # Create sales order sale_order = self.env['sale.order'].create({ 'partner_id': customer.id, 'partner_invoice_id': customer.id, 'partner_shipping_id': customer.id, 'payment_term_id': self.payment_term.id, 'warehouse_id': self.warehouse.id, 'order_line': [(0, 0, { 'product_id': product.id, 'product_uom_qty': quantity, 'price_unit': price, })], }) # Confirm sales order sale_order.action_confirm() # Process delivery picking = sale_order.picking_ids self.assertTrue(picking, f"Sales order should create a delivery picking for product {product_name}") # Set quantities done and validate for move in picking.move_ids: move.quantity = move.product_uom_qty picking.button_validate() # Get the stock move and check accounting entries stock_move = picking.move_ids.filtered(lambda m: m.product_id == product) self.assertTrue(stock_move, f"Should have stock move for product {product_name}") # Get account move lines created by stock move account_moves = stock_move.account_move_ids if account_moves: # Find COGS expense line expense_lines = account_moves.line_ids.filtered( lambda l: l.account_id.account_type == 'expense' ) if expense_lines: # Determine expected account based on precedence if has_customer_account: expected_account = self.customer_expense_account else: expected_account = self.category_expense_account # Verify COGS uses correct account based on precedence for expense_line in expense_lines: self.assertEqual( expense_line.account_id.id, expected_account.id, f"Account determination should follow precedence: customer first, then category. " f"Customer: '{customer_name}', Has custom account: {has_customer_account}, " f"Product: '{product_name}'. " f"Expected account: {expected_account.code}, " f"Got: {expense_line.account_id.code}" ) @settings(max_examples=10, deadline=None) @given( customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), num_lines=st.integers(min_value=2, max_value=4), ) def test_property_6_multiple_lines_use_customer_expense_account(self, customer_name, num_lines): """ **Feature: customer-cogs-expense-account, Property 6: Customer expense account precedence** For any sales order with multiple lines and a customer that has a defined expense account, ALL COGS journal entries should use the customer's expense account. **Validates: Requirements 2.7** """ # Create customer WITH expense account customer = self.env['res.partner'].create({ 'name': customer_name, 'property_account_expense_customer_id': self.customer_expense_account.id, }) # Create multiple products products = [] order_lines = [] for i in range(num_lines): product = self.env['product.product'].create({ 'name': f'Product {i}', 'type': 'consu', 'is_storable': True, 'categ_id': self.product_category.id, 'list_price': 50.0 * (i + 1), 'standard_price': 30.0 * (i + 1), 'invoice_policy': 'order', }) products.append(product) # Add stock self.env['stock.quant'].create({ 'product_id': product.id, 'location_id': self.stock_location.id, 'quantity': 20.0, }) order_lines.append((0, 0, { 'product_id': product.id, 'product_uom_qty': 2.0, 'price_unit': 50.0 * (i + 1), })) # Create sales order with multiple lines sale_order = self.env['sale.order'].create({ 'partner_id': customer.id, 'partner_invoice_id': customer.id, 'partner_shipping_id': customer.id, 'payment_term_id': self.payment_term.id, 'warehouse_id': self.warehouse.id, 'order_line': order_lines, }) # Confirm sales order sale_order.action_confirm() # Process delivery picking = sale_order.picking_ids self.assertTrue(picking, f"Sales order should create a delivery picking") # Set quantities done and validate for move in picking.move_ids: move.quantity = move.product_uom_qty picking.button_validate() # Collect all expense lines from all stock moves all_expense_lines = self.env['account.move.line'] for move in picking.move_ids: if move.account_move_ids: expense_lines = move.account_move_ids.line_ids.filtered( lambda l: l.account_id.account_type == 'expense' ) all_expense_lines |= expense_lines # Verify ALL COGS lines use customer expense account if all_expense_lines: for line in all_expense_lines: self.assertEqual( line.account_id.id, self.customer_expense_account.id, f"ALL COGS lines should use customer-specific expense account. " f"Customer: '{customer_name}'. " f"Expected account: {self.customer_expense_account.code}, " f"Got: {line.account_id.code}" )