customer_cogs_expense_account/tests/test_expense_account_determination.py
2025-11-25 21:43:35 +07:00

464 lines
19 KiB
Python

# -*- 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}"
)