464 lines
19 KiB
Python
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}"
|
|
)
|