10149 lines
393 KiB
Python
10149 lines
393 KiB
Python
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
|
# this repository contains the full copyright notices and license terms.
|
|
|
|
import datetime
|
|
import zipfile
|
|
from pathlib import Path
|
|
from contextlib import nullcontext
|
|
from decimal import Decimal
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
from unittest.mock import ANY, Mock, call, patch
|
|
from xml.etree import ElementTree
|
|
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Eval, PYSONDecoder
|
|
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
|
|
from trytond.exceptions import UserError, UserWarning
|
|
from trytond.transaction import Transaction
|
|
from trytond.modules.purchase import purchase as base_purchase_module
|
|
from trytond.modules.purchase_trade import valuation as valuation_module
|
|
from trytond.modules.purchase_trade import lot as lot_module
|
|
from trytond.modules.purchase_trade import purchase as purchase_module
|
|
from trytond.modules.purchase_trade import sale as sale_module
|
|
from trytond.modules.purchase_trade import stock as stock_module
|
|
from trytond.modules.purchase_trade import fee as fee_module
|
|
from trytond.modules.purchase_trade import pricing as pricing_module
|
|
from trytond.modules.purchase_trade import duplicate as duplicate_module
|
|
from trytond.modules.purchase_trade import coffee as coffee_module
|
|
from trytond.modules.purchase_trade import company_defaults
|
|
from trytond.modules.purchase_trade.service import ContractFactory
|
|
|
|
|
|
class PurchaseTradeTestCase(ModuleTestCase):
|
|
'Test purchase_trade module'
|
|
module = 'purchase_trade'
|
|
|
|
def test_coffee_cupping_result_score_sums_configured_criteria(self):
|
|
'coffee cupping result score sums criteria and subtracts defects'
|
|
score = SimpleNamespace(subtract_from_score=False)
|
|
defect = SimpleNamespace(subtract_from_score=True)
|
|
result = SimpleNamespace(lines=[
|
|
SimpleNamespace(score=Decimal('8.00'), criterion=score),
|
|
SimpleNamespace(score=Decimal('8.25'), criterion=score),
|
|
SimpleNamespace(score=Decimal('2.00'), criterion=defect),
|
|
])
|
|
|
|
self.assertEqual(
|
|
coffee_module.CoffeeCuppingResult.get_total_score(result),
|
|
Decimal('14.25'))
|
|
|
|
def test_coffee_cupping_session_counts_requested_cups(self):
|
|
'coffee cupping session capacity is based on sample cups'
|
|
session = SimpleNamespace(cups_per_sample=3)
|
|
lines = [
|
|
SimpleNamespace(cups_per_sample=None, session=session),
|
|
SimpleNamespace(cups_per_sample=4, session=session),
|
|
]
|
|
|
|
self.assertEqual(
|
|
coffee_module.CoffeeCuppingSession._total_requested_cups(lines),
|
|
7)
|
|
|
|
def test_coffee_cupping_session_orders_arabica_before_robusta(self):
|
|
'coffee cupping session tasting order keeps robusta last'
|
|
arabica = SimpleNamespace(
|
|
id=2,
|
|
reference='A',
|
|
get_public_origin=lambda: 'Colombia',
|
|
get_public_coffee_type=lambda: 'arabica')
|
|
robusta = SimpleNamespace(
|
|
id=1,
|
|
reference='R',
|
|
get_public_origin=lambda: 'Vietnam',
|
|
get_public_coffee_type=lambda: 'robusta')
|
|
lines = [
|
|
SimpleNamespace(sample=robusta),
|
|
SimpleNamespace(sample=arabica),
|
|
]
|
|
|
|
ordered = sorted(
|
|
lines, key=coffee_module.CoffeeCuppingSession._line_sort_key)
|
|
|
|
self.assertEqual([line.sample.reference for line in ordered], ['A', 'R'])
|
|
|
|
def test_coffee_lab_analysis_compliance_uses_line_targets(self):
|
|
'coffee lab analysis compliance checks sample line targets'
|
|
line = SimpleNamespace(
|
|
coffee_moisture_max=Decimal('12.50'),
|
|
coffee_defect_max=8,
|
|
coffee_cup_score_min=Decimal('82.00'))
|
|
sample = SimpleNamespace(_coffee_line=lambda: line)
|
|
analysis = SimpleNamespace(
|
|
state='received',
|
|
sample=sample,
|
|
moisture=Decimal('12.00'),
|
|
defect_count=6,
|
|
cup_score=Decimal('84.00'))
|
|
|
|
self.assertTrue(
|
|
coffee_module.CoffeeLabAnalysis.get_compliant(analysis))
|
|
|
|
def test_coffee_sample_quality_recommends_reject_on_failed_targets(self):
|
|
'coffee sample quality recommendation rejects failed target values'
|
|
line = SimpleNamespace(
|
|
coffee_moisture_max=Decimal('12.50'),
|
|
coffee_defect_max=8,
|
|
coffee_cup_score_min=Decimal('82.00'))
|
|
sample = SimpleNamespace(
|
|
purchase_line=line,
|
|
sale_line=None,
|
|
moisture=Decimal('13.00'),
|
|
defect_count=6,
|
|
lab_analyses=[],
|
|
cupping_lines=[],
|
|
get_cupping_average_score=lambda: Decimal('84.00'),
|
|
_coffee_line=lambda: line,
|
|
_latest_lab_analysis=lambda: None,
|
|
_quality_values=lambda: {
|
|
'moisture': Decimal('13.00'),
|
|
'defect_count': 6,
|
|
'cupping_score': Decimal('84.00'),
|
|
})
|
|
sample.get_quality_status = (
|
|
lambda: coffee_module.CoffeeSample.get_quality_status(sample))
|
|
|
|
self.assertEqual(
|
|
coffee_module.CoffeeSample.get_quality_status(sample),
|
|
'fail')
|
|
self.assertEqual(
|
|
coffee_module.CoffeeSample.get_recommended_decision(sample),
|
|
'reject')
|
|
|
|
def test_coffee_sample_default_expiry_date_uses_shelf_life(self):
|
|
'coffee sample expiry date defaults from request date and shelf life'
|
|
values = {
|
|
'requested_date': datetime.date(2026, 6, 29),
|
|
'shelf_life_days': 30,
|
|
}
|
|
|
|
coffee_module.CoffeeSample._set_default_expiry_date(
|
|
values, force=True)
|
|
|
|
self.assertEqual(
|
|
values['expiry_date'], datetime.date(2026, 7, 29))
|
|
|
|
@with_transaction()
|
|
def test_purchase_line_coffee_quality_status_uses_samples(self):
|
|
'purchase line coffee quality status follows linked samples'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = PurchaseLine()
|
|
line.coffee_samples = [
|
|
Mock(state='approved', get_is_expired=Mock(return_value=False))]
|
|
|
|
self.assertEqual(
|
|
line.get_coffee_quality_status(), 'approved')
|
|
|
|
def test_itsa_book_year_suffix_uses_april_fiscal_start(self):
|
|
'ITSA book year changes on April 1st'
|
|
self.assertEqual(
|
|
company_defaults._book_year_suffix(datetime.date(2026, 3, 31)),
|
|
'25')
|
|
self.assertEqual(
|
|
company_defaults._book_year_suffix(datetime.date(2026, 4, 1)),
|
|
'26')
|
|
|
|
@with_transaction()
|
|
def test_purchase_list_fields_use_first_trade_line(self):
|
|
'purchase list helper fields use the first real purchase line'
|
|
Purchase = Pool().get('purchase.purchase')
|
|
|
|
product = Mock(id=10)
|
|
period = Mock(id=20)
|
|
comment_line = Mock(type='comment')
|
|
trade_line = Mock(
|
|
type='line',
|
|
product=product,
|
|
price_type='basis',
|
|
del_period=period,
|
|
from_del=datetime.date(2026, 7, 1),
|
|
to_del=datetime.date(2026, 7, 31),
|
|
)
|
|
purchase = Purchase()
|
|
purchase.lines = [comment_line, trade_line]
|
|
|
|
self.assertEqual(purchase.get_first_line_product(None), 10)
|
|
self.assertEqual(purchase.get_first_line_price_type(None), 'basis')
|
|
self.assertEqual(purchase.get_first_line_del_period(None), 20)
|
|
self.assertEqual(
|
|
purchase.get_first_line_from_del(None),
|
|
datetime.date(2026, 7, 1))
|
|
self.assertEqual(
|
|
purchase.get_first_line_to_del(None),
|
|
datetime.date(2026, 7, 31))
|
|
|
|
@with_transaction()
|
|
def test_get_totals_without_rows(self):
|
|
'get_totals returns zeros when the query has no row'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
cursor = Mock()
|
|
cursor.fetchone.return_value = None
|
|
connection = Mock()
|
|
connection.cursor.return_value = cursor
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.valuation.Transaction'
|
|
) as Transaction, patch.object(
|
|
Valuation, '__table__', return_value='valuation_valuation'):
|
|
Transaction.return_value.connection = connection
|
|
|
|
self.assertEqual(
|
|
Valuation.get_totals(), (Decimal(0), Decimal(0)))
|
|
|
|
@with_transaction()
|
|
def test_get_totals_without_previous_total(self):
|
|
'get_totals converts null variation to zero'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
cursor = Mock()
|
|
cursor.fetchone.return_value = (Decimal('42.50'), None)
|
|
connection = Mock()
|
|
connection.cursor.return_value = cursor
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.valuation.Transaction'
|
|
) as Transaction, patch.object(
|
|
Valuation, '__table__', return_value='valuation_valuation'):
|
|
Transaction.return_value.connection = connection
|
|
|
|
self.assertEqual(
|
|
Valuation.get_totals(), (Decimal('42.50'), Decimal(0)))
|
|
|
|
def test_lotqt_plan_to_transport_splits_source_quantity(self):
|
|
'planning transport creates an isolated lot.qt split'
|
|
LotQt = lot_module.LotQt
|
|
source = Mock(
|
|
lot_p=Mock(id=1),
|
|
lot_s=Mock(id=2),
|
|
lot_quantity=Decimal('500'),
|
|
lot_unit=Mock(id=3),
|
|
lot_av='available',
|
|
lot_status='forecast',
|
|
lot_visible=True,
|
|
)
|
|
source.has_shipment.return_value = False
|
|
source.is_planned_transport.return_value = False
|
|
values = {
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
'planned_transport_type': 'truck',
|
|
'planned_from_date': datetime.date(2026, 7, 1),
|
|
'planned_to_date': datetime.date(2026, 7, 5),
|
|
'planned_note': 'Early planning',
|
|
}
|
|
|
|
with patch.object(LotQt, 'save') as save, patch.object(
|
|
LotQt, 'create') as create:
|
|
LotQt.plan_to_transport(source, Decimal('125'), values)
|
|
|
|
self.assertEqual(source.lot_quantity, Decimal('375.00000'))
|
|
save.assert_called_once_with([source])
|
|
create.assert_called_once_with([{
|
|
'lot_p': 1,
|
|
'lot_s': 2,
|
|
'lot_quantity': Decimal('125.00000'),
|
|
'lot_unit': 3,
|
|
'lot_av': 'available',
|
|
'lot_status': 'forecast',
|
|
'lot_visible': True,
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
'planned_transport_type': 'truck',
|
|
'planned_from_date': datetime.date(2026, 7, 1),
|
|
'planned_to_date': datetime.date(2026, 7, 5),
|
|
'planned_note': 'Early planning',
|
|
}])
|
|
|
|
def test_lotqt_plan_to_transport_preserves_sale_only_sign(self):
|
|
'planning a sale-only lot.qt keeps the negative quantity direction'
|
|
LotQt = lot_module.LotQt
|
|
source = Mock(
|
|
lot_p=None,
|
|
lot_s=Mock(id=2),
|
|
lot_quantity=Decimal('-500'),
|
|
lot_unit=Mock(id=3),
|
|
lot_av='available',
|
|
lot_status='forecast',
|
|
lot_visible=True,
|
|
)
|
|
source.has_shipment.return_value = False
|
|
source.is_planned_transport.return_value = False
|
|
|
|
with patch.object(LotQt, 'save'), patch.object(
|
|
LotQt, 'create') as create:
|
|
LotQt.plan_to_transport(
|
|
source, Decimal('125'),
|
|
{'planned_transport_type': 'vessel'})
|
|
|
|
self.assertEqual(source.lot_quantity, Decimal('-375.00000'))
|
|
self.assertEqual(
|
|
create.call_args[0][0][0]['lot_quantity'],
|
|
Decimal('-125.00000'))
|
|
self.assertEqual(create.call_args[0][0][0]['lot_p'], None)
|
|
self.assertEqual(create.call_args[0][0][0]['lot_s'], 2)
|
|
|
|
def test_lotqt_cancel_transport_plan_merges_matching_open_line(self):
|
|
'cancelling a transport plan returns quantity to the open lot.qt'
|
|
LotQt = lot_module.LotQt
|
|
planned = Mock(
|
|
lot_p=Mock(id=1),
|
|
lot_s=None,
|
|
lot_quantity=Decimal('125'),
|
|
lot_unit=Mock(id=3),
|
|
lot_av='available',
|
|
lot_status='forecast',
|
|
lot_visible=True,
|
|
)
|
|
planned.has_shipment.return_value = False
|
|
planned.is_planned_transport.return_value = True
|
|
target = Mock(lot_quantity=Decimal('375'))
|
|
|
|
with patch.object(
|
|
LotQt, 'search', return_value=[target]) as search, patch.object(
|
|
LotQt, 'save') as save, patch.object(
|
|
LotQt, 'create') as create, patch.object(
|
|
LotQt, 'delete') as delete:
|
|
LotQt.cancel_transport_plan(planned)
|
|
|
|
search.assert_called_once()
|
|
self.assertEqual(target.lot_quantity, Decimal('500'))
|
|
save.assert_called_once_with([target])
|
|
create.assert_not_called()
|
|
delete.assert_called_once_with([planned])
|
|
|
|
def test_plan_transport_collects_affected_lines_before_cancel(self):
|
|
'plan transport wizard can keep affected lines before deleting lot.qt'
|
|
wizard = lot_module.LotPlanTransport()
|
|
purchase_line = Mock()
|
|
sale_line = Mock()
|
|
lotqt = Mock(
|
|
lot_p=Mock(line=purchase_line),
|
|
lot_s=Mock(sale_line=sale_line),
|
|
)
|
|
affected_lines = []
|
|
|
|
wizard._append_lotqt_affected_lines(lotqt, affected_lines)
|
|
|
|
self.assertEqual(affected_lines, [purchase_line, sale_line])
|
|
|
|
def test_plan_transport_defaults_multiple_lines(self):
|
|
'plan transport wizard opens one planning line per selected quantity'
|
|
wizard = lot_module.LotPlanTransport()
|
|
wizard.records = [
|
|
Mock(
|
|
id=10000001,
|
|
r_lot_quantity=Decimal('100'),
|
|
r_lot_matched=Decimal('0'),
|
|
r_lot_unit_line=Mock(id=3),
|
|
r_planned_from_location=Mock(id=10),
|
|
r_planned_to_location=Mock(id=20),
|
|
r_planned_transport_type='vessel',
|
|
r_planned_from_date=datetime.date(2026, 7, 1),
|
|
r_planned_to_date=datetime.date(2026, 7, 5),
|
|
r_planned_note='First leg'),
|
|
Mock(
|
|
id=10000002,
|
|
r_lot_quantity=Decimal('200'),
|
|
r_lot_matched=Decimal('0'),
|
|
r_lot_unit_line=Mock(id=4),
|
|
r_planned_from_location=None,
|
|
r_planned_to_location=None,
|
|
r_planned_transport_type=None,
|
|
r_planned_from_date=None,
|
|
r_planned_to_date=None,
|
|
r_planned_note=None),
|
|
]
|
|
|
|
values = wizard.default_plan(None)
|
|
|
|
self.assertEqual(len(values['lines']), 2)
|
|
self.assertEqual(values['lines'][0]['lotqt'], 1)
|
|
self.assertEqual(values['lines'][0]['quantity'], Decimal('100'))
|
|
self.assertEqual(values['lines'][0]['planned_from_location'], 10)
|
|
self.assertTrue(values['lines'][0]['cancel_plan'])
|
|
self.assertEqual(values['lines'][1]['lotqt'], 2)
|
|
self.assertEqual(values['lines'][1]['quantity'], Decimal('200'))
|
|
self.assertFalse(values['lines'][1]['cancel_plan'])
|
|
self.assertIsNone(values['default_lotqt'])
|
|
self.assertIsNone(values['default_unit'])
|
|
|
|
def test_plan_transport_defaults_single_line_for_new_rows(self):
|
|
'plan transport wizard gives new rows the current quantity line'
|
|
wizard = lot_module.LotPlanTransport()
|
|
wizard.records = [
|
|
Mock(
|
|
id=10002242,
|
|
r_lot_quantity=Decimal('1000'),
|
|
r_lot_matched=Decimal('0'),
|
|
r_lot_unit_line=Mock(id=3),
|
|
r_planned_from_location=None,
|
|
r_planned_to_location=None,
|
|
r_planned_transport_type=None,
|
|
r_planned_from_date=None,
|
|
r_planned_to_date=None,
|
|
r_planned_note=None),
|
|
]
|
|
|
|
values = wizard.default_plan(None)
|
|
|
|
self.assertEqual(values['default_lotqt'], 2242)
|
|
self.assertEqual(values['default_unit'], 3)
|
|
|
|
def test_plan_transport_line_defaults_from_context(self):
|
|
'new planning lines read their lot.qt and unit from the parent context'
|
|
context = {
|
|
'lot_plan_transport_default_lotqt': 2242,
|
|
'lot_plan_transport_default_unit': 3,
|
|
}
|
|
with patch.object(
|
|
lot_module, 'Transaction',
|
|
return_value=Mock(context=context)):
|
|
self.assertEqual(
|
|
lot_module.LotPlanTransportLine.default_lotqt(), 2242)
|
|
self.assertEqual(
|
|
lot_module.LotPlanTransportLine.default_unit(), 3)
|
|
|
|
def test_plan_transport_applies_each_planning_line(self):
|
|
'plan transport wizard applies each line independently'
|
|
wizard = lot_module.LotPlanTransport()
|
|
lotqt_a = Mock(
|
|
lot_p=Mock(line=Mock()),
|
|
lot_s=None)
|
|
lotqt_b = Mock(
|
|
lot_p=None,
|
|
lot_s=Mock(sale_line=Mock()))
|
|
wizard.plan = Mock(lines=[
|
|
Mock(
|
|
lotqt=lotqt_a,
|
|
cancel_plan=False,
|
|
quantity=Decimal('100'),
|
|
planned_from_location=Mock(id=10),
|
|
planned_to_location=Mock(id=20),
|
|
planned_transport_type='vessel',
|
|
planned_from_date=datetime.date(2026, 7, 1),
|
|
planned_to_date=datetime.date(2026, 7, 5),
|
|
planned_note='A'),
|
|
Mock(
|
|
lotqt=lotqt_b,
|
|
cancel_plan=False,
|
|
quantity=Decimal('200'),
|
|
planned_from_location=Mock(id=11),
|
|
planned_to_location=Mock(id=21),
|
|
planned_transport_type='truck',
|
|
planned_from_date=datetime.date(2026, 7, 6),
|
|
planned_to_date=datetime.date(2026, 7, 8),
|
|
planned_note='B'),
|
|
])
|
|
Lot = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
LotQt = Mock()
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, LotQt]
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_planning()
|
|
|
|
self.assertEqual(state, 'end')
|
|
LotQt.plan_to_transport.assert_any_call(lotqt_a, Decimal('100'), {
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
'planned_transport_type': 'vessel',
|
|
'planned_from_date': datetime.date(2026, 7, 1),
|
|
'planned_to_date': datetime.date(2026, 7, 5),
|
|
'planned_note': 'A',
|
|
})
|
|
LotQt.plan_to_transport.assert_any_call(lotqt_b, Decimal('200'), {
|
|
'planned_from_location': 11,
|
|
'planned_to_location': 21,
|
|
'planned_transport_type': 'truck',
|
|
'planned_from_date': datetime.date(2026, 7, 6),
|
|
'planned_to_date': datetime.date(2026, 7, 8),
|
|
'planned_note': 'B',
|
|
})
|
|
Lot.assert_lines_quantity_consistency.assert_called_once()
|
|
|
|
def test_lot_shipping_defaults_planned_locations(self):
|
|
'link to transport carries planned locations into shipment create'
|
|
wizard = lot_module.LotShipping()
|
|
wizard.records = [Mock(
|
|
r_planned_from_location=Mock(id=10),
|
|
r_planned_to_location=Mock(id=20),
|
|
)]
|
|
|
|
self.assertEqual(wizard.default_ship(None), {
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
})
|
|
|
|
def test_lot_shipping_defaults_purchase_locations_without_plan(self):
|
|
'link to transport falls back to purchase locations without a plan'
|
|
wizard = lot_module.LotShipping()
|
|
wizard.records = [Mock(
|
|
r_planned_from_location=None,
|
|
r_planned_to_location=None,
|
|
r_purchase=Mock(
|
|
from_location=Mock(id=10),
|
|
to_location=Mock(id=20)),
|
|
)]
|
|
|
|
self.assertEqual(wizard.default_ship(None), {
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
})
|
|
|
|
def test_lot_shipping_defaults_uniform_planned_locations(self):
|
|
'link to transport carries uniform planned locations from selection'
|
|
wizard = lot_module.LotShipping()
|
|
wizard.records = [
|
|
Mock(
|
|
r_planned_from_location=Mock(id=10),
|
|
r_planned_to_location=Mock(id=20)),
|
|
Mock(
|
|
r_planned_from_location=Mock(id=10),
|
|
r_planned_to_location=Mock(id=20)),
|
|
]
|
|
|
|
self.assertEqual(wizard.default_ship(None), {
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
})
|
|
|
|
def test_lot_shipping_ignores_mixed_planned_locations(self):
|
|
'link to transport does not force mixed planned locations'
|
|
wizard = lot_module.LotShipping()
|
|
wizard.records = [
|
|
Mock(
|
|
r_planned_from_location=Mock(id=10),
|
|
r_planned_to_location=Mock(id=20)),
|
|
Mock(
|
|
r_planned_from_location=Mock(id=11),
|
|
r_planned_to_location=Mock(id=21)),
|
|
]
|
|
|
|
self.assertEqual(wizard.default_ship(None), {})
|
|
|
|
def test_lot_shipping_link_creates_shipment_from_dialog_supplier(self):
|
|
'link to transport creates shipment in from dialog supplier'
|
|
wizard = lot_module.LotShipping()
|
|
supplier = Mock(id=30)
|
|
created_shipment = Mock(id=40)
|
|
ShipmentIn = Mock()
|
|
ShipmentIn.create.return_value = [created_shipment]
|
|
wizard.records = []
|
|
wizard.ship = Mock(
|
|
shipment='in',
|
|
shipment_in=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
create_new_shipment=True,
|
|
shipment_supplier=supplier,
|
|
planned_from_location=Mock(id=10),
|
|
planned_to_location=Mock(id=20),
|
|
)
|
|
|
|
Lot = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, Mock(), Mock(), ShipmentIn]
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_shipping()
|
|
|
|
self.assertEqual(state, 'end')
|
|
ShipmentIn.create.assert_called_once_with([{
|
|
'supplier': 30,
|
|
'from_location': 10,
|
|
'to_location': 20,
|
|
}])
|
|
self.assertEqual(wizard.ship.shipment_in, created_shipment)
|
|
Lot.assert_lines_quantity_consistency.assert_called_once_with([])
|
|
|
|
def test_lot_shipping_rejects_existing_and_new_shipment(self):
|
|
'link to transport rejects choosing existing shipment and create new'
|
|
wizard = lot_module.LotShipping()
|
|
wizard.ship = Mock(
|
|
shipment='in',
|
|
shipment_in=Mock(id=40),
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
create_new_shipment=True,
|
|
shipment_supplier=Mock(id=30),
|
|
)
|
|
|
|
with self.assertRaises(UserError):
|
|
wizard.transition_shipping()
|
|
|
|
def test_lot_shipping_physical_without_move_creates_shipment_move(self):
|
|
'linking a physical lot to shipment creates the missing stock move'
|
|
wizard = lot_module.LotShipping()
|
|
shipment = Mock(id=40)
|
|
wizard.ship = Mock(
|
|
shipment='in',
|
|
shipment_in=shipment,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
create_new_shipment=False,
|
|
quantity=None,
|
|
)
|
|
record = Mock(
|
|
id=2014,
|
|
r_lot_type='physic',
|
|
)
|
|
wizard.records = [record]
|
|
move = Mock()
|
|
move.get_linked_transit_move.return_value = None
|
|
lot = Mock(
|
|
id=2014,
|
|
move=None,
|
|
line=Mock(),
|
|
sale_line=None,
|
|
)
|
|
lot.get_current_quantity_converted.return_value = Decimal('22')
|
|
lot.getVlot_p.return_value = Mock()
|
|
lot.getVlot_s.return_value = None
|
|
lot.create_shipment_move.return_value = move
|
|
Lot = Mock()
|
|
Lot.return_value = lot
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
LotQt = Mock()
|
|
Move = Mock()
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, LotQt, Move]
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_shipping()
|
|
|
|
self.assertEqual(state, 'end')
|
|
self.assertEqual(lot.lot_shipment_in, shipment)
|
|
lot.create_shipment_move.assert_called_once_with(
|
|
'stock.shipment.in,40')
|
|
self.assertEqual(move.shipment, 'stock.shipment.in,40')
|
|
Move.save.assert_called_once_with([move])
|
|
lot.updateVirtualPart.assert_called_once_with(
|
|
Decimal('-22'), 'stock.shipment.in,40', None)
|
|
Lot.save.assert_called()
|
|
Lot.assert_lines_quantity_consistency.assert_called_once_with([
|
|
lot.line])
|
|
|
|
def test_lot_shipping_open_quantity_uses_shared_transport_link(self):
|
|
'standalone link action keeps linking open lot.qt quantities'
|
|
wizard = lot_module.LotShipping()
|
|
shipment = Mock(id=40)
|
|
wizard.ship = Mock(
|
|
shipment='in',
|
|
shipment_in=shipment,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
create_new_shipment=False,
|
|
quantity=None,
|
|
)
|
|
wizard.records = [Mock(
|
|
id=10000007,
|
|
r_lot_shipment_in=None,
|
|
r_lot_quantity=Decimal('125'),
|
|
r_lot_matched=Decimal('0'),
|
|
)]
|
|
purchase_line = Mock()
|
|
source = Mock(
|
|
lot_p=Mock(line=purchase_line),
|
|
lot_s=None,
|
|
)
|
|
Lot = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
LotQt = Mock(return_value=source)
|
|
Move = Mock()
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, LotQt, Move]
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_shipping()
|
|
|
|
self.assertEqual(state, 'end')
|
|
LotQt.link_to_transport.assert_called_once_with(
|
|
source, Decimal('125.00000'), 'stock.shipment.in,40')
|
|
Lot.assert_lines_quantity_consistency.assert_called_once_with([
|
|
purchase_line])
|
|
|
|
def test_lot_unship_restores_planned_open_quantity(self):
|
|
'unlinking a planned scheduled lot.qt returns it to planned'
|
|
wizard = lot_module.LotUnship()
|
|
record = Mock(
|
|
id=10000007,
|
|
r_lot_type='virtual',
|
|
)
|
|
wizard.records = [record]
|
|
purchase_line = Mock()
|
|
sale_line = Mock()
|
|
lot = Mock(line=purchase_line)
|
|
sale_lot = Mock(sale_line=sale_line)
|
|
lqt = Mock(
|
|
lot_quantity=Decimal('40'),
|
|
lot_p=lot,
|
|
lot_s=sale_lot,
|
|
lot_shipment_in=Mock(),
|
|
lot_shipment_internal=None,
|
|
lot_shipment_out=None,
|
|
lot_shipment_origin='stock.shipment.in,12',
|
|
)
|
|
lqt.is_planned_transport.return_value = True
|
|
Lot = Mock()
|
|
Lot.return_value = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
LotQt = Mock()
|
|
LotQt.return_value = lqt
|
|
Move = Mock()
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, LotQt, Move]
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_start()
|
|
|
|
self.assertEqual(state, 'end')
|
|
lot.updateVirtualPart.assert_not_called()
|
|
LotQt.restore_planned_open_quantity.assert_called_once_with(
|
|
lqt, Decimal('40'))
|
|
LotQt.delete.assert_called_once_with([lqt])
|
|
lot.createVirtualPart.assert_not_called()
|
|
Lot.assert_lines_quantity_consistency.assert_called_once_with([
|
|
purchase_line, sale_line])
|
|
|
|
def test_lot_unship_deletes_zero_scheduled_lotqt_without_restore(self):
|
|
'unlinking a residual zero scheduled lot.qt only cleans the shipment tab'
|
|
wizard = lot_module.LotUnship()
|
|
wizard.records = [Mock(id=10000007, r_lot_type='virtual')]
|
|
purchase_line = Mock()
|
|
sale_line = Mock()
|
|
lot = Mock(line=purchase_line)
|
|
sale_lot = Mock(sale_line=sale_line)
|
|
lqt = Mock(
|
|
lot_quantity=Decimal('0'),
|
|
lot_p=lot,
|
|
lot_s=sale_lot,
|
|
lot_shipment_in=Mock(),
|
|
lot_shipment_internal=None,
|
|
lot_shipment_out=None,
|
|
)
|
|
lqt.is_planned_transport.return_value = True
|
|
Lot = Mock()
|
|
Lot.return_value = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
LotQt = Mock()
|
|
LotQt.return_value = lqt
|
|
Move = Mock()
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, LotQt, Move]
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_start()
|
|
|
|
self.assertEqual(state, 'end')
|
|
LotQt.restore_planned_open_quantity.assert_not_called()
|
|
lot.updateVirtualPart.assert_not_called()
|
|
lot.createVirtualPart.assert_not_called()
|
|
LotQt.delete.assert_called_once_with([lqt])
|
|
Lot.assert_lines_quantity_consistency.assert_called_once_with([
|
|
purchase_line, sale_line])
|
|
|
|
def test_add_physical_lots_requires_linked_transport(self):
|
|
'physical lots can only be added from a scheduled quantity'
|
|
LotQt = Pool().get('lot.qt')
|
|
lqt = Mock()
|
|
lqt.has_shipment.return_value = False
|
|
|
|
with self.assertRaises(UserError):
|
|
LotQt.add_physical_lots(lqt, [])
|
|
|
|
def test_add_physical_lot_copies_planned_transport_memory(self):
|
|
'physical lots keep planned transport fields from lot.qt'
|
|
LotQt = Pool().get('lot.qt')
|
|
lqt = LotQt()
|
|
planned_from = Mock()
|
|
planned_to = Mock()
|
|
purchase_line = Mock(product=Mock(), fees=[])
|
|
lqt.lot_p = Mock(line=purchase_line)
|
|
lqt.lot_s = None
|
|
lqt.lot_shipment_in = Mock(fees=[])
|
|
lqt.lot_shipment_internal = None
|
|
lqt.lot_shipment_out = None
|
|
lqt.planned_from_location = planned_from
|
|
lqt.planned_to_location = planned_to
|
|
lqt.planned_transport_type = 'truck'
|
|
lqt.planned_from_date = datetime.date(2026, 6, 15)
|
|
lqt.planned_to_date = datetime.date(2026, 6, 20)
|
|
lqt.planned_note = 'Gate 4'
|
|
physical_input = Mock(
|
|
lot_qt=Decimal('1'),
|
|
lot_unit=Mock(),
|
|
lot_unit_line=Mock(),
|
|
lot_quantity=Decimal('40'),
|
|
lot_gross_quantity=Decimal('41'),
|
|
lot_premium=Decimal('0'),
|
|
lot_chunk_key=None,
|
|
)
|
|
newlot = Mock()
|
|
newlot.line = purchase_line
|
|
newlot.sale_line = None
|
|
newlot.set_current_quantity = Mock()
|
|
newlot.get_current_quantity_converted.return_value = Decimal('40')
|
|
Lot = Mock()
|
|
Lot.return_value = newlot
|
|
FeeLots = Mock()
|
|
Purchase = Mock()
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, FeeLots, Purchase]
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
lot, _ = lqt.add_physical_lot(physical_input)
|
|
|
|
self.assertIs(lot.planned_from_location, planned_from)
|
|
self.assertIs(lot.planned_to_location, planned_to)
|
|
self.assertEqual(lot.planned_transport_type, 'truck')
|
|
self.assertEqual(lot.planned_from_date, datetime.date(2026, 6, 15))
|
|
self.assertEqual(lot.planned_to_date, datetime.date(2026, 6, 20))
|
|
self.assertEqual(lot.planned_note, 'Gate 4')
|
|
|
|
def test_remove_physical_lot_restores_planned_scheduled_quantity(self):
|
|
'removing a planned physical lot restores the planned scheduled lot.qt'
|
|
wizard = lot_module.LotRemove()
|
|
wizard.records = [Mock(id=2014)]
|
|
quantity = Decimal('40')
|
|
lot_s = Mock()
|
|
lot = Mock(
|
|
move=None,
|
|
lot_shipment_origin='stock.shipment.in,12',
|
|
lot_shipment_in=None,
|
|
lot_shipment_internal=None,
|
|
lot_shipment_out=None,
|
|
)
|
|
lot.get_current_quantity_converted.return_value = quantity
|
|
lot.getVlot_s.return_value = lot_s
|
|
lot.sale_line = Mock()
|
|
lot.is_planned_transport.return_value = True
|
|
Lot = Mock()
|
|
Lot.return_value = lot
|
|
LotQt = Mock()
|
|
Move = Mock()
|
|
Warning = Mock()
|
|
Warning.check.return_value = False
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = [Lot, LotQt, Move, Warning]
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_start()
|
|
|
|
self.assertEqual(state, 'end')
|
|
LotQt.restore_planned_scheduled_quantity.assert_called_once_with(
|
|
lot, quantity, lot_s, 'stock.shipment.in,12')
|
|
lot.updateVirtualPart.assert_not_called()
|
|
Lot.delete.assert_called_once_with([lot])
|
|
|
|
def test_lot_shipping_scheduled_lotqt_keeps_planned_locations(self):
|
|
'scheduled lot.qt keeps the planned from and to locations'
|
|
LotQt = Pool().get('lot.qt')
|
|
linked = Mock()
|
|
lqt = Mock(
|
|
lot_quantity=Decimal('125'),
|
|
lot_p=Mock(id=1),
|
|
lot_s=Mock(id=2),
|
|
lot_unit=Mock(id=3),
|
|
lot_av='available',
|
|
lot_status='forecast',
|
|
lot_visible=True,
|
|
planned_from_location=Mock(id=10),
|
|
planned_to_location=Mock(id=20),
|
|
planned_transport_type='vessel',
|
|
planned_from_date=datetime.date(2026, 7, 1),
|
|
planned_to_date=datetime.date(2026, 7, 5),
|
|
planned_note='POL to POD',
|
|
)
|
|
lqt.is_planned_transport.return_value = True
|
|
|
|
with patch.object(
|
|
LotQt, 'create', return_value=[linked]) as create, \
|
|
patch.object(LotQt, 'save') as save:
|
|
result = LotQt.link_to_transport(
|
|
lqt, Decimal('125'), 'stock.shipment.in,40')
|
|
|
|
create.assert_called_once_with([{
|
|
'lot_p': 1,
|
|
'lot_s': 2,
|
|
'lot_quantity': Decimal('125'),
|
|
'lot_unit': 3,
|
|
'lot_av': 'available',
|
|
'lot_status': 'forecast',
|
|
'lot_visible': True,
|
|
'planned_from_location': 10,
|
|
'planned_to_location': 20,
|
|
'planned_transport_type': 'vessel',
|
|
'planned_from_date': datetime.date(2026, 7, 1),
|
|
'planned_to_date': datetime.date(2026, 7, 5),
|
|
'planned_note': 'POL to POD',
|
|
'lot_shipment_in': 40,
|
|
}])
|
|
save.assert_called_once_with([lqt])
|
|
self.assertEqual(lqt.lot_quantity, 0)
|
|
self.assertIs(result, linked)
|
|
|
|
def test_add_physical_lot_defaults_transport_from_purchase(self):
|
|
'add physical lot proposes shipment creation from purchase values'
|
|
wizard = lot_module.LotAdding()
|
|
purchase = Mock(
|
|
party=Mock(id=30),
|
|
from_location=Mock(id=10),
|
|
to_location=Mock(id=20),
|
|
)
|
|
lqt = Mock(
|
|
lot_p=Mock(line=Mock(
|
|
purchase=purchase,
|
|
unit=Mock(id=3))),
|
|
lot_quantity=Decimal('125'),
|
|
lot_shipment_in=None,
|
|
lot_shipment_internal=None,
|
|
lot_shipment_out=None,
|
|
planned_from_location=None,
|
|
planned_to_location=None,
|
|
)
|
|
lqt.lot_p.lot_unit_line = Mock(id=3)
|
|
LotQt = Mock(return_value=lqt)
|
|
pool = Mock()
|
|
pool.get.side_effect = [Mock(), LotQt]
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool), patch(
|
|
'trytond.modules.purchase_trade.lot.Transaction'
|
|
) as TransactionMock:
|
|
TransactionMock.return_value.context = {
|
|
'active_ids': [10000007]}
|
|
values = wizard.default_add(None)
|
|
|
|
self.assertTrue(values['transport_missing'])
|
|
self.assertIsNone(values['shipment_supplier'])
|
|
self.assertEqual(values['transport_from_location'], 10)
|
|
self.assertEqual(values['transport_to_location'], 20)
|
|
|
|
def test_add_physical_lot_links_existing_shipment_before_add(self):
|
|
'add physical lot can link an existing shipment in one transaction'
|
|
wizard = lot_module.LotAdding()
|
|
shipment = Mock(id=40)
|
|
source = Mock(lot_quantity=Decimal('125'))
|
|
source.has_shipment.return_value = False
|
|
linked = Mock()
|
|
LotQt = Mock(return_value=source)
|
|
LotQt.link_to_transport.return_value = linked
|
|
Lot = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
pool = Mock()
|
|
pool.get.side_effect = [LotQt, Lot]
|
|
wizard.records = [Mock(id=10000007)]
|
|
wizard.add = Mock(
|
|
shipment_in=shipment,
|
|
create_new_shipment=False,
|
|
lots=[Mock()],
|
|
unlink_remainder=True,
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_adding()
|
|
|
|
self.assertEqual(state, 'end')
|
|
LotQt.link_to_transport.assert_called_once_with(
|
|
source, Decimal('125'), 'stock.shipment.in,40')
|
|
LotQt.add_physical_lots.assert_called_once_with(
|
|
linked, wizard.add.lots, True)
|
|
|
|
def test_add_physical_lot_creates_shipment_with_optional_bl(self):
|
|
'add physical lot creates and links a shipment with optional BL data'
|
|
wizard = lot_module.LotAdding()
|
|
shipment = Mock(id=40)
|
|
source = Mock(lot_quantity=Decimal('125'))
|
|
source.has_shipment.return_value = False
|
|
linked = Mock()
|
|
LotQt = Mock(return_value=source)
|
|
LotQt.link_to_transport.return_value = linked
|
|
ShipmentIn = Mock()
|
|
ShipmentIn.create.return_value = [shipment]
|
|
Lot = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
|
|
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
|
|
pool = Mock()
|
|
pool.get.side_effect = [LotQt, ShipmentIn, Lot]
|
|
wizard.records = [Mock(id=10000007)]
|
|
wizard.add = Mock(
|
|
shipment_in=None,
|
|
create_new_shipment=True,
|
|
shipment_supplier=Mock(id=30),
|
|
transport_from_location=Mock(id=10),
|
|
transport_to_location=Mock(id=20),
|
|
shipment_bl_number='BL-2026-01',
|
|
shipment_bl_date=datetime.date(2026, 6, 22),
|
|
lots=[Mock()],
|
|
unlink_remainder=True,
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool',
|
|
return_value=pool):
|
|
state = wizard.transition_adding()
|
|
|
|
self.assertEqual(state, 'end')
|
|
ShipmentIn.create.assert_called_once_with([{
|
|
'supplier': 30,
|
|
'from_location': 10,
|
|
'to_location': 20,
|
|
'bl_number': 'BL-2026-01',
|
|
'bl_date': datetime.date(2026, 6, 22),
|
|
}])
|
|
LotQt.link_to_transport.assert_called_once_with(
|
|
source, Decimal('125'), 'stock.shipment.in,40')
|
|
LotQt.add_physical_lots.assert_called_once_with(
|
|
linked, wizard.add.lots, True)
|
|
|
|
@with_transaction()
|
|
def test_get_mtm_applies_component_ratio_as_percentage(self):
|
|
'get_mtm treats component ratio as a percentage'
|
|
Strategy = Pool().get('mtm.strategy')
|
|
strategy = Strategy()
|
|
strategy.scenario = Mock(
|
|
valuation_date='2026-03-29',
|
|
use_last_price=True,
|
|
)
|
|
strategy.currency = Mock()
|
|
strategy.components = [Mock(
|
|
price_source_type='curve',
|
|
price_index=Mock(get_price=Mock(return_value=Decimal('100'))),
|
|
price_matrix=None,
|
|
ratio=Decimal('25'),
|
|
)]
|
|
line = Mock(unit=Mock())
|
|
|
|
self.assertEqual(
|
|
strategy.get_mtm(line, Decimal('10')),
|
|
Decimal('250.00'))
|
|
strategy.components[0].price_index.get_price.assert_called_once_with(
|
|
'2026-03-29',
|
|
line.unit,
|
|
strategy.currency,
|
|
relative_last=True)
|
|
|
|
def test_mtm_scenario_resolves_fixed_valuation_date(self):
|
|
'mtm scenario can force an explicit valuation date'
|
|
scenario = pricing_module.MtmScenario()
|
|
scenario.valuation_date_type = 'fixed'
|
|
scenario.valuation_date = datetime.date(2026, 6, 18)
|
|
|
|
self.assertEqual(
|
|
scenario.get_valuation_date(),
|
|
datetime.date(2026, 6, 18))
|
|
|
|
def test_itsa_mtm_scenario_defaults_calendar(self):
|
|
'ITSA mtm scenarios default to the sulphuric acid calendar'
|
|
with patch('trytond.modules.purchase_trade.pricing.is_itsa_company',
|
|
return_value=True), patch(
|
|
'trytond.modules.purchase_trade.pricing.'
|
|
'default_itsa_price_calendar',
|
|
return_value=17):
|
|
self.assertEqual(pricing_module.MtmScenario.default_calendar(), 17)
|
|
|
|
def test_itsa_mtm_component_defaults(self):
|
|
'ITSA mtm components default market price, ratio and USD currency'
|
|
with patch('trytond.modules.purchase_trade.pricing.is_itsa_company',
|
|
return_value=True), patch(
|
|
'trytond.modules.purchase_trade.pricing.'
|
|
'default_itsa_price_fix_type',
|
|
return_value=21), patch(
|
|
'trytond.modules.purchase_trade.pricing.default_itsa_currency',
|
|
return_value=840):
|
|
self.assertEqual(pricing_module.Mtm.default_fix_type(), 21)
|
|
self.assertEqual(
|
|
pricing_module.Mtm.default_ratio(), Decimal('100'))
|
|
self.assertEqual(pricing_module.Mtm.default_currency(), 840)
|
|
|
|
def test_itsa_pricing_component_defaults(self):
|
|
'ITSA pricing definitions default market price, ratio, USD and calendar'
|
|
with patch('trytond.modules.purchase_trade.pricing.is_itsa_company',
|
|
return_value=True), patch(
|
|
'trytond.modules.purchase_trade.pricing.'
|
|
'default_itsa_price_fix_type',
|
|
return_value=21), patch(
|
|
'trytond.modules.purchase_trade.pricing.'
|
|
'default_itsa_price_calendar',
|
|
return_value=17), patch(
|
|
'trytond.modules.purchase_trade.pricing.default_itsa_currency',
|
|
return_value=840):
|
|
self.assertEqual(pricing_module.Component.default_fix_type(), 21)
|
|
self.assertEqual(
|
|
pricing_module.Component.default_ratio(), Decimal('100'))
|
|
self.assertEqual(
|
|
pricing_module.Component.default_fixed_currency(), 840)
|
|
self.assertEqual(pricing_module.Component.default_calendar(), 17)
|
|
|
|
def test_mtm_scenario_resolves_relative_valuation_dates(self):
|
|
'mtm scenario can resolve relative valuation dates'
|
|
scenario = pricing_module.MtmScenario()
|
|
Date = Mock()
|
|
Date.today.return_value = datetime.date(2026, 2, 12)
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.pricing.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = Date
|
|
|
|
scenario.valuation_date_type = 'today'
|
|
self.assertEqual(
|
|
scenario.get_valuation_date(),
|
|
datetime.date(2026, 2, 12))
|
|
|
|
scenario.valuation_date_type = 'month_end'
|
|
self.assertEqual(
|
|
scenario.get_valuation_date(),
|
|
datetime.date(2026, 2, 28))
|
|
|
|
scenario.valuation_date_type = 'next_month_end'
|
|
self.assertEqual(
|
|
scenario.get_valuation_date(),
|
|
datetime.date(2026, 3, 31))
|
|
|
|
@with_transaction()
|
|
def test_get_mtm_uses_resolved_scenario_date(self):
|
|
'get_mtm uses the scenario resolved valuation date'
|
|
Strategy = Pool().get('mtm.strategy')
|
|
strategy = Strategy()
|
|
scenario = pricing_module.MtmScenario()
|
|
scenario.valuation_date_type = 'month_end'
|
|
scenario.use_last_price = True
|
|
strategy.scenario = scenario
|
|
strategy.currency = Mock()
|
|
price_index = Mock(get_price=Mock(return_value=Decimal('100')))
|
|
strategy.components = [Mock(
|
|
price_source_type='curve',
|
|
price_index=price_index,
|
|
price_matrix=None,
|
|
ratio=Decimal('100'),
|
|
)]
|
|
line = Mock(unit=Mock())
|
|
Date = Mock()
|
|
Date.today.return_value = datetime.date(2026, 2, 12)
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.pricing.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = Date
|
|
|
|
self.assertEqual(
|
|
strategy.get_mtm(line, Decimal('2')),
|
|
Decimal('200.00'))
|
|
|
|
price_index.get_price.assert_called_once_with(
|
|
datetime.date(2026, 2, 28),
|
|
line.unit,
|
|
strategy.currency,
|
|
relative_last=True)
|
|
|
|
@with_transaction()
|
|
def test_get_mtm_uses_fixed_pricing_component(self):
|
|
'get_mtm values fixed components like a constant price curve'
|
|
Strategy = Pool().get('mtm.strategy')
|
|
strategy = Strategy()
|
|
strategy.scenario = Mock(
|
|
valuation_date='2026-03-29',
|
|
use_last_price=True,
|
|
)
|
|
strategy.currency = Mock()
|
|
strategy.components = [Mock(
|
|
price_source_type='fixed',
|
|
price_index=None,
|
|
price_matrix=None,
|
|
get_price=Mock(return_value=Decimal('75')),
|
|
ratio=Decimal('100'),
|
|
)]
|
|
line = Mock(unit=Mock())
|
|
|
|
self.assertEqual(
|
|
strategy.get_mtm(line, Decimal('3')),
|
|
Decimal('225.00'))
|
|
|
|
@with_transaction()
|
|
def test_signed_strategy_mtm_follows_valuation_direction(self):
|
|
'strategy MTM follows purchase/sale valuation sign'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
strategy = Mock(get_mtm=Mock(return_value=Decimal('349167.53')))
|
|
line = Mock()
|
|
|
|
self.assertEqual(
|
|
Valuation._signed_strategy_mtm(
|
|
{'type': 'pur. priced', 'amount': Decimal('-190686.15'),
|
|
'quantity': Decimal('4237.47')},
|
|
strategy, line),
|
|
Decimal('-349167.53'))
|
|
self.assertEqual(
|
|
Valuation._signed_strategy_mtm(
|
|
{'type': 'sale priced', 'amount': Decimal('349167.53'),
|
|
'quantity': Decimal('4237.47')},
|
|
strategy, line),
|
|
Decimal('349167.53'))
|
|
self.assertEqual(
|
|
Valuation._signed_strategy_mtm(
|
|
{'type': 'pur. priced', 'amount': Decimal('0'),
|
|
'quantity': Decimal('0')},
|
|
strategy, line),
|
|
Decimal('-349167.53'))
|
|
|
|
@with_transaction()
|
|
def test_strategy_mtm_lines_are_separate_from_realized_price(self):
|
|
'strategy MTM creates separate curve lines and preserves realized line'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
valuation_date = datetime.date(2026, 6, 5)
|
|
previous_date = datetime.date(2026, 6, 4)
|
|
unit = Mock()
|
|
currency = Mock()
|
|
line = Mock(unit=unit)
|
|
curve_a = Mock(id=10)
|
|
curve_a.get_price.side_effect = [Decimal('100'), Decimal('90')]
|
|
curve_b = Mock(id=20)
|
|
curve_b.get_price.side_effect = [Decimal('50'), Decimal('45')]
|
|
strategy = Mock(
|
|
scenario=Mock(
|
|
valuation_date=valuation_date,
|
|
use_last_price=False),
|
|
currency=currency,
|
|
components=[
|
|
Mock(
|
|
price_source_type='curve',
|
|
price_index=curve_a,
|
|
ratio=Decimal('60')),
|
|
Mock(
|
|
price_source_type='curve',
|
|
price_index=curve_b,
|
|
ratio=Decimal('40')),
|
|
])
|
|
line.mtm = [strategy]
|
|
values = {
|
|
'type': 'pur. priced',
|
|
'price': Decimal('10'),
|
|
'amount': Decimal('-100'),
|
|
'base_amount': Decimal('-100'),
|
|
'quantity': Decimal('10'),
|
|
}
|
|
target = []
|
|
price_value = Mock()
|
|
price_value.search.return_value = [Mock(price_date=previous_date)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = price_value
|
|
Valuation._append_pnl_values(target, values, line)
|
|
|
|
self.assertEqual(len(target), 3)
|
|
self.assertEqual(target[0], values)
|
|
self.assertEqual(target[0]['pnl'], Decimal('-100.00'))
|
|
self.assertEqual(target[1]['type'], 'pur. mtm')
|
|
self.assertEqual(target[1]['mtm_curve'], curve_a.id)
|
|
self.assertEqual(target[1]['price'], None)
|
|
self.assertEqual(target[1]['amount'], Decimal('0'))
|
|
self.assertEqual(target[1]['quantity'], Decimal('10'))
|
|
self.assertEqual(target[1]['mtm_price'], Decimal('100.0000'))
|
|
self.assertEqual(target[1]['mtm_price_prev'], Decimal('90.0000'))
|
|
self.assertEqual(target[1]['amount_prev'], Decimal('-540.00'))
|
|
self.assertEqual(target[1]['mtm'], Decimal('-600.00'))
|
|
self.assertEqual(target[1]['pnl'], Decimal('600.00'))
|
|
self.assertEqual(target[2]['type'], 'pur. mtm')
|
|
self.assertEqual(target[2]['mtm_curve'], curve_b.id)
|
|
self.assertEqual(target[2]['price'], None)
|
|
self.assertEqual(target[2]['amount'], Decimal('0'))
|
|
self.assertEqual(target[2]['quantity'], Decimal('10'))
|
|
self.assertEqual(target[2]['mtm_price'], Decimal('50.0000'))
|
|
self.assertEqual(target[2]['mtm_price_prev'], Decimal('45.0000'))
|
|
self.assertEqual(target[2]['amount_prev'], Decimal('-180.00'))
|
|
self.assertEqual(target[2]['mtm'], Decimal('-200.00'))
|
|
self.assertEqual(target[2]['pnl'], Decimal('200.00'))
|
|
|
|
@with_transaction()
|
|
def test_sale_strategy_mtm_lines_use_sale_type(self):
|
|
'sale strategy MTM lines are labelled as sale MTM'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
line = Mock(unit=Mock())
|
|
strategy = Mock(
|
|
scenario=Mock(valuation_date=datetime.date(2026, 6, 5)),
|
|
currency=None,
|
|
components=[],
|
|
get_mtm=Mock(return_value=Decimal('250')))
|
|
line.mtm = [strategy]
|
|
values = {
|
|
'type': 'sale priced',
|
|
'amount': Decimal('100'),
|
|
'base_amount': Decimal('100'),
|
|
'quantity': Decimal('10'),
|
|
}
|
|
target = []
|
|
|
|
Valuation._append_pnl_values(target, values, line)
|
|
|
|
self.assertEqual(target[1]['type'], 'sale mtm')
|
|
self.assertEqual(target[1]['mtm'], Decimal('250'))
|
|
self.assertEqual(target[1]['pnl'], Decimal('-250.00'))
|
|
|
|
def test_pnl_converts_strategy_mtm_to_base_currency(self):
|
|
'valuation PnL compares base amount with MTM in base currency'
|
|
Valuation = valuation_module.Valuation
|
|
source_currency = Mock(id=1)
|
|
base_currency = Mock(id=2)
|
|
strategy = Mock(currency=source_currency, scenario=None)
|
|
values = {
|
|
'base_amount': Decimal('100'),
|
|
'base_currency': base_currency,
|
|
}
|
|
Currency = Mock()
|
|
Currency.compute.return_value = Decimal('90')
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = Currency
|
|
|
|
pnl = Valuation._pnl_amount(
|
|
values, strategy=strategy, mtm_amount=Decimal('120'))
|
|
|
|
Currency.compute.assert_called_once_with(
|
|
source_currency, Decimal('120'), base_currency)
|
|
self.assertEqual(pnl, Decimal('10.00'))
|
|
|
|
def test_purchase_pnl_uses_partial_lotqt_match_quantity(self):
|
|
'open matched purchase and sale pnl use the matched lot.qt quantity'
|
|
Valuation = valuation_module.Valuation
|
|
unit = Mock(id=1)
|
|
currency = Mock(id=2)
|
|
company = Mock(currency=2)
|
|
purchase = Mock(
|
|
id=10, currency=currency, company=company, party=Mock(id=11))
|
|
purchase_line = Mock(
|
|
id=20, price_type='priced', mtm=[], purchase=purchase,
|
|
product=Mock(id=21), unit=unit, lots=[])
|
|
purchase_lot = Mock(
|
|
id=30, lot_type='virtual', lot_price=Decimal('45'),
|
|
sale_line=None)
|
|
purchase_lot.get_current_quantity_converted.return_value = (
|
|
Decimal('1000'))
|
|
sale = Mock(
|
|
id=40, currency=currency, company=company, party=Mock(id=41))
|
|
sale_line = Mock(
|
|
id=50, price_type='priced', mtm=[], sale=sale,
|
|
product=Mock(id=51), unit=unit)
|
|
sale_lot = Mock(
|
|
id=60, lot_type='virtual', lot_price_sale=Decimal('100'),
|
|
sale_line=sale_line)
|
|
sale_lot.get_current_quantity_converted.return_value = Decimal('2000')
|
|
purchase_line.lots = [purchase_lot]
|
|
shipment = Mock(id=70)
|
|
lotqt = Mock(
|
|
lot_p=purchase_lot, lot_s=sale_lot,
|
|
lot_quantity=Decimal('500'), lot_unit=unit,
|
|
lot_shipment_in=shipment)
|
|
lotqt_model = Mock()
|
|
lotqt_model.search.return_value = [lotqt]
|
|
pool = Mock()
|
|
pool.get.return_value = lotqt_model
|
|
|
|
with patch.object(valuation_module, 'Pool', return_value=pool):
|
|
lines = Valuation.create_pnl_price_from_line(purchase_line)
|
|
|
|
self.assertEqual(len(lines), 2)
|
|
self.assertEqual(lines[0]['type'], 'pur. priced')
|
|
self.assertEqual(lines[0]['quantity'], Decimal('500.00000'))
|
|
self.assertEqual(lines[0]['shipment_in'], shipment.id)
|
|
self.assertEqual(lines[0]['amount'], Decimal('-22500.00'))
|
|
self.assertEqual(lines[1]['type'], 'sale priced')
|
|
self.assertEqual(lines[1]['quantity'], Decimal('500.00000'))
|
|
self.assertEqual(lines[1]['shipment_in'], shipment.id)
|
|
self.assertEqual(lines[1]['amount'], Decimal('50000.00'))
|
|
|
|
@with_transaction()
|
|
def test_purchase_line_charter_conditions_inherit_header_when_empty(self):
|
|
'purchase line uses header charter conditions when it has no line terms'
|
|
Line = Pool().get('purchase.line')
|
|
line_condition = Mock(id=2)
|
|
header_condition = Mock(id=1)
|
|
line = Line()
|
|
line.charter_conditions = []
|
|
line.purchase = Mock(charter_conditions=[header_condition])
|
|
|
|
self.assertEqual(
|
|
line.get_effective_charter_conditions(), [header_condition])
|
|
|
|
line.charter_conditions = [line_condition]
|
|
|
|
self.assertEqual(
|
|
line.get_effective_charter_conditions(), [line_condition])
|
|
|
|
@with_transaction()
|
|
def test_sale_line_charter_conditions_inherit_header_when_empty(self):
|
|
'sale line uses header charter conditions when it has no line terms'
|
|
Line = Pool().get('sale.line')
|
|
line_condition = Mock(id=2)
|
|
header_condition = Mock(id=1)
|
|
line = Line()
|
|
line.charter_conditions = []
|
|
line.sale = Mock(charter_conditions=[header_condition])
|
|
|
|
self.assertEqual(
|
|
line.get_effective_charter_conditions(), [header_condition])
|
|
|
|
line.charter_conditions = [line_condition]
|
|
|
|
self.assertEqual(
|
|
line.get_effective_charter_conditions(), [line_condition])
|
|
|
|
@with_transaction()
|
|
def test_charter_condition_rate_type_sets_default_basis(self):
|
|
'charter condition rate copies the dynamic type default basis'
|
|
Rate = Pool().get('charter.condition.rate')
|
|
rate = Rate()
|
|
rate.rate_type = Mock(default_basis='per_day')
|
|
|
|
rate.on_change_rate_type()
|
|
|
|
self.assertEqual(rate.basis, 'per_day')
|
|
|
|
def test_charter_condition_rate_basis_includes_pumping_unit(self):
|
|
'charter condition rates expose a clear pumping throughput unit'
|
|
self.assertIn(('mt_per_hour', 'MT/hour'), stock_module.RATE_BASIS)
|
|
|
|
@with_transaction()
|
|
def test_charter_condition_role_defaults_from_context(self):
|
|
'charter condition role defaults to the business source context'
|
|
Condition = Pool().get('charter.condition')
|
|
|
|
with Transaction().set_context(charter_condition_role='sale'):
|
|
self.assertEqual(Condition.default_party_role(), 'customer')
|
|
condition = Condition()
|
|
self.assertEqual(
|
|
condition.get_party_roles(),
|
|
[(None, ''), ('customer', 'Customer')])
|
|
|
|
with Transaction().set_context(charter_condition_role='purchase'):
|
|
self.assertEqual(Condition.default_party_role(), 'supplier')
|
|
condition = Condition()
|
|
self.assertEqual(
|
|
condition.get_party_roles(),
|
|
[(None, ''), ('supplier', 'Supplier')])
|
|
|
|
with Transaction().set_context(charter_condition_role='charter_party'):
|
|
self.assertEqual(Condition.default_party_role(), 'owner')
|
|
condition = Condition()
|
|
roles = {role for role, _ in condition.get_party_roles()}
|
|
self.assertNotIn('supplier', roles)
|
|
self.assertNotIn('customer', roles)
|
|
|
|
@with_transaction()
|
|
def test_charter_condition_role_rejects_invalid_source_role(self):
|
|
'charter condition role is constrained by its source'
|
|
Condition = Pool().get('charter.condition')
|
|
condition = Condition()
|
|
condition.name = 'Invalid supplier condition on sale'
|
|
condition.party_role = 'supplier'
|
|
|
|
with Transaction().set_context(charter_condition_role='sale'):
|
|
with self.assertRaises(UserError):
|
|
Condition.validate([condition])
|
|
|
|
@with_transaction()
|
|
def test_sof_calculation_reads_applied_condition_rates(self):
|
|
'sof demurrage calculation uses the selected charter condition rates'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = Mock(
|
|
laytime_allowed=None,
|
|
laytime_start_offset=None,
|
|
laytime_start_offset_unit=None,
|
|
turn_time=Decimal('6'),
|
|
turn_time_unit='hours',
|
|
rates=[
|
|
Mock(rate=Decimal('300'), rate_type=Mock(category='pumping')),
|
|
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
|
|
],
|
|
)
|
|
sof.laytime_balance = -Decimal('12')
|
|
|
|
self.assertEqual(
|
|
sof._applied_turn_delta(), datetime.timedelta(hours=6))
|
|
self.assertEqual(
|
|
sof._applied_laytime_allowed(Decimal('600')), 2.0)
|
|
self.assertEqual(
|
|
sof._applied_compensation_amount(), Decimal('-12000.00'))
|
|
self.assertEqual(sof.compensation_type, 'demurrage')
|
|
|
|
@with_transaction()
|
|
def test_sof_calculation_rate_overrides_win_over_condition_rates(self):
|
|
'sof calculation rate overrides are used before condition rates'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = Mock(
|
|
laytime_allowed=None,
|
|
rates=[
|
|
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
|
|
Mock(rate=Decimal('100'), rate_type=Mock(category='pumping')),
|
|
])
|
|
sof.demurrage_rate_override = Decimal('36000')
|
|
sof.pumping_rate_override = Decimal('200')
|
|
sof.laytime_balance = -Decimal('12')
|
|
|
|
self.assertEqual(
|
|
sof._applied_laytime_allowed(Decimal('1000')), 5.0)
|
|
self.assertEqual(
|
|
sof._applied_compensation_amount(), Decimal('-18000.00'))
|
|
self.assertEqual(sof.compensation_type, 'demurrage')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_demurrage_rate'),
|
|
'36000 / day')
|
|
|
|
@with_transaction()
|
|
def test_sof_available_conditions_include_shipment_contracts(self):
|
|
'sof applied condition choices include owner, purchase and sale terms'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.charter_party = None
|
|
sof.shipment = Mock(
|
|
get_owner_charter_conditions=Mock(return_value=[1]),
|
|
get_purchase_charter_conditions=Mock(return_value=[2]),
|
|
get_sale_charter_conditions=Mock(return_value=[3]))
|
|
|
|
self.assertEqual(sof.get_applied_conditions(), [1, 2, 3])
|
|
|
|
@with_transaction()
|
|
def test_sof_default_applied_condition_can_use_purchase_terms(self):
|
|
'sof default applied condition can come from purchase without charter'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.charter_party = None
|
|
sof.shipment = Mock(
|
|
charter_party=None,
|
|
get_owner_charter_conditions=Mock(return_value=[]),
|
|
get_purchase_charter_conditions=Mock(return_value=[12]),
|
|
get_sale_charter_conditions=Mock(return_value=[]))
|
|
|
|
sof.on_change_shipment()
|
|
|
|
self.assertEqual(sof.applied_condition, 12)
|
|
|
|
@with_transaction()
|
|
def test_sof_laytime_start_reads_structured_condition_rule(self):
|
|
'sof laytime start reads the structured event and offset'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = Mock(
|
|
laytime_start_event='notice_of_readiness',
|
|
laytime_start_offset=Decimal('6'),
|
|
laytime_start_offset_unit='hours',
|
|
turn_time=None)
|
|
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
|
|
|
|
self.assertEqual(
|
|
sof._applied_laytime_start(),
|
|
datetime.datetime(2026, 6, 1, 8, 0))
|
|
|
|
@with_transaction()
|
|
def test_sof_laytime_start_uses_earliest_candidate(self):
|
|
'sof laytime start can use the earliest of multiple candidates'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = SimpleNamespace(
|
|
laytime_start_rule='earliest_of',
|
|
laytime_start_rules=[
|
|
SimpleNamespace(
|
|
sequence=10,
|
|
event='notice_of_readiness',
|
|
offset=Decimal('6'),
|
|
offset_unit='hours'),
|
|
SimpleNamespace(
|
|
sequence=20,
|
|
event='all_fast',
|
|
offset=None,
|
|
offset_unit='hours'),
|
|
])
|
|
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
|
|
sof.all_fast = datetime.datetime(2026, 6, 1, 7, 0)
|
|
|
|
self.assertEqual(
|
|
sof._applied_laytime_start(),
|
|
datetime.datetime(2026, 6, 1, 7, 0))
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_start_event'),
|
|
'Earliest Of')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_start_offset'),
|
|
'Notice of Readiness + 6 hours; All Fast')
|
|
|
|
@with_transaction()
|
|
def test_sof_laytime_end_reads_structured_condition_rule(self):
|
|
'sof laytime end reads the structured end event'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = Mock(laytime_end_event='documents_on_board')
|
|
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
|
|
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
|
|
sof.documents_on_board = datetime.datetime(2026, 6, 1, 14, 0)
|
|
|
|
self.assertEqual(
|
|
sof._applied_laytime_end(),
|
|
datetime.datetime(2026, 6, 1, 14, 0))
|
|
|
|
@with_transaction()
|
|
def test_sof_additional_quantity_is_added_to_calculation_quantity(self):
|
|
'sof additional quantity is added to the quantity used for calculation'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.additional_quantity = Decimal('25')
|
|
|
|
self.assertEqual(
|
|
sof._calculation_quantity(Decimal('100')),
|
|
Decimal('125'))
|
|
|
|
@with_transaction()
|
|
def test_sof_laytime_ignores_free_text_clauses(self):
|
|
'sof laytime calculation ignores contractual free text fields'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = Mock(
|
|
laytime_start_event='arrival',
|
|
laytime_start_offset=Decimal('1'),
|
|
laytime_start_offset_unit='hours',
|
|
laytime_end_event='end_pumping',
|
|
laytime_start='NOR + 6 hours',
|
|
laytime_end='Hoses disconnected',
|
|
turn_time=None)
|
|
sof.arrival_time = datetime.datetime(2026, 6, 1, 3, 0)
|
|
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
|
|
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
|
|
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
|
|
|
|
self.assertEqual(
|
|
sof._applied_laytime_start(),
|
|
datetime.datetime(2026, 6, 1, 4, 0))
|
|
self.assertEqual(
|
|
sof._applied_laytime_end(),
|
|
datetime.datetime(2026, 6, 1, 12, 0))
|
|
|
|
@with_transaction()
|
|
def test_sof_calculation_info_explains_structured_inputs(self):
|
|
'sof calculation info displays the structured calculation inputs'
|
|
Sof = Pool().get('sof.statement')
|
|
sof = Sof()
|
|
sof.applied_condition = Mock(
|
|
rec_name='Discharge',
|
|
name='Discharge',
|
|
party_role='owner',
|
|
responsibility='ours',
|
|
laytime_allowed=Decimal('72'),
|
|
laytime_unit='hours',
|
|
laytime_start_event='notice_of_readiness',
|
|
laytime_start_offset=Decimal('6'),
|
|
laytime_start_offset_unit='hours',
|
|
laytime_end_event='hoses_disconnected',
|
|
laytime_start='Arrival + 1 day',
|
|
laytime_end='End pumping',
|
|
rates=[
|
|
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
|
|
Mock(rate=Decimal('12000'), rate_type=Mock(category='despatch')),
|
|
])
|
|
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
|
|
sof.hoses_disconnected = datetime.datetime(2026, 6, 4, 10, 0)
|
|
sof.laytime_balance = -Decimal('12')
|
|
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_condition'), 'Discharge')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_start_event'),
|
|
'Notice of Readiness')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_start_event_time'),
|
|
'01/06/2026 02:00')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_start_offset'), '6 hours')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_end_event'),
|
|
'Hoses Disconnected')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_end_event_time'),
|
|
'04/06/2026 10:00')
|
|
self.assertEqual(
|
|
sof.get_calculation_info('calculation_rate_used'),
|
|
'Demurrage: 24000 / day')
|
|
|
|
@with_transaction()
|
|
def test_sof_header_date_time_values_sync_to_datetime(self):
|
|
'sof header date and time fields build the technical datetime value'
|
|
Sof = Pool().get('sof.statement')
|
|
values = Sof._sync_datetime_values({
|
|
'notice_of_readiness_date': datetime.date(2026, 7, 6),
|
|
'notice_of_readiness_hour': datetime.time(8, 0),
|
|
'all_fast_date': datetime.date(2026, 7, 6),
|
|
'all_fast_hour': datetime.time(10, 30),
|
|
'documents_on_board_date': datetime.date(2026, 7, 6),
|
|
'documents_on_board_hour': datetime.time(12, 0),
|
|
})
|
|
|
|
self.assertEqual(
|
|
values['notice_of_readiness_time'],
|
|
datetime.datetime(2026, 7, 6, 8, 0))
|
|
self.assertEqual(
|
|
values['all_fast'],
|
|
datetime.datetime(2026, 7, 6, 10, 30))
|
|
self.assertEqual(
|
|
values['documents_on_board'],
|
|
datetime.datetime(2026, 7, 6, 12, 0))
|
|
|
|
@with_transaction()
|
|
def test_sof_event_date_time_values_sync_to_datetime(self):
|
|
'sof event date and time fields build the technical datetime values'
|
|
Event = Pool().get('sof.event')
|
|
values = Event._sync_datetime_values({
|
|
'start_date': datetime.date(2026, 1, 1),
|
|
'start_time': datetime.time(10, 0),
|
|
'end_date': datetime.date(2026, 1, 1),
|
|
'end_time': datetime.time(12, 0),
|
|
})
|
|
|
|
self.assertEqual(
|
|
values['start'], datetime.datetime(2026, 1, 1, 10, 0))
|
|
self.assertEqual(
|
|
values['end'], datetime.datetime(2026, 1, 1, 12, 0))
|
|
|
|
def test_contract_clause_variables_and_rendering_use_contract_context(self):
|
|
'contract clauses expose placeholders and render from purchase context'
|
|
clause = purchase_module.ContractClause()
|
|
clause.name = 'Payment'
|
|
clause.direction = 'both'
|
|
clause.text = (
|
|
'Buyer pays [party] under [incoterm] for [product] '
|
|
'contract [Contract_Number].')
|
|
|
|
self.assertEqual(
|
|
clause.get_variables('variables'),
|
|
'Contract_Number, incoterm, party, product')
|
|
|
|
selection = purchase_module.ContractClauseSelection()
|
|
selection.clause = clause
|
|
selection.purchase = Mock(
|
|
reference='PUR-001',
|
|
party=Mock(rec_name='Supplier Ltd'),
|
|
company=Mock(rec_name='Company SA'),
|
|
currency=Mock(rec_name='USD'),
|
|
incoterm=Mock(code='FOB'),
|
|
incoterm_location=None,
|
|
payment_term=None,
|
|
product_origin='',
|
|
lines=[
|
|
Mock(type='line', product=Mock(rec_name='Steel'), quantity=10,
|
|
unit=Mock(rec_name='MT')),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(
|
|
selection.get_rendered_text('rendered_text'),
|
|
'Buyer pays Supplier Ltd under FOB for Steel contract PUR-001.')
|
|
|
|
@with_transaction()
|
|
def test_add_physical_lot_defaults_hidden_premium_and_chunk_key(self):
|
|
'add physical lot works when hidden tree fields are not loaded'
|
|
LotQt = Pool().get('lot.qt')
|
|
saved_lots = []
|
|
|
|
class FakeLot:
|
|
def __init__(self):
|
|
self.sale_line = None
|
|
self.id = 1
|
|
|
|
def set_current_quantity(self, quantity, gross_quantity, type_):
|
|
self.quantity = quantity
|
|
self.gross_quantity = gross_quantity
|
|
self.quantity_type = type_
|
|
|
|
def get_current_quantity_converted(self):
|
|
return self.quantity
|
|
|
|
@classmethod
|
|
def save(cls, lots):
|
|
saved_lots.extend(lots)
|
|
|
|
class AddLine:
|
|
lot_qt = Decimal('10')
|
|
lot_unit = Mock()
|
|
lot_unit_line = Mock()
|
|
lot_quantity = Decimal('10')
|
|
lot_gross_quantity = Decimal('11')
|
|
|
|
line = Mock(product=Mock(), fees=[])
|
|
line.purchase = Mock()
|
|
lqt = LotQt()
|
|
lqt.lot_p = Mock(line=line)
|
|
lqt.lot_s = None
|
|
lqt.lot_shipment_in = None
|
|
lqt.lot_shipment_internal = None
|
|
lqt.lot_shipment_out = None
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': FakeLot,
|
|
'fee.lots': Mock(),
|
|
'purchase.purchase': Mock(),
|
|
}[name]
|
|
|
|
lqt.add_physical_lot(AddLine())
|
|
|
|
self.assertEqual(len(saved_lots), 1)
|
|
self.assertIsNone(saved_lots[0].lot_chunk_key)
|
|
self.assertEqual(saved_lots[0].lot_premium, Decimal(0))
|
|
|
|
def test_get_strategy_mtm_price_applies_component_ratio(self):
|
|
'strategy mtm price applies component ratios'
|
|
price_index = Mock(get_price=Mock(return_value=Decimal('100')))
|
|
strategy = Mock(
|
|
scenario=Mock(
|
|
valuation_date='2026-03-29',
|
|
use_last_price=True,
|
|
),
|
|
currency=Mock(),
|
|
)
|
|
strategy.components = [Mock(
|
|
price_source_type='curve',
|
|
price_index=price_index,
|
|
price_matrix=None,
|
|
ratio=Decimal('25'),
|
|
)]
|
|
line = Mock(unit=Mock())
|
|
|
|
self.assertEqual(
|
|
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
|
|
Decimal('25.0000'))
|
|
price_index.get_price.assert_called_once_with(
|
|
'2026-03-29',
|
|
line.unit,
|
|
strategy.currency,
|
|
relative_last=True)
|
|
|
|
def test_get_strategy_mtm_price_keeps_signed_component_spread(self):
|
|
'strategy mtm price keeps signed spread between different components'
|
|
strategy = Mock(
|
|
scenario=Mock(
|
|
valuation_date='2026-03-29',
|
|
use_last_price=True,
|
|
),
|
|
currency=Mock(),
|
|
)
|
|
strategy.components = [
|
|
Mock(
|
|
price_source_type='curve',
|
|
price_index=Mock(get_price=Mock(return_value=Decimal('120'))),
|
|
price_matrix=None,
|
|
ratio=Decimal('100'),
|
|
),
|
|
Mock(
|
|
price_source_type='curve',
|
|
price_index=Mock(get_price=Mock(return_value=Decimal('80'))),
|
|
price_matrix=None,
|
|
ratio=Decimal('-100'),
|
|
),
|
|
]
|
|
line = Mock(unit=Mock())
|
|
|
|
self.assertEqual(
|
|
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
|
|
Decimal('40.0000'))
|
|
|
|
def test_get_strategy_mtm_price_uses_fixed_pricing_component(self):
|
|
'strategy mtm price uses fixed components like constant price curves'
|
|
strategy = Mock(
|
|
scenario=Mock(
|
|
valuation_date='2026-03-29',
|
|
use_last_price=True,
|
|
),
|
|
currency=Mock(),
|
|
)
|
|
strategy.components = [Mock(
|
|
price_source_type='fixed',
|
|
price_index=None,
|
|
price_matrix=None,
|
|
get_price=Mock(return_value=Decimal('75')),
|
|
ratio=Decimal('50'),
|
|
)]
|
|
line = Mock(unit=Mock())
|
|
|
|
self.assertEqual(
|
|
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
|
|
Decimal('37.5000'))
|
|
|
|
def test_sale_line_is_unmatched_checks_lot_links(self):
|
|
'sale line unmatched helper ignores empty matches and detects linked purchases'
|
|
sale_line = Mock()
|
|
sale_line.get_matched_lines.return_value = []
|
|
self.assertTrue(
|
|
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
|
|
|
|
linked = Mock(lot_p=Mock(line=Mock()))
|
|
sale_line.get_matched_lines.return_value = [linked]
|
|
self.assertFalse(
|
|
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
|
|
|
|
def test_regenerate_for_purchase_lines_deduplicates_targets(self):
|
|
'targeted valuation regeneration processes each purchase line once'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
line = Mock(id=10)
|
|
|
|
with patch.object(Valuation, 'generate') as generate:
|
|
Valuation.regenerate_for_purchase_lines([line, line])
|
|
|
|
generate.assert_called_once_with(line, valuation_type='all')
|
|
|
|
def test_regenerate_for_sale_lines_deduplicates_targets(self):
|
|
'targeted valuation regeneration processes each sale line once'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
sale_line = Mock(id=20)
|
|
|
|
with patch.object(
|
|
Valuation, 'generate_from_sale_line') as generate_from_sale_line:
|
|
Valuation.regenerate_for_sale_lines([sale_line, sale_line])
|
|
|
|
generate_from_sale_line.assert_called_once_with(
|
|
sale_line, valuation_type='all')
|
|
|
|
def test_parse_numbers_supports_inline_and_legacy_separators(self):
|
|
'parse_numbers keeps supporting inline entry and legacy separators'
|
|
self.assertEqual(
|
|
valuation_module.ValuationProcess._parse_numbers(
|
|
'PUR-001 PUR-002, PUR-003\nPUR-004;PUR-005'
|
|
),
|
|
['PUR-001', 'PUR-002', 'PUR-003', 'PUR-004', 'PUR-005'])
|
|
|
|
def test_get_generate_types_maps_business_groups(self):
|
|
'valuation type groups map to the expected stored valuation types'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
|
|
self.assertEqual(
|
|
Valuation._get_generate_types('fees'),
|
|
{'line fee', 'pur. fee', 'sale fee', 'shipment fee'})
|
|
self.assertEqual(
|
|
Valuation._get_generate_types('derivatives'),
|
|
{'derivative'})
|
|
self.assertIn('pur. priced', Valuation._get_generate_types('goods'))
|
|
self.assertIn('mtm', Valuation._get_generate_types('goods'))
|
|
self.assertIn('pur. mtm', Valuation._get_generate_types('goods'))
|
|
self.assertIn('sale mtm', Valuation._get_generate_types('goods'))
|
|
|
|
def test_filter_values_by_types_keeps_matching_entries_only(self):
|
|
'type filtering keeps only the requested valuation entries'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
|
|
values = [
|
|
{'type': 'pur. fee', 'amount': Decimal('10')},
|
|
{'type': 'pur. priced', 'amount': Decimal('20')},
|
|
{'type': 'derivative', 'amount': Decimal('30')},
|
|
]
|
|
|
|
self.assertEqual(
|
|
Valuation._filter_values_by_types(
|
|
values, {'pur. fee', 'derivative'}),
|
|
[
|
|
{'type': 'pur. fee', 'amount': Decimal('10')},
|
|
{'type': 'derivative', 'amount': Decimal('30')},
|
|
])
|
|
|
|
def test_delete_existing_purchase_clears_matched_sale_line_only_values(self):
|
|
'purchase valuation cleanup removes matched sale-only valuation rows'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
today = datetime.date(2026, 7, 1)
|
|
sale_line = Mock(id=30)
|
|
sale_lot = Mock(sale_line=sale_line)
|
|
purchase_lot = Mock(id=20, lot_type='virtual', sale_line=None)
|
|
line = Mock(
|
|
id=10,
|
|
finished=False,
|
|
lots=[purchase_lot],
|
|
get_matched_lines=Mock(return_value=[]))
|
|
valuation_model = Mock()
|
|
valuation_line_model = Mock()
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=today)),
|
|
'valuation.valuation': valuation_model,
|
|
'valuation.valuation.line': valuation_line_model,
|
|
'lot.qt': lot_qt_model,
|
|
}[name]
|
|
|
|
Valuation._delete_existing(line)
|
|
|
|
valuation_model.search.assert_any_call([
|
|
('line', '=', line.id),
|
|
('date', '=', today),
|
|
])
|
|
valuation_model.search.assert_any_call([
|
|
('sale_line', '=', sale_line.id),
|
|
('line', '=', None),
|
|
('date', '=', today),
|
|
])
|
|
valuation_line_model.search.assert_any_call([
|
|
('sale_line', '=', sale_line.id),
|
|
('line', '=', None),
|
|
])
|
|
|
|
def test_generate_keeps_finished_purchase_line_physical_pnl(self):
|
|
'finished purchase lines still generate physical lot valuation'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
line = Mock(finished=True)
|
|
|
|
with patch.object(Valuation, '_delete_existing') as delete_existing, patch.object(
|
|
Valuation, 'create_pnl_fee_from_line',
|
|
return_value=[{'type': 'pur. fee'}]) as create_fees, patch.object(
|
|
Valuation, 'create_pnl_price_from_line',
|
|
return_value=[{'type': 'pur. priced'}]) as create_prices, patch.object(
|
|
Valuation, 'create_pnl_der_from_line',
|
|
return_value=[{'type': 'derivative'}]) as create_derivatives, patch(
|
|
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
Valuation.generate(line)
|
|
|
|
delete_existing.assert_called_once_with(line, selected_types=None)
|
|
create_fees.assert_called_once_with(line)
|
|
create_prices.assert_called_once_with(line)
|
|
create_derivatives.assert_called_once_with(line)
|
|
valuation_model = PoolMock.return_value.get.return_value
|
|
valuation_model.create.assert_any_call([
|
|
{'type': 'pur. fee'},
|
|
{'type': 'pur. priced'},
|
|
{'type': 'derivative'},
|
|
])
|
|
|
|
def test_generate_keeps_finished_sale_line_physical_pnl(self):
|
|
'finished sale lines still generate physical lot valuation'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
sale_line = Mock(finished=True)
|
|
|
|
with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object(
|
|
Valuation, 'create_pnl_fee_from_sale_line',
|
|
return_value=[{'type': 'sale fee'}]) as create_fees, patch.object(
|
|
Valuation, 'create_pnl_price_from_sale_line',
|
|
return_value=[{'type': 'sale priced'}]) as create_prices, patch.object(
|
|
Valuation, 'create_pnl_der_from_sale_line',
|
|
return_value=[{'type': 'derivative'}]) as create_derivatives, patch(
|
|
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
Valuation.generate_from_sale_line(sale_line)
|
|
|
|
delete_existing.assert_called_once_with(sale_line, selected_types=None)
|
|
create_fees.assert_called_once_with(sale_line)
|
|
create_prices.assert_called_once_with(sale_line)
|
|
create_derivatives.assert_called_once_with(sale_line)
|
|
valuation_model = PoolMock.return_value.get.return_value
|
|
valuation_model.create.assert_any_call([
|
|
{'type': 'sale fee'},
|
|
{'type': 'sale priced'},
|
|
{'type': 'derivative'},
|
|
])
|
|
|
|
def test_update_daily_snapshot_generates_all_purchases_and_unmatched_sales(self):
|
|
'daily valuation cron snapshots purchases and unmatched sale lines'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
PurchaseLine = Mock()
|
|
SaleLine = Mock()
|
|
purchase_lines = [Mock(id=1), Mock(id=2)]
|
|
unmatched_sale_line = Mock(id=3)
|
|
unmatched_sale_line.get_matched_lines.return_value = []
|
|
matched_sale_line = Mock(id=4)
|
|
matched_sale_line.get_matched_lines.return_value = [
|
|
Mock(lot_p=Mock(line=Mock(id=5)))]
|
|
PurchaseLine.search.return_value = purchase_lines
|
|
SaleLine.search.return_value = [unmatched_sale_line, matched_sale_line]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.valuation.Pool'
|
|
) as PoolMock, patch.object(
|
|
Valuation, 'generate') as generate, patch.object(
|
|
Valuation, 'generate_from_sale_line'
|
|
) as generate_from_sale_line:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'purchase.line': PurchaseLine,
|
|
'sale.line': SaleLine,
|
|
}[name]
|
|
|
|
Valuation.update_daily_snapshot()
|
|
|
|
generate.assert_any_call(purchase_lines[0])
|
|
generate.assert_any_call(purchase_lines[1])
|
|
self.assertEqual(generate.call_count, 2)
|
|
generate_from_sale_line.assert_called_once_with(unmatched_sale_line)
|
|
|
|
def test_update_daily_pricing_checks_and_recomputes_all_lines(self):
|
|
'daily pricing cron refreshes pricing and line prices'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
PurchaseLine = Mock()
|
|
SaleLine = Mock()
|
|
purchase_lines = [Mock(id=1), Mock(id=2)]
|
|
sale_lines = [Mock(id=3), Mock(id=4)]
|
|
PurchaseLine.search.return_value = purchase_lines
|
|
SaleLine.search.return_value = sale_lines
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.pricing.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'purchase.line': PurchaseLine,
|
|
'sale.line': SaleLine,
|
|
}[name]
|
|
|
|
Pricing.update_daily_pricing()
|
|
|
|
PurchaseLine.search.assert_called_once_with([])
|
|
SaleLine.search.assert_called_once_with([])
|
|
for line in purchase_lines + sale_lines:
|
|
line.check_pricing.assert_called_once_with()
|
|
line._recompute_trade_price_fields.assert_called_once_with()
|
|
PurchaseLine.save.assert_called_once_with(purchase_lines)
|
|
SaleLine.save.assert_called_once_with(sale_lines)
|
|
|
|
def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self):
|
|
'purchase fee valuation treats an uncomputed rate amount as zero'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
product = Mock(id=3, name='Financing fees')
|
|
supplier = Mock(id=4)
|
|
lot = Mock(id=5, sale_line=None, lot_type='virtual')
|
|
lot.get_current_quantity_converted.return_value = Decimal('10')
|
|
fee = Mock(
|
|
id=11,
|
|
product=product,
|
|
supplier=supplier,
|
|
type='budgeted',
|
|
p_r='pay',
|
|
mode='rate',
|
|
price=Decimal('10'),
|
|
currency=currency,
|
|
shipment_in=None,
|
|
sale_line=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
unit=unit,
|
|
)
|
|
fee.get_amount.return_value = None
|
|
line = Mock(
|
|
id=6,
|
|
lots=[lot],
|
|
get_matched_lines=Mock(return_value=[]),
|
|
purchase=Mock(id=7, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee.line = line
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': Mock(),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(values[0]['amount'], Decimal('0'))
|
|
self.assertEqual(values[0]['fee'], fee.id)
|
|
|
|
def test_create_pnl_fee_from_line_ignores_orphan_fee_lot(self):
|
|
'purchase fee valuation ignores fees without business attachment'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
lot = Mock(id=5, sale_line=None, lot_type='virtual')
|
|
lot.get_current_quantity_converted.return_value = Decimal('10')
|
|
fee = Mock(
|
|
line=None,
|
|
sale_line=None,
|
|
shipment_in=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
)
|
|
line = Mock(
|
|
id=6,
|
|
lots=[lot],
|
|
get_matched_lines=Mock(return_value=[]),
|
|
purchase=Mock(id=7, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': Mock(),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(values, [])
|
|
|
|
def test_fee_quantity_sync_uses_physical_lots_when_present(self):
|
|
'fee quantity sync ignores virtual lot once physical lots exist'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.id = 1
|
|
fee.mode = 'perqt'
|
|
fee.quantity = Decimal('100')
|
|
fee.unit = Mock()
|
|
virtual = Mock(lot_type='virtual')
|
|
virtual.get_current_quantity_converted.return_value = Decimal('100')
|
|
physical = Mock(lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('40')
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [
|
|
Mock(lot=virtual),
|
|
Mock(lot=physical),
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.fee.Pool'
|
|
) as PoolMock, patch.object(Fee, 'save') as save:
|
|
PoolMock.return_value.get.return_value = fee_lots
|
|
|
|
fee.sync_quantity_from_lots()
|
|
|
|
self.assertEqual(fee.quantity, Decimal('40.00000'))
|
|
save.assert_called_once_with([fee])
|
|
|
|
def test_fee_ppack_auto_sync_keeps_auto_quantity(self):
|
|
'per packing auto fee keeps the on-change quantity on save'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.id = 1
|
|
fee.mode = 'ppack'
|
|
fee.auto_calculation = True
|
|
fee.quantity = Decimal('3')
|
|
fee.unit = Mock()
|
|
lot = Mock(lot_type='physic', lot_qt=Decimal('990'))
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(lot=lot)]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.fee.Pool'
|
|
) as PoolMock, patch.object(Fee, 'save') as save:
|
|
PoolMock.return_value.get.return_value = fee_lots
|
|
|
|
fee.sync_quantity_from_lots()
|
|
|
|
self.assertEqual(fee.quantity, Decimal('3'))
|
|
save.assert_not_called()
|
|
|
|
def test_fee_linked_price_updates_fee_price(self):
|
|
'fee linked price converts to the accounting currency price'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.enable_linked_currency = True
|
|
fee.linked_price = Decimal('15')
|
|
fee.linked_currency = Mock(factor=Decimal('0.01'))
|
|
fee.linked_unit = Mock()
|
|
fee.unit = Mock()
|
|
Uom = Mock()
|
|
Uom.compute_qty.return_value = Decimal('2')
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = Uom
|
|
|
|
fee.on_change_linked_price()
|
|
|
|
self.assertEqual(fee.price, Decimal('0.3000'))
|
|
|
|
def test_fee_pnl_regeneration_uses_shipment_lotqt_purchase_line(self):
|
|
'fee pnl regeneration follows shipment lot.qt back to purchase line'
|
|
Fee = Pool().get('fee.fee')
|
|
purchase_line = Mock(id=1)
|
|
lot_p = Mock(line=purchase_line, sale_line=None)
|
|
shipment = Mock(
|
|
incoming_moves=[],
|
|
lotqt=[Mock(lot_p=lot_p, lot_s=None)],
|
|
)
|
|
fee = Mock(line=None, sale_line=None, lots=[], shipment_in=shipment)
|
|
valuation = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = valuation
|
|
|
|
Fee._regenerate_fee_pnl(fees=[fee])
|
|
|
|
valuation.generate.assert_called_once_with(
|
|
purchase_line, valuation_type='fees')
|
|
valuation.generate_from_sale_line.assert_not_called()
|
|
|
|
def test_fee_pnl_regeneration_updates_purchase_and_sale_lot_lines(self):
|
|
'fee pnl regeneration follows physical fee lots to purchase and sale'
|
|
Fee = Pool().get('fee.fee')
|
|
purchase_line = Mock(id=1)
|
|
sale_line = Mock(id=2)
|
|
lot = Mock(line=purchase_line, sale_line=sale_line)
|
|
fee = Mock(line=None, sale_line=None, lots=[lot], shipment_in=None)
|
|
valuation = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = valuation
|
|
|
|
Fee._regenerate_fee_pnl(fees=[fee])
|
|
|
|
valuation.generate.assert_called_once_with(
|
|
purchase_line, valuation_type='fees')
|
|
valuation.generate_from_sale_line.assert_called_once_with(
|
|
sale_line, valuation_type='fees')
|
|
|
|
def test_purchase_fee_pnl_ignores_virtual_fee_lot_when_physical_exists(self):
|
|
'purchase fee pnl uses physical fee lots instead of the residual virtual'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
product = Mock(id=3, name='Handling')
|
|
supplier = Mock(id=4)
|
|
virtual = Mock(id=5, sale_line=None, lot_type='virtual')
|
|
virtual.get_current_quantity_converted.return_value = Decimal('60')
|
|
physical = Mock(id=6, sale_line=None, lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('40')
|
|
fee = Mock(
|
|
product=product,
|
|
supplier=supplier,
|
|
type='budgeted',
|
|
p_r='pay',
|
|
mode='perqt',
|
|
price=Decimal('2'),
|
|
currency=currency,
|
|
shipment_in=None,
|
|
sale_line=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
unit=unit,
|
|
)
|
|
fee.is_effective_fee_lot.side_effect = lambda lot: lot is physical
|
|
fee.get_price_per_qt.return_value = Decimal('2')
|
|
line = Mock(
|
|
id=7,
|
|
lots=[virtual, physical],
|
|
get_matched_lines=Mock(return_value=[]),
|
|
purchase=Mock(id=8, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee.line = line
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': Mock(),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(len(values), 1)
|
|
self.assertEqual(values[0]['lot'], physical.id)
|
|
self.assertEqual(values[0]['quantity'], Decimal('40.00000'))
|
|
self.assertEqual(values[0]['amount'], Decimal('-80.00'))
|
|
|
|
def test_purchase_rate_fee_amount_uses_virtual_lot_quantity(self):
|
|
'purchase rate fee amount uses the financing delta as absolute period'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.mode = 'rate'
|
|
fee.price = Decimal('12')
|
|
fee.fee_date = None
|
|
fee.quantity = None
|
|
fee.unit = Mock()
|
|
fee.shipment_in = None
|
|
lot = Mock()
|
|
lot.get_current_quantity_converted.return_value = Decimal('10')
|
|
fee.line = Mock(
|
|
unit_price=Decimal('100'),
|
|
lots=[lot],
|
|
estimated_date=[
|
|
Mock(
|
|
trigger='bldate',
|
|
estimated_date=datetime.date(2026, 5, 23),
|
|
fin_int_delta=10,
|
|
),
|
|
],
|
|
)
|
|
fee.sale_line = None
|
|
|
|
self.assertEqual(fee.get_amount(), Decimal('3.33'))
|
|
|
|
def test_sale_rate_fee_amount_uses_absolute_financing_delta(self):
|
|
'sale rate fee amount uses the financing delta as absolute period'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.mode = 'rate'
|
|
fee.price = Decimal('12')
|
|
fee.fee_date = None
|
|
fee.quantity = Decimal('10')
|
|
fee.unit = Mock()
|
|
fee.shipment_in = None
|
|
fee.line = None
|
|
fee.sale_line = Mock(
|
|
unit_price=Decimal('100'),
|
|
lots=[],
|
|
estimated_date=[
|
|
Mock(
|
|
trigger='bldate',
|
|
estimated_date=datetime.date(2026, 5, 23),
|
|
fin_int_delta=10,
|
|
),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(fee.get_amount(), Decimal('3.33'))
|
|
|
|
def test_purchase_line_bldate_uses_shipment_bl_before_estimated_date(self):
|
|
'purchase line BL trigger uses the real shipment BL date first'
|
|
Line = Pool().get('purchase.line')
|
|
line = Line()
|
|
line.lots = [
|
|
Mock(
|
|
lot_shipment_in=Mock(
|
|
bl_date=datetime.date(2026, 6, 3))),
|
|
]
|
|
line.estimated_date = [
|
|
Mock(
|
|
trigger='bldate',
|
|
estimated_date=datetime.date(2026, 5, 23)),
|
|
]
|
|
|
|
self.assertEqual(
|
|
line.get_date('bldate'),
|
|
datetime.date(2026, 6, 3))
|
|
|
|
def test_sale_line_bldate_uses_shipment_bl_before_estimated_date(self):
|
|
'sale line BL trigger uses the real shipment BL date first'
|
|
Line = Pool().get('sale.line')
|
|
line = Line()
|
|
line.lots = [
|
|
Mock(
|
|
lot_shipment_in=Mock(
|
|
bl_date=datetime.date(2026, 6, 3))),
|
|
]
|
|
line.estimated_date = [
|
|
Mock(
|
|
trigger='bldate',
|
|
estimated_date=datetime.date(2026, 5, 23)),
|
|
]
|
|
|
|
self.assertEqual(
|
|
line.get_date('bldate'),
|
|
datetime.date(2026, 6, 3))
|
|
|
|
def test_sale_rate_fee_amount_stays_positive_with_negative_delta(self):
|
|
'sale rate fee display amount is unsigned even when financing delta is negative'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.mode = 'rate'
|
|
fee.price = Decimal('12')
|
|
fee.fee_date = None
|
|
fee.quantity = Decimal('10')
|
|
fee.unit = Mock()
|
|
fee.shipment_in = None
|
|
fee.line = None
|
|
fee.sale_line = Mock(
|
|
unit_price=Decimal('100'),
|
|
lots=[],
|
|
estimated_date=[
|
|
Mock(
|
|
trigger='bldate',
|
|
estimated_date=datetime.date(2026, 5, 23),
|
|
fin_int_delta=-10,
|
|
),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(fee.get_amount(), Decimal('3.33'))
|
|
|
|
def test_fee_pnl_applies_pay_sign_to_unsigned_rate_amount(self):
|
|
'fee pnl sign is applied once from PAY/REC'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
lot = Mock()
|
|
lot.get_current_quantity_converted.return_value = Decimal('10')
|
|
fee = Mock(
|
|
mode='rate',
|
|
price=Decimal('12'),
|
|
unit=Mock(),
|
|
line=None,
|
|
sale_line=Mock(
|
|
unit_price=Decimal('100'),
|
|
estimated_date=[
|
|
Mock(trigger='bldate', fin_int_delta=-10),
|
|
],
|
|
),
|
|
)
|
|
|
|
self.assertEqual(
|
|
Valuation._fee_amount_for_lot_or_zero(fee, lot),
|
|
Decimal('3.33'))
|
|
|
|
def test_create_pnl_price_from_line_keeps_finished_physical_sale_line(self):
|
|
'purchase valuation keeps finished sale-side pnl on physical lots'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
company = Mock(currency=currency.id)
|
|
unit = Mock(id=1)
|
|
product = Mock(id=1)
|
|
party = Mock(id=1)
|
|
sale = Mock(id=2, currency=currency, company=company, party=party)
|
|
finished_sale_line = Mock(
|
|
finished=True,
|
|
price_type='priced',
|
|
mtm=[],
|
|
sale=sale,
|
|
unit=unit,
|
|
product=product,
|
|
)
|
|
sale_lot = Mock(
|
|
id=3,
|
|
sale_line=finished_sale_line,
|
|
lot_price_sale=Decimal('20'),
|
|
lot_type='physic',
|
|
)
|
|
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
|
|
purchase_lot = Mock(
|
|
id=4,
|
|
sale_line=None,
|
|
lot_price=Decimal('10'),
|
|
lot_type='physic',
|
|
)
|
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
|
|
line = Mock(
|
|
finished=False,
|
|
lots=[purchase_lot],
|
|
price_type='priced',
|
|
mtm=[],
|
|
purchase=Mock(id=1, currency=currency, company=company, party=party),
|
|
unit=unit,
|
|
product=product,
|
|
)
|
|
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.qt': lot_qt_model,
|
|
'currency.currency': Mock(),
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_price_from_line(line)
|
|
|
|
self.assertEqual(len(values), 2)
|
|
self.assertEqual(values[0]['type'], 'pur. priced')
|
|
self.assertEqual(values[0]['sale_line'], finished_sale_line.id)
|
|
self.assertEqual(values[1]['type'], 'sale priced')
|
|
self.assertEqual(values[1]['sale_line'], finished_sale_line.id)
|
|
self.assertEqual(values[1]['reference'], 'Sale/Physic')
|
|
|
|
def test_purchase_open_pnl_price_links_sale_from_lot_qt(self):
|
|
'purchase-side open price pnl is linked to the matched sale via lot.qt'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
company = Mock(currency=currency.id)
|
|
unit = Mock(id=1)
|
|
product = Mock(id=1)
|
|
purchase_party = Mock(id=1)
|
|
sale_party = Mock(id=2)
|
|
sale = Mock(id=3, currency=currency, company=company, party=sale_party)
|
|
sale_line = Mock(
|
|
id=4,
|
|
finished=False,
|
|
price_type='priced',
|
|
mtm=[],
|
|
sale=sale,
|
|
unit=unit,
|
|
product=product,
|
|
)
|
|
sale_lot = Mock(
|
|
id=5,
|
|
sale_line=sale_line,
|
|
lot_price_sale=Decimal('20'),
|
|
lot_type='virtual',
|
|
)
|
|
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
|
|
purchase_lot = Mock(
|
|
id=6,
|
|
sale_line=None,
|
|
lot_price=Decimal('10'),
|
|
lot_type='virtual',
|
|
)
|
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
|
|
line = Mock(
|
|
finished=False,
|
|
lots=[purchase_lot],
|
|
price_type='priced',
|
|
mtm=[],
|
|
purchase=Mock(
|
|
id=7, currency=currency, company=company, party=purchase_party),
|
|
unit=unit,
|
|
product=product,
|
|
)
|
|
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(
|
|
lot_s=sale_lot,
|
|
lot_quantity=Decimal('2'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=None)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.qt': lot_qt_model,
|
|
'currency.currency': Mock(),
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_price_from_line(line)
|
|
|
|
purchase_values = [v for v in values if v['type'] == 'pur. priced']
|
|
self.assertEqual(purchase_values[0]['sale'], sale.id)
|
|
self.assertEqual(purchase_values[0]['sale_line'], sale_line.id)
|
|
|
|
def test_purchase_open_pnl_fee_links_sale_from_lot_qt(self):
|
|
'purchase-side open fee pnl is linked to the matched sale via lot.qt'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
product = Mock(id=3, name='Financing interests')
|
|
supplier = Mock(id=4)
|
|
sale = Mock(id=5)
|
|
sale_line = Mock(id=6, sale=sale, finished=False)
|
|
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
|
|
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
|
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
|
|
fee = Mock(
|
|
product=product,
|
|
supplier=supplier,
|
|
type='budgeted',
|
|
p_r='pay',
|
|
mode='lumpsum',
|
|
price=Decimal('10'),
|
|
currency=currency,
|
|
shipment_in=None,
|
|
sale_line=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
unit=unit,
|
|
)
|
|
line = Mock(
|
|
id=9,
|
|
finished=False,
|
|
lots=[purchase_lot],
|
|
get_matched_lines=Mock(return_value=[]),
|
|
purchase=Mock(id=10, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee.line = line
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(
|
|
lot_s=sale_lot,
|
|
lot_quantity=Decimal('2'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=None)]
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': lot_qt_model,
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(values[0]['type'], 'pur. fee')
|
|
self.assertEqual(values[0]['sale'], sale.id)
|
|
self.assertEqual(values[0]['sale_line'], sale_line.id)
|
|
|
|
def test_purchase_pnl_fee_deduplicates_sale_lot_seen_twice(self):
|
|
'purchase-side fee pnl does not duplicate a sale lot already in matching'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
product = Mock(id=3, name='Broker commission')
|
|
supplier = Mock(id=4)
|
|
sale = Mock(id=5)
|
|
sale_line = Mock(id=6, sale=sale, finished=False)
|
|
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
|
|
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
|
|
fee = Mock(
|
|
product=product,
|
|
supplier=supplier,
|
|
type='budgeted',
|
|
p_r='pay',
|
|
mode='lumpsum',
|
|
price=Decimal('10'),
|
|
currency=currency,
|
|
shipment_in=None,
|
|
sale_line=sale_line,
|
|
unit=unit,
|
|
)
|
|
line = Mock(
|
|
id=9,
|
|
finished=False,
|
|
lots=[sale_lot],
|
|
get_matched_lines=Mock(return_value=[Mock(lot_s=sale_lot)]),
|
|
purchase=Mock(id=10, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 5, 14))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': Mock(),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(len(values), 1)
|
|
self.assertEqual(values[0]['type'], 'sale fee')
|
|
self.assertEqual(values[0]['lot'], sale_lot.id)
|
|
|
|
def test_purchase_open_fee_pnl_splits_by_matched_sale_lotqt(self):
|
|
'purchase open fee pnl keeps one segment per matched sale lot.qt'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
product = Mock(id=3, name='Broker commission')
|
|
supplier = Mock(id=4)
|
|
purchase = Mock(id=5, currency=currency, company=Mock(currency=currency))
|
|
line = Mock(id=6, purchase=purchase, unit=unit, finished=False)
|
|
purchase_lot = Mock(id=7, sale_line=None, lot_type='virtual')
|
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('60')
|
|
line.lots = [purchase_lot]
|
|
line.get_matched_lines.return_value = []
|
|
sales = [Mock(id=20 + index) for index in range(3)]
|
|
sale_lines = [
|
|
Mock(id=30 + index, sale=sales[index], finished=False)
|
|
for index in range(3)]
|
|
sale_lots = [
|
|
Mock(id=40 + index, sale_line=sale_lines[index])
|
|
for index in range(3)]
|
|
lot_qts = [
|
|
Mock(
|
|
id=50,
|
|
lot_s=sale_lots[0],
|
|
lot_quantity=Decimal('10'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=None),
|
|
Mock(
|
|
id=51,
|
|
lot_s=sale_lots[1],
|
|
lot_quantity=Decimal('20'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=None),
|
|
Mock(
|
|
id=52,
|
|
lot_s=sale_lots[2],
|
|
lot_quantity=Decimal('30'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=None),
|
|
]
|
|
fee = Mock(
|
|
id=60,
|
|
line=line,
|
|
sale_line=None,
|
|
shipment_in=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
product=product,
|
|
supplier=supplier,
|
|
type='budgeted',
|
|
p_r='pay',
|
|
mode='perqt',
|
|
price=Decimal('2'),
|
|
currency=currency,
|
|
unit=unit,
|
|
)
|
|
fee.get_price_per_qt.return_value = Decimal('2')
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = lot_qts
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 6, 30))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': lot_qt_model,
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(len(values), 3)
|
|
self.assertEqual(
|
|
[value['sale_line'] for value in values],
|
|
[sale_line.id for sale_line in sale_lines])
|
|
self.assertEqual(
|
|
[value['quantity'] for value in values],
|
|
[Decimal('10.00000'), Decimal('20.00000'), Decimal('30.00000')])
|
|
self.assertEqual(
|
|
[value['amount'] for value in values],
|
|
[Decimal('-20.00'), Decimal('-40.00'), Decimal('-60.00')])
|
|
self.assertEqual(len(Valuation._dedupe_values(values)), 3)
|
|
|
|
def test_sale_open_fee_pnl_splits_by_shipment_lotqt(self):
|
|
'sale open fee pnl is split between shipped and unshipped lot.qt'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
shipment = Mock(id=70)
|
|
sale = Mock(id=3, currency=currency)
|
|
sale_line = Mock(id=4, sale=sale, unit=unit, finished=False)
|
|
sale_lot = Mock(id=5, sale_line=sale_line, lot_type='virtual')
|
|
sale_lot.get_current_quantity_converted.return_value = Decimal('220')
|
|
sale_line.lots = [sale_lot]
|
|
fee = Mock(
|
|
id=12,
|
|
product=Mock(id=6, name='Broker commission'),
|
|
supplier=Mock(id=7),
|
|
type='budgeted',
|
|
p_r='pay',
|
|
mode='perqt',
|
|
price=Decimal('10'),
|
|
currency=currency,
|
|
shipment_in=None,
|
|
sale_line=sale_line,
|
|
line=None,
|
|
shipment_out=None,
|
|
shipment_internal=None,
|
|
unit=unit,
|
|
)
|
|
fee.is_effective_fee_lot.return_value = True
|
|
fee.get_price_per_qt.return_value = Decimal('10')
|
|
fee_lots = Mock()
|
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(
|
|
lot_s=sale_lot,
|
|
lot_quantity=Decimal('88'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=shipment),
|
|
Mock(
|
|
lot_s=sale_lot,
|
|
lot_quantity=Decimal('132'),
|
|
lot_unit=unit,
|
|
lot_shipment_in=None),
|
|
]
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 5, 14))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': lot_qt_model,
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
|
|
|
|
self.assertEqual(len(values), 2)
|
|
self.assertEqual(values[0]['fee'], fee.id)
|
|
self.assertEqual(values[1]['fee'], fee.id)
|
|
self.assertEqual(values[0]['shipment_in'], shipment.id)
|
|
self.assertEqual(values[0]['quantity'], Decimal('88.00000'))
|
|
self.assertEqual(values[0]['amount'], Decimal('-880.00'))
|
|
self.assertIsNone(values[1]['shipment_in'])
|
|
self.assertEqual(values[1]['quantity'], Decimal('132.00000'))
|
|
self.assertEqual(values[1]['amount'], Decimal('-1320.00'))
|
|
|
|
def test_shipment_fee_pnl_sums_all_shipment_lotqt(self):
|
|
'shipment fee pnl sums every lot.qt linked to the shipment'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
shipment = Mock(id=70)
|
|
unit = Mock(id=2)
|
|
lot = Mock(id=5, lot_type='virtual', sale_line=None)
|
|
lot.get_current_quantity_converted.return_value = Decimal('18000')
|
|
line = Mock(id=4, unit=unit)
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(id=1, lot_quantity=Decimal('5000'), lot_unit=unit,
|
|
lot_shipment_in=shipment),
|
|
Mock(id=2, lot_quantity=Decimal('6000'), lot_unit=unit,
|
|
lot_shipment_in=shipment),
|
|
Mock(id=3, lot_quantity=Decimal('7000'), lot_unit=unit,
|
|
lot_shipment_in=shipment),
|
|
]
|
|
fee = Mock(shipment_in=shipment)
|
|
|
|
segments = Valuation._fee_segments_for_fee(
|
|
fee, lot, line, lot_qt_model)
|
|
|
|
self.assertEqual(segments, [{
|
|
'quantity': Decimal('18000'),
|
|
'share': Decimal('1'),
|
|
'sale_lot': None,
|
|
'sale_line': None,
|
|
'shipment_in': shipment.id,
|
|
}])
|
|
|
|
def test_snapshot_identity_prefers_sale_line_over_purchase_line(self):
|
|
'valuation snapshot identity ignores purchase line when sale line exists'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
base = {
|
|
'purchase': 1,
|
|
'line': 2,
|
|
'sale': 3,
|
|
'sale_line': 4,
|
|
'lot': 5,
|
|
'type': 'sale fee',
|
|
'reference': 'Broker/Open',
|
|
'counterparty': 6,
|
|
'product': 7,
|
|
'state': 'budgeted',
|
|
'strategy': None,
|
|
'price': Decimal('10'),
|
|
'quantity': Decimal('2'),
|
|
'amount': Decimal('-20'),
|
|
}
|
|
regenerated = base.copy()
|
|
regenerated.update({
|
|
'purchase': None,
|
|
'line': None,
|
|
'price': Decimal('12'),
|
|
'quantity': Decimal('3'),
|
|
'amount': Decimal('-36'),
|
|
})
|
|
|
|
self.assertEqual(
|
|
Valuation._value_key(base),
|
|
Valuation._value_key(regenerated))
|
|
|
|
def test_snapshot_identity_keeps_mtm_curves_distinct(self):
|
|
'valuation snapshot identity keeps strategy curves distinct'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
base = {
|
|
'purchase': 1,
|
|
'line': 2,
|
|
'sale': None,
|
|
'sale_line': None,
|
|
'lot': 5,
|
|
'type': 'pur. priced',
|
|
'reference': 'Open',
|
|
'counterparty': 6,
|
|
'product': 7,
|
|
'state': 'fixed',
|
|
'strategy': 8,
|
|
'shipment_in': None,
|
|
'mtm_curve': 10,
|
|
}
|
|
other_curve = base.copy()
|
|
other_curve['mtm_curve'] = 20
|
|
|
|
self.assertNotEqual(
|
|
Valuation._value_key(base),
|
|
Valuation._value_key(other_curve))
|
|
self.assertIn(
|
|
('mtm_curve', '=', 10),
|
|
Valuation._snapshot_identity_domain(base))
|
|
|
|
def test_generate_from_matched_sale_line_does_not_create_snapshot(self):
|
|
'matched sale line pnl is owned by the matched purchase line'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
purchase_line = Mock(id=10)
|
|
sale_line = Mock()
|
|
sale_line.get_matched_lines.return_value = [
|
|
Mock(lot_p=Mock(line=purchase_line))]
|
|
|
|
with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object(
|
|
Valuation, 'create_pnl_fee_from_sale_line') as create_fees, patch.object(
|
|
Valuation, 'generate') as generate:
|
|
Valuation.generate_from_sale_line(sale_line, valuation_type='fees')
|
|
|
|
generate.assert_called_once_with(purchase_line, valuation_type='fees')
|
|
delete_existing.assert_not_called()
|
|
create_fees.assert_not_called()
|
|
|
|
def test_purchase_open_pnl_fee_skips_zero_virtual_lot(self):
|
|
'purchase-side fee pnl ignores open virtual lots with no quantity'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
|
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('0')
|
|
line = Mock(
|
|
id=9,
|
|
finished=False,
|
|
lots=[purchase_lot],
|
|
get_matched_lines=Mock(return_value=[]),
|
|
purchase=Mock(id=10, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee_lots = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': Mock(),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_line(line)
|
|
|
|
self.assertEqual(values, [])
|
|
fee_lots.search.assert_not_called()
|
|
|
|
def test_sale_open_pnl_fee_skips_zero_virtual_lot(self):
|
|
'sale-side fee pnl ignores open virtual lots with no quantity'
|
|
Valuation = Pool().get('valuation.valuation')
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
sale_lot = Mock(id=7, lot_type='virtual')
|
|
sale_lot.get_current_quantity_converted.return_value = Decimal('0')
|
|
sale_line = Mock(
|
|
id=6,
|
|
finished=False,
|
|
lots=[sale_lot],
|
|
sale=Mock(id=5, currency=currency),
|
|
unit=unit,
|
|
)
|
|
fee_lots = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
|
|
'currency.currency': Mock(),
|
|
'fee.lots': fee_lots,
|
|
'lot.qt': Mock(),
|
|
}[name]
|
|
|
|
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
|
|
|
|
self.assertEqual(values, [])
|
|
fee_lots.search.assert_not_called()
|
|
|
|
def test_sale_report_crop_name_handles_missing_crop(self):
|
|
'sale report crop name returns an empty string when crop is missing'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
sale = Sale()
|
|
sale.crop = None
|
|
self.assertEqual(sale.report_crop_name, '')
|
|
|
|
sale.crop = Mock(name='crop')
|
|
sale.crop.name = 'Main Crop'
|
|
self.assertEqual(sale.report_crop_name, 'Main Crop')
|
|
|
|
def test_sale_line_default_pricing_rule_comes_from_configuration(self):
|
|
'sale line pricing_rule defaults to the purchase_trade singleton value'
|
|
SaleLine = Pool().get('sale.line')
|
|
config = Mock(pricing_rule='Default pricing rule')
|
|
configuration_model = Mock()
|
|
configuration_model.search.return_value = [config]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.sale.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = configuration_model
|
|
self.assertEqual(
|
|
SaleLine.default_pricing_rule(), 'Default pricing rule')
|
|
|
|
def test_sale_line_compute_unit_price_keeps_existing_value_when_base_returns_none(self):
|
|
'sale line quantity changes keep the manual unit price when no sale price is found'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = SaleLine()
|
|
line.unit_price = Decimal('123.45')
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.super') as super_mock:
|
|
super_mock.return_value.compute_unit_price.return_value = None
|
|
|
|
self.assertEqual(
|
|
line.compute_unit_price(),
|
|
Decimal('123.45'))
|
|
|
|
def test_sale_line_linked_unit_factor_keeps_currency_factor_without_product(self):
|
|
'linked price conversion does not crash when automation builds a sale line before setting product'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = SaleLine()
|
|
line.enable_linked_currency = True
|
|
line.linked_currency = Mock(factor=Decimal('2'))
|
|
line.linked_unit = Mock()
|
|
line.unit = None
|
|
|
|
self.assertEqual(line._get_linked_unit_factor(), Decimal('2'))
|
|
|
|
def test_get_sla_cost_returns_empty_tuple_when_no_place_matches(self):
|
|
'controller sla helper keeps a stable tuple contract when no sla rule matches'
|
|
Party = Pool().get('party.party')
|
|
party = Party()
|
|
party.sla = []
|
|
|
|
self.assertEqual(
|
|
party.get_sla_cost(Mock()),
|
|
(None, None, None, None))
|
|
|
|
def test_get_sla_cost_matches_country_without_location(self):
|
|
'controller sla helper can match a destination country'
|
|
Party = Pool().get('party.party')
|
|
party = Party()
|
|
sla = Mock(id=1)
|
|
party.sla = [sla]
|
|
country = Mock(id=10)
|
|
location = Mock(country=country)
|
|
country_place = Mock(
|
|
country=country,
|
|
location=None,
|
|
cost=Decimal('12'),
|
|
mode='ppack',
|
|
currency=Mock(id=1),
|
|
unit=Mock(id=2),
|
|
)
|
|
place_model = Mock()
|
|
place_model.search.return_value = [country_place]
|
|
|
|
with patch('trytond.modules.purchase_trade.party.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = place_model
|
|
|
|
self.assertEqual(
|
|
party.get_sla_cost(location),
|
|
(
|
|
country_place.cost,
|
|
country_place.mode,
|
|
country_place.currency,
|
|
country_place.unit,
|
|
))
|
|
|
|
def test_get_sla_cost_prioritizes_country_location_pair(self):
|
|
'controller sla helper prefers country and location over either alone'
|
|
Party = Pool().get('party.party')
|
|
party = Party()
|
|
sla = Mock(id=1)
|
|
party.sla = [sla]
|
|
country = Mock(id=10)
|
|
location = Mock(country=country)
|
|
country_only = Mock(
|
|
country=country,
|
|
location=None,
|
|
cost=Decimal('12'),
|
|
mode='ppack',
|
|
currency=Mock(id=1),
|
|
unit=Mock(id=2),
|
|
)
|
|
location_only = Mock(
|
|
country=None,
|
|
location=location,
|
|
cost=Decimal('14'),
|
|
mode='perqt',
|
|
currency=Mock(id=3),
|
|
unit=Mock(id=4),
|
|
)
|
|
pair = Mock(
|
|
country=country,
|
|
location=location,
|
|
cost=Decimal('16'),
|
|
mode='rate',
|
|
currency=Mock(id=5),
|
|
unit=Mock(id=6),
|
|
)
|
|
place_model = Mock()
|
|
place_model.search.return_value = [country_only, location_only, pair]
|
|
|
|
with patch('trytond.modules.purchase_trade.party.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = place_model
|
|
|
|
self.assertEqual(
|
|
party.get_sla_cost(location),
|
|
(pair.cost, pair.mode, pair.currency, pair.unit))
|
|
|
|
def test_get_party_by_name_adds_missing_category_to_existing_party(self):
|
|
'existing parties found by automation gain the requested category when missing'
|
|
Party = Pool().get('party.party')
|
|
existing_party = Mock(id=12)
|
|
category = Mock(id=34)
|
|
category_model = Mock()
|
|
category_model.search.return_value = [category]
|
|
party_category_model = Mock()
|
|
party_category_model.search.return_value = []
|
|
|
|
with patch.object(Party, 'search', return_value=[existing_party]), patch(
|
|
'trytond.modules.purchase_trade.party.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'party.category': category_model,
|
|
'party.party-party.category': party_category_model,
|
|
}[name]
|
|
|
|
party = Party.getPartyByName('SYED SP COT D BD', 'CLIENT')
|
|
|
|
self.assertIs(party, existing_party)
|
|
party_category_model.save.assert_called_once()
|
|
|
|
def test_create_fee_skips_when_controller_has_no_sla_cost(self):
|
|
'shipment fee creation skips cleanly when controller has no matching sla'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.id = 99
|
|
shipment.to_location = Mock()
|
|
shipment.get_bales = Mock(return_value=Decimal('10'))
|
|
controller = Mock()
|
|
controller.get_sla_cost.return_value = (None, None, None, None)
|
|
|
|
fee_model = Mock()
|
|
product_model = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'fee.fee': fee_model,
|
|
'product.product': product_model,
|
|
}[name]
|
|
|
|
shipment.create_fee(controller)
|
|
|
|
fee_model.save.assert_not_called()
|
|
product_model.get_by_name.assert_not_called()
|
|
|
|
def test_shipment_started_cancel_deletes_draft_stock_account_moves(self):
|
|
'started shipment cancel removes draft account moves linked to stock moves'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
draft_move = Mock(state='draft')
|
|
account_move = Mock()
|
|
account_move.search.return_value = [draft_move]
|
|
stock_moves = [Mock(id=10), Mock(id=20)]
|
|
|
|
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = account_move
|
|
|
|
ShipmentIn._delete_draft_account_moves_for_stock_moves(stock_moves)
|
|
|
|
account_move.search.assert_called_once_with([
|
|
('origin', 'in', ['stock.move,10', 'stock.move,20']),
|
|
])
|
|
account_move.delete.assert_called_once_with([draft_move])
|
|
|
|
def test_shipment_started_cancel_refuses_posted_stock_account_moves(self):
|
|
'started shipment cancel is blocked by posted stock account moves'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
posted_move = Mock(state='posted', rec_name='STJ/1')
|
|
account_move = Mock()
|
|
account_move.search.return_value = [posted_move]
|
|
|
|
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = account_move
|
|
|
|
with self.assertRaises(UserError):
|
|
ShipmentIn._delete_draft_account_moves_for_stock_moves([
|
|
Mock(id=10),
|
|
])
|
|
|
|
account_move.delete.assert_not_called()
|
|
|
|
@with_transaction()
|
|
def test_shipment_in_type_detects_dropship(self):
|
|
'shipment type is dropship from supplier directly to customer'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.from_location = Mock(type='supplier')
|
|
shipment.to_location = Mock(type='customer')
|
|
|
|
self.assertEqual(shipment.get_shipment_type(), 'dropship')
|
|
|
|
@with_transaction()
|
|
def test_shipment_in_type_defaults_to_inbound(self):
|
|
'shipment type is inbound for non supplier-to-customer routes'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.from_location = Mock(type='supplier')
|
|
shipment.to_location = Mock(type='storage')
|
|
|
|
self.assertEqual(shipment.get_shipment_type(), 'inbound')
|
|
|
|
@with_transaction()
|
|
def test_shipment_in_location_domains_follow_trade_direction(self):
|
|
'shipment locations exclude invalid trade counterpart directions'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
|
|
self.assertEqual(
|
|
ShipmentIn.from_location.domain,
|
|
[('type', '!=', 'customer')])
|
|
self.assertEqual(
|
|
ShipmentIn.to_location.domain,
|
|
[('type', '!=', 'supplier')])
|
|
|
|
@with_transaction()
|
|
def test_lot_report_shipment_type_uses_linked_shipment(self):
|
|
'lot report shipment type mirrors the linked shipment_in'
|
|
LotReport = Pool().get('lot.report')
|
|
report = LotReport()
|
|
report.r_lot_shipment_in = Mock(shipment_type='dropship')
|
|
report.r_from_l = Mock(type='storage')
|
|
report.r_from_t = Mock(type='storage')
|
|
|
|
self.assertEqual(report.get_shipment_type(None), 'dropship')
|
|
|
|
def test_lot_report_exposes_purchase_creator_and_date(self):
|
|
'lots management exposes the purchase creator and purchase date'
|
|
LotReport = Pool().get('lot.report')
|
|
report = LotReport()
|
|
report.r_purchase = Mock(
|
|
create_uid=Mock(id=42),
|
|
purchase_date=datetime.date(2026, 6, 22),
|
|
)
|
|
|
|
self.assertEqual(report.get_purchase_creator(), 42)
|
|
self.assertEqual(
|
|
report.get_purchase_date(), datetime.date(2026, 6, 22))
|
|
|
|
@with_transaction()
|
|
def test_shipment_in_quantity_uses_lotqt_without_physical_moves(self):
|
|
'shipment quantity falls back to linked open lots before physical moves'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.incoming_moves = []
|
|
shipment.lotqt = [
|
|
Mock(lot_quantity=Decimal('100.50')),
|
|
Mock(lot_quantity=Decimal('20.25')),
|
|
]
|
|
|
|
self.assertEqual(shipment.get_quantity(), Decimal('120.75'))
|
|
|
|
def test_sof_quantity_uses_shipment_quantity_fallback(self):
|
|
'demurrage quantity uses shipment quantity including linked open lots'
|
|
sof = stock_module.StatementOfFacts()
|
|
sof.shipment = Mock(get_quantity=Mock(return_value=Decimal('442')))
|
|
|
|
self.assertEqual(sof.get_qt(None), Decimal('442'))
|
|
|
|
def test_fee_ppack_auto_uses_lot_unit_line_when_lot_unit_missing(self):
|
|
'per packing auto quantity falls back to lot_unit_line'
|
|
bale = Mock(factor=Decimal('1'))
|
|
pack = Mock(factor=Decimal('10'))
|
|
lot = Mock(lot_unit=None, lot_unit_line=bale)
|
|
lot.get_current_quantity_converted.side_effect = (
|
|
lambda state=0, unit=None: Decimal('25')
|
|
if unit else Decimal('250'))
|
|
fee = fee_module.Fee()
|
|
fee.line = Mock(lots=[lot])
|
|
fee.sale_line = None
|
|
fee.shipment_in = None
|
|
fee.mode = 'ppack'
|
|
fee.auto_calculation = True
|
|
fee.unit = pack
|
|
|
|
self.assertEqual(fee.on_change_with_quantity(), Decimal('25'))
|
|
|
|
def test_fee_currency_is_conditionally_required_for_linked_currency(self):
|
|
'fee currency does not block linked currency draft saves'
|
|
self.assertFalse(fee_module.Fee.currency.required)
|
|
self.assertIn('required', fee_module.Fee.currency.states)
|
|
self.assertEqual(
|
|
fee_module.Fee.currency.states['required'],
|
|
~Eval('enable_linked_currency'))
|
|
|
|
for field in (
|
|
fee_module.Fee.linked_price,
|
|
fee_module.Fee.linked_currency,
|
|
fee_module.Fee.linked_unit):
|
|
self.assertFalse(field.required)
|
|
self.assertNotIn('required', field.states)
|
|
|
|
def test_fee_ensure_ordered_purchase_creates_missing_service_purchase(self):
|
|
'ordered fee creates missing purchase after linked values are completed'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.id = 10
|
|
fee.type = 'ordered'
|
|
fee.purchase = None
|
|
fee.currency = Mock()
|
|
fee.price = Decimal('12')
|
|
fee.mode = 'perqt'
|
|
fee.product = Mock()
|
|
fee.supplier = Mock(addresses=[])
|
|
fee.quantity = Decimal('5')
|
|
fee.unit = Mock()
|
|
fee.shipment_in = None
|
|
fee.sale_line = None
|
|
fee.line = Mock(
|
|
purchase=Mock(
|
|
from_location=Mock(),
|
|
to_location=Mock(),
|
|
payment_term=Mock()))
|
|
|
|
purchase = Mock()
|
|
purchase_model = Mock(return_value=purchase)
|
|
line = Mock()
|
|
line_model = Mock(return_value=line)
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.fee.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.fee.Transaction'
|
|
) as TransactionMock, patch.object(
|
|
Fee, 'save') as save, patch.object(
|
|
fee, '_get_effective_fee_lots', return_value=[]), patch.object(
|
|
fee, '_create_fee_accruals') as create_accruals:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'purchase.purchase': purchase_model,
|
|
'purchase.line': line_model,
|
|
}[name]
|
|
TransactionMock.return_value.set_context.return_value = (
|
|
nullcontext())
|
|
|
|
self.assertTrue(fee.ensure_ordered_purchase())
|
|
|
|
self.assertEqual(fee.purchase, purchase)
|
|
self.assertEqual(purchase.lines, [line])
|
|
self.assertEqual(purchase.currency, fee.currency)
|
|
self.assertEqual(line.fee_, fee.id)
|
|
self.assertEqual(line.unit_price, Decimal('12.0000'))
|
|
purchase_model.save.assert_called_once_with([purchase])
|
|
save.assert_called_once_with([fee])
|
|
create_accruals.assert_called_once_with()
|
|
|
|
def test_purchase_service_fee_invoice_reverses_previous_line(self):
|
|
're-invoiced service fee creates a reversal and a current line'
|
|
Line = base_purchase_module.Line
|
|
line = Line()
|
|
line.purchase = Mock(id=20)
|
|
line.unit_price = Decimal('15')
|
|
lot = Mock(id=30)
|
|
invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('15'))
|
|
fee = Mock(
|
|
state='invoiced',
|
|
mode='perqt',
|
|
get_fee_lots_qt=Mock(return_value=Decimal('12')))
|
|
previous_line = Mock(
|
|
quantity=Decimal('10'),
|
|
unit_price=Decimal('11'),
|
|
invoice=Mock(party=Mock()))
|
|
reversal_line = Mock()
|
|
fee_model = Mock()
|
|
fee_model.search.return_value = [fee]
|
|
invoice_line_model = Mock()
|
|
invoice_line_model.copy.return_value = [reversal_line]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase.purchase.Pool'
|
|
) as PoolMock, patch.object(
|
|
Line, '_get_last_fee_invoice_line',
|
|
return_value=previous_line):
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'fee.fee': fee_model,
|
|
'account.invoice.line': invoice_line_model,
|
|
}[name]
|
|
|
|
result = line._get_service_fee_invoice_lines(invoice_line, lot)
|
|
|
|
self.assertEqual(result, [reversal_line, invoice_line])
|
|
self.assertEqual(invoice_line.fee, fee)
|
|
self.assertEqual(invoice_line.quantity, Decimal('12'))
|
|
fee.get_fee_lots_qt.assert_called_once_with()
|
|
invoice_line_model.copy.assert_called_once_with(
|
|
[previous_line], default={
|
|
'invoice': None,
|
|
'quantity': Decimal('-10'),
|
|
'unit_price': Decimal('11'),
|
|
'party': previous_line.invoice.party,
|
|
'origin': str(line),
|
|
})
|
|
|
|
def test_purchase_service_fee_invoice_skips_unchanged_reinvoice(self):
|
|
'unchanged re-invoiced service fee does not create a zero note'
|
|
Line = base_purchase_module.Line
|
|
line = Line()
|
|
line.purchase = Mock(id=20)
|
|
lot = Mock(id=30)
|
|
invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('15'))
|
|
fee = Mock(
|
|
state='invoiced',
|
|
mode='perqt',
|
|
get_fee_lots_qt=Mock(return_value=Decimal('12')))
|
|
previous_line = Mock(
|
|
quantity=Decimal('12'),
|
|
unit_price=Decimal('15'),
|
|
invoice=Mock(party=Mock()))
|
|
fee_model = Mock()
|
|
fee_model.search.return_value = [fee]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase.purchase.Pool'
|
|
) as PoolMock, patch.object(
|
|
Line, '_get_last_fee_invoice_line',
|
|
return_value=previous_line):
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'fee.fee': fee_model,
|
|
}[name]
|
|
|
|
self.assertEqual(
|
|
line._get_service_fee_invoice_lines(invoice_line, lot), [])
|
|
fee.get_fee_lots_qt.assert_called_once_with()
|
|
|
|
def test_purchase_service_percent_price_fee_invoice_uses_amount_line(self):
|
|
'% price service fee invoice uses a single amount line'
|
|
Line = base_purchase_module.Line
|
|
line = Line()
|
|
line.purchase = Mock(id=20)
|
|
lot = Mock(id=30)
|
|
invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('50'))
|
|
fee = Mock(
|
|
state='not invoiced',
|
|
mode='pprice',
|
|
warn_percent_price_partial_lots=Mock())
|
|
fee_model = Mock()
|
|
fee_model.search.return_value = [fee]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase.purchase.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'fee.fee': fee_model,
|
|
}[name]
|
|
|
|
result = line._get_service_fee_invoice_lines(invoice_line, lot)
|
|
|
|
self.assertEqual(result, [invoice_line])
|
|
self.assertEqual(invoice_line.fee, fee)
|
|
self.assertEqual(invoice_line.quantity, 1)
|
|
self.assertEqual(invoice_line.unit_price, Decimal('50'))
|
|
fee.warn_percent_price_partial_lots.assert_called_once_with()
|
|
|
|
def test_percent_price_fee_amount_uses_full_line_amount(self):
|
|
'% price fee ignores quantity when all line lots are linked'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
lot = SimpleNamespace(id=1, lot_type='physic')
|
|
fee.mode = 'pprice'
|
|
fee.price = Decimal('2')
|
|
fee.quantity = Decimal('999')
|
|
fee.sale_line = None
|
|
fee.shipment_in = None
|
|
fee.line = SimpleNamespace(
|
|
amount=Decimal('1000'),
|
|
unit_price=Decimal('10'),
|
|
lots=[lot])
|
|
|
|
with patch.object(
|
|
fee, '_get_effective_fee_lots', return_value=[lot]):
|
|
self.assertEqual(fee.get_amount(), Decimal('20.00'))
|
|
|
|
def test_per_quantity_fee_with_padding_uses_lots_plus_padding(self):
|
|
'per quantity fee can bill linked lots plus sale invoice padding'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
unit = SimpleNamespace(id=1)
|
|
sale_line = SimpleNamespace(unit=unit)
|
|
lot = SimpleNamespace(
|
|
id=1,
|
|
lot_type='physic',
|
|
sale_line=sale_line,
|
|
sale_invoice_padding=Decimal('2'),
|
|
lot_hist=[],
|
|
get_current_quantity_converted=(
|
|
lambda state=0, unit=None: Decimal('5')))
|
|
fee.mode = 'perqt'
|
|
fee.price = Decimal('10')
|
|
fee.add_padding = True
|
|
fee.unit = unit
|
|
fee.quantity = Decimal('5')
|
|
fee.line = None
|
|
fee.sale_line = sale_line
|
|
fee.shipment_in = None
|
|
|
|
with patch.object(
|
|
fee, '_get_effective_fee_lots', return_value=[lot]):
|
|
self.assertEqual(fee.get_billing_quantity(), Decimal('7'))
|
|
self.assertEqual(fee.get_amount(), Decimal('70.00'))
|
|
|
|
def test_percent_price_fee_with_padding_uses_lots_padding_unit_price(self):
|
|
'% price fee with padding uses fee lots plus padding as amount base'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
unit = SimpleNamespace(id=1)
|
|
sale_line = SimpleNamespace(
|
|
unit=unit,
|
|
unit_price=Decimal('100'),
|
|
amount=Decimal('9999'))
|
|
lot = SimpleNamespace(
|
|
id=1,
|
|
lot_type='physic',
|
|
sale_line=sale_line,
|
|
sale_invoice_padding=Decimal('2'),
|
|
lot_hist=[],
|
|
get_current_quantity_converted=(
|
|
lambda state=0, unit=None: Decimal('5')))
|
|
fee.mode = 'pprice'
|
|
fee.price = Decimal('2')
|
|
fee.add_padding = True
|
|
fee.state = 'not invoiced'
|
|
fee.unit = unit
|
|
fee.quantity = Decimal('5')
|
|
fee.line = None
|
|
fee.sale_line = sale_line
|
|
fee.shipment_in = None
|
|
|
|
with patch.object(
|
|
fee, '_get_effective_fee_lots', return_value=[lot]):
|
|
self.assertEqual(fee.get_amount(), Decimal('14.00'))
|
|
|
|
def test_fee_dn_cn_amount_ignores_padding_for_current_value(self):
|
|
'fee DN/CN current value ignores padding while reversal keeps prior line'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
unit = SimpleNamespace(id=1)
|
|
sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100'))
|
|
lot = SimpleNamespace(
|
|
id=1,
|
|
lot_type='physic',
|
|
sale_line=sale_line,
|
|
sale_invoice_padding=Decimal('2'),
|
|
lot_hist=[],
|
|
get_current_quantity_converted=(
|
|
lambda state=0, unit=None: Decimal('5')))
|
|
fee.mode = 'pprice'
|
|
fee.price = Decimal('2')
|
|
fee.add_padding = True
|
|
fee.unit = unit
|
|
fee.quantity = Decimal('5')
|
|
fee.line = None
|
|
fee.sale_line = sale_line
|
|
fee.shipment_in = None
|
|
|
|
with patch.object(
|
|
fee, '_get_effective_fee_lots', return_value=[lot]):
|
|
self.assertEqual(fee.get_amount(), Decimal('14.00'))
|
|
fee.state = 'invoiced'
|
|
self.assertEqual(fee.get_amount(), Decimal('10.00'))
|
|
with Transaction().set_context(
|
|
_purchase_trade_ignore_fee_padding=True):
|
|
self.assertEqual(fee.get_amount(), Decimal('10.00'))
|
|
|
|
def test_percent_price_fee_warns_when_line_lots_are_partial(self):
|
|
'% price fee warns when it does not cover all line lots'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
lot_1 = SimpleNamespace(id=1, lot_type='physic')
|
|
lot_2 = SimpleNamespace(id=2, lot_type='physic')
|
|
fee.mode = 'pprice'
|
|
fee.line = SimpleNamespace(lots=[lot_1, lot_2])
|
|
fee.sale_line = None
|
|
warning = Mock()
|
|
warning.format.return_value = 'partial-pprice-fee'
|
|
warning.check.return_value = True
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.fee.Pool'
|
|
) as PoolMock, patch.object(
|
|
fee, '_get_effective_fee_lots', return_value=[lot_1]):
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'res.user.warning': warning,
|
|
}[name]
|
|
|
|
with self.assertRaises(UserWarning):
|
|
fee.warn_percent_price_partial_lots()
|
|
|
|
warning.format.assert_called_once_with(
|
|
"Partial % price fee lots", [fee])
|
|
warning.check.assert_called_once_with('partial-pprice-fee')
|
|
|
|
def test_percent_price_fee_adjusts_purchase_to_amount_line(self):
|
|
'% price fee keeps generated purchase as one amount line'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.type = 'ordered'
|
|
fee.mode = 'pprice'
|
|
fee.amount = Decimal('20')
|
|
fee.product = Mock()
|
|
fee.supplier = Mock()
|
|
fee.currency = Mock()
|
|
purchase_line = Mock(
|
|
unit_price=Decimal('10'),
|
|
quantity=Decimal('999'),
|
|
product=fee.product)
|
|
fee.purchase = Mock(
|
|
lines=[purchase_line],
|
|
party=fee.supplier,
|
|
currency=fee.currency)
|
|
purchase_model = Mock()
|
|
purchase_line_model = Mock()
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.fee.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'purchase.purchase': purchase_model,
|
|
'purchase.line': purchase_line_model,
|
|
}[name]
|
|
|
|
fee.adjust_purchase_values()
|
|
|
|
self.assertEqual(purchase_line.unit_price, Decimal('20'))
|
|
self.assertEqual(purchase_line.quantity, 1)
|
|
purchase_line_model.save.assert_called_once_with([purchase_line])
|
|
purchase_model.save.assert_called_once_with([fee.purchase])
|
|
|
|
def test_per_quantity_fee_padding_adjusts_generated_purchase_quantity(self):
|
|
'per quantity fee with padding updates the generated purchase quantity'
|
|
Fee = Pool().get('fee.fee')
|
|
fee = Fee()
|
|
fee.type = 'ordered'
|
|
fee.mode = 'perqt'
|
|
fee.add_padding = True
|
|
fee.price = Decimal('10')
|
|
fee.product = Mock()
|
|
fee.supplier = Mock()
|
|
fee.currency = Mock()
|
|
purchase_line = Mock(
|
|
unit_price=Decimal('10'),
|
|
quantity=Decimal('5'),
|
|
product=fee.product)
|
|
fee.purchase = Mock(
|
|
lines=[purchase_line],
|
|
party=fee.supplier,
|
|
currency=fee.currency)
|
|
purchase_model = Mock()
|
|
purchase_line_model = Mock()
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.fee.Pool'
|
|
) as PoolMock, patch.object(
|
|
fee, 'get_billing_quantity',
|
|
return_value=Decimal('7')):
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'purchase.purchase': purchase_model,
|
|
'purchase.line': purchase_line_model,
|
|
}[name]
|
|
|
|
fee.adjust_purchase_values()
|
|
|
|
self.assertEqual(purchase_line.unit_price, Decimal('10'))
|
|
self.assertEqual(purchase_line.quantity, Decimal('7'))
|
|
purchase_line_model.save.assert_called_once_with([purchase_line])
|
|
purchase_model.save.assert_called_once_with([fee.purchase])
|
|
|
|
def test_purchase_trade_service_fee_invoice_uses_padding_quantity(self):
|
|
'service fee invoice line uses billing quantity when padding is enabled'
|
|
Line = purchase_module.Line
|
|
line = Line()
|
|
line.purchase = Mock(id=20)
|
|
lot = Mock(id=30)
|
|
invoice_line = Mock(quantity=Decimal('5'), unit_price=Decimal('10'))
|
|
fee = Mock(
|
|
state='not invoiced',
|
|
mode='perqt',
|
|
add_padding=True,
|
|
warn_percent_price_partial_lots=Mock(),
|
|
get_billing_quantity=Mock(return_value=Decimal('7')))
|
|
fee_model = Mock()
|
|
fee_model.search.return_value = [fee]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'fee.fee': fee_model,
|
|
}[name]
|
|
|
|
result = line._get_service_fee_invoice_lines(invoice_line, lot)
|
|
|
|
self.assertEqual(result, [invoice_line])
|
|
self.assertEqual(invoice_line.fee, fee)
|
|
self.assertEqual(invoice_line.quantity, Decimal('7'))
|
|
fee.get_billing_quantity.assert_called_once_with()
|
|
|
|
def test_purchase_trade_service_fee_dn_cn_ignores_padding_quantity(self):
|
|
'fee DN/CN current invoice line uses final lot quantity without padding'
|
|
Line = purchase_module.Line
|
|
line = Line()
|
|
line.purchase = Mock(id=20)
|
|
lot = Mock(id=30)
|
|
invoice_line = Mock(quantity=Decimal('7'), unit_price=Decimal('10'))
|
|
fee = Mock(
|
|
state='invoiced',
|
|
mode='perqt',
|
|
add_padding=True,
|
|
warn_percent_price_partial_lots=Mock(),
|
|
get_billing_quantity=Mock(return_value=Decimal('7')),
|
|
get_fee_lots_qt=Mock(return_value=Decimal('5')))
|
|
fee_model = Mock()
|
|
fee_model.search.return_value = [fee]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock, patch.object(
|
|
Line, '_get_last_fee_invoice_line',
|
|
return_value=Mock(
|
|
quantity=Decimal('7'),
|
|
unit_price=Decimal('10'),
|
|
invoice=Mock(party=Mock()))), patch.object(
|
|
Line, '_get_fee_reversal_invoice_line',
|
|
return_value=Mock()):
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'fee.fee': fee_model,
|
|
}[name]
|
|
|
|
with Transaction().set_context(
|
|
_purchase_trade_ignore_fee_padding=True):
|
|
result = line._get_service_fee_invoice_lines(
|
|
invoice_line, lot)
|
|
|
|
self.assertEqual(result, [ANY, invoice_line])
|
|
self.assertEqual(invoice_line.quantity, Decimal('5'))
|
|
fee.get_billing_quantity.assert_not_called()
|
|
fee.get_fee_lots_qt.assert_called_once_with()
|
|
|
|
def test_lot_invoice_sale_padding_refreshes_per_quantity_fee_amount(self):
|
|
'sale padding refreshes displayed per quantity fee amount'
|
|
start = lot_module.LotInvoiceStart()
|
|
unit = SimpleNamespace(id=1)
|
|
lot = SimpleNamespace(id=10, sale_line=SimpleNamespace(unit=unit))
|
|
fee = SimpleNamespace(
|
|
add_padding=True,
|
|
mode='perqt',
|
|
price=Decimal('10'),
|
|
quantity=Decimal('5'),
|
|
_get_effective_fee_lots=Mock(return_value=[lot]),
|
|
_get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')),
|
|
_convert_padding_quantity=Mock(
|
|
side_effect=lambda quantity, unit: Decimal(str(quantity))),
|
|
get_quantity=Mock(return_value=Decimal('5')))
|
|
fee_line = SimpleNamespace(
|
|
fee=fee,
|
|
fee_quantity=Decimal('5'),
|
|
fee_amount=Decimal('50'))
|
|
start.type = 'sale'
|
|
start.sale_padding = Decimal('2')
|
|
start.lot_s = [SimpleNamespace(lot=lot)]
|
|
start.fee_sale = [fee_line]
|
|
|
|
start.on_change_sale_padding()
|
|
|
|
self.assertEqual(fee_line.fee_quantity, Decimal('7'))
|
|
self.assertEqual(fee_line.fee_amount, Decimal('70.00'))
|
|
|
|
def test_lot_invoice_sale_padding_refreshes_percent_price_fee_amount(self):
|
|
'sale padding refreshes displayed percent price fee amount'
|
|
start = lot_module.LotInvoiceStart()
|
|
unit = SimpleNamespace(id=1)
|
|
sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100'))
|
|
lot = SimpleNamespace(id=10, sale_line=sale_line)
|
|
fee = SimpleNamespace(
|
|
add_padding=True,
|
|
mode='pprice',
|
|
price=Decimal('2'),
|
|
quantity=Decimal('5'),
|
|
sale_line=sale_line,
|
|
_get_effective_fee_lots=Mock(return_value=[lot]),
|
|
_get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')),
|
|
_convert_padding_quantity=Mock(
|
|
side_effect=lambda quantity, unit: Decimal(str(quantity))),
|
|
get_quantity=Mock(return_value=Decimal('5')))
|
|
fee_line = SimpleNamespace(
|
|
fee=fee,
|
|
fee_quantity=Decimal('5'),
|
|
fee_amount=Decimal('10'))
|
|
start.type = 'sale'
|
|
start.sale_padding = Decimal('2')
|
|
start.lot_s = [SimpleNamespace(lot=lot)]
|
|
start.fee_sale = [fee_line]
|
|
|
|
start.on_change_sale_padding()
|
|
|
|
self.assertEqual(fee_line.fee_quantity, Decimal('7'))
|
|
self.assertEqual(fee_line.fee_amount, Decimal('14.00'))
|
|
|
|
def test_lot_invoice_final_existing_fee_display_ignores_padding(self):
|
|
'final DN/CN fee display ignores padding for the current value'
|
|
start = lot_module.LotInvoiceStart()
|
|
unit = SimpleNamespace(id=1)
|
|
sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100'))
|
|
lot = SimpleNamespace(id=10, sale_line=sale_line)
|
|
fee = SimpleNamespace(
|
|
add_padding=True,
|
|
state='invoiced',
|
|
mode='pprice',
|
|
price=Decimal('2'),
|
|
quantity=Decimal('5'),
|
|
sale_line=sale_line,
|
|
_get_effective_fee_lots=Mock(return_value=[lot]),
|
|
_get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')),
|
|
_convert_padding_quantity=Mock(
|
|
side_effect=lambda quantity, unit: Decimal(str(quantity))),
|
|
get_quantity=Mock(return_value=Decimal('5')))
|
|
fee_line = SimpleNamespace(
|
|
fee=fee,
|
|
fee_quantity=Decimal('5'),
|
|
fee_amount=Decimal('10'))
|
|
start.type = 'sale'
|
|
start.action = 'final'
|
|
start.sale_padding = Decimal('2')
|
|
start.lot_s = [SimpleNamespace(lot=lot)]
|
|
start.fee_sale = [fee_line]
|
|
|
|
start.on_change_sale_padding()
|
|
|
|
self.assertEqual(fee_line.fee_quantity, Decimal('5'))
|
|
self.assertEqual(fee_line.fee_amount, Decimal('10.00'))
|
|
|
|
def test_lot_invoice_default_final_fee_display_ignores_padding(self):
|
|
'initial final fee display reads amount without padding'
|
|
class FeeStub:
|
|
state = 'invoiced'
|
|
add_padding = True
|
|
|
|
@property
|
|
def amount(self):
|
|
if Transaction().context.get(
|
|
'_purchase_trade_ignore_fee_padding'):
|
|
return Decimal('14970')
|
|
return Decimal('14985')
|
|
|
|
fee = FeeStub()
|
|
|
|
self.assertEqual(
|
|
lot_module.LotInvoice._fee_display_amount(fee, 'final'),
|
|
Decimal('14970'))
|
|
|
|
def test_fee_get_non_cog_returns_zero_without_move_lines(self):
|
|
'fee non-cog amount is zero before any accounting move line exists'
|
|
fee = fee_module.Fee()
|
|
fee.id = 1
|
|
fee.product = Mock(account_stock_in_used=Mock(id=2))
|
|
lot = Mock(id=3)
|
|
move_line = Mock()
|
|
move_line.search.return_value = []
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'account.move.line': move_line,
|
|
'currency.currency': Mock(),
|
|
'ir.date': Mock(),
|
|
'account.configuration': Mock(return_value=Mock()),
|
|
'product.uom': Mock(),
|
|
}[name]
|
|
|
|
self.assertEqual(fee.get_non_cog(lot), Decimal(0))
|
|
|
|
def test_fee_get_cog_returns_zero_without_move_lines(self):
|
|
'fee cog amount is zero before any stock accounting move line exists'
|
|
fee = fee_module.Fee()
|
|
fee.id = 1
|
|
fee.product = Mock(account_stock_in_used=Mock(id=2))
|
|
lot = Mock(id=3)
|
|
move_line = Mock()
|
|
move_line.search.return_value = []
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'account.move.line': move_line,
|
|
'currency.currency': Mock(),
|
|
'ir.date': Mock(),
|
|
'account.configuration': Mock(return_value=Mock()),
|
|
'product.uom': Mock(),
|
|
}[name]
|
|
|
|
self.assertEqual(fee.get_cog(lot), Decimal(0))
|
|
|
|
def test_valuation_rejects_fee_without_currency_cleanly(self):
|
|
'pnl generation reports a missing fee currency before currency conversion'
|
|
fee = Mock(currency=None, product=Mock(name='Maritime freight'))
|
|
|
|
with self.assertRaises(UserError) as cm:
|
|
valuation_module.ValuationBase._check_fee_required_fields(fee)
|
|
|
|
self.assertIn('Currency is required on fee', str(cm.exception))
|
|
|
|
def test_purchase_line_default_pricing_rule_comes_from_configuration(self):
|
|
'purchase line pricing_rule defaults to the purchase_trade singleton value'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
config = Mock(pricing_rule='Default pricing rule')
|
|
configuration_model = Mock()
|
|
configuration_model.search.return_value = [config]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = configuration_model
|
|
self.assertEqual(
|
|
PurchaseLine.default_pricing_rule(), 'Default pricing rule')
|
|
|
|
def test_purchase_delete_blocks_matched_physical_contract(self):
|
|
'purchase delete stops when a physical lot already links purchase and sale'
|
|
Purchase = Pool().get('purchase.purchase')
|
|
matched_lot = Mock(lot_type='physic', line=Mock(), sale_line=Mock())
|
|
purchase = Mock(lines=[Mock(lots=[matched_lot])])
|
|
|
|
with self.assertRaises(UserError):
|
|
Purchase.delete([purchase])
|
|
|
|
def test_purchase_delete_delegates_when_no_matched_physical_lot(self):
|
|
'purchase delete keeps default flow when no matched physical lot exists'
|
|
Purchase = Pool().get('purchase.purchase')
|
|
purchase = Mock(lines=[Mock(lots=[Mock(lot_type='virtual')])])
|
|
|
|
with patch('trytond.modules.purchase_trade.purchase.super') as super_mock:
|
|
Purchase.delete([purchase])
|
|
super_mock.return_value.delete.assert_called_once_with([purchase])
|
|
|
|
def test_sale_delete_blocks_matched_physical_contract(self):
|
|
'sale delete stops when a physical lot already links sale and purchase'
|
|
Sale = Pool().get('sale.sale')
|
|
matched_lot = Mock(lot_type='physic', line=Mock(), sale_line=Mock())
|
|
sale = Mock(lines=[Mock(lots=[matched_lot])])
|
|
|
|
with self.assertRaises(UserError):
|
|
Sale.delete([sale])
|
|
|
|
def test_sale_delete_delegates_when_no_matched_physical_lot(self):
|
|
'sale delete keeps default flow when no matched physical lot exists'
|
|
Sale = Pool().get('sale.sale')
|
|
sale = Mock(lines=[Mock(lots=[Mock(lot_type='virtual')])])
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.super') as super_mock:
|
|
Sale.delete([sale])
|
|
super_mock.return_value.delete.assert_called_once_with([sale])
|
|
|
|
def test_component_quota_uses_quantity_fallback_when_theoretical_is_missing(self):
|
|
'component quota does not crash when theoretical quantity is still empty'
|
|
SaleComponent = Pool().get('pricing.component')
|
|
PurchaseComponent = Pool().get('pricing.component')
|
|
|
|
sale_component = SaleComponent()
|
|
sale_component.nbdays = None
|
|
sale_component.sale_line = Mock(
|
|
quantity=Decimal('12'),
|
|
quantity_theorical=None,
|
|
)
|
|
|
|
purchase_component = PurchaseComponent()
|
|
purchase_component.nbdays = None
|
|
purchase_component.line = Mock(
|
|
quantity=Decimal('15'),
|
|
quantity_theorical=None,
|
|
)
|
|
|
|
self.assertEqual(sale_component.get_quota_sale('quota_sale'), Decimal('12.0000'))
|
|
self.assertEqual(
|
|
purchase_component.get_quota_purchase('quota'),
|
|
Decimal('15.00000'))
|
|
|
|
def test_sale_and_purchase_generate_pricing_use_quantity_fallback(self):
|
|
'pricing generation uses quantity when theoretical quantity is missing'
|
|
Sale = Pool().get('sale.sale')
|
|
Purchase = Pool().get('purchase.purchase')
|
|
|
|
sale = Sale()
|
|
sale.id = 1
|
|
sale.quantity = Decimal('10')
|
|
sale.quantity_theorical = None
|
|
sale.unit = Mock()
|
|
sale.sale = Mock(currency=Mock())
|
|
sale.getnearprice = Mock(return_value=Decimal('0'))
|
|
|
|
purchase = Purchase()
|
|
purchase.id = 1
|
|
purchase.quantity = Decimal('12')
|
|
purchase.quantity_theorical = None
|
|
purchase.unit = Mock()
|
|
purchase.purchase = Mock(currency=Mock())
|
|
purchase.getnearprice = Mock(return_value=Decimal('0'))
|
|
|
|
pricing_model = Mock()
|
|
pricing_model.search.return_value = []
|
|
pc_sale = Mock(
|
|
id=1, quota_sale=Decimal('2'), pricing_date=None,
|
|
get_price=Mock(return_value=Decimal('1')))
|
|
pc_purchase = Mock(
|
|
id=1, quota=Decimal('3'), pricing_date=None,
|
|
get_price=Mock(return_value=Decimal('1')))
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool, patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
|
|
SalePool.return_value.get.return_value = pricing_model
|
|
PurchasePool.return_value.get.return_value = pricing_model
|
|
|
|
sale.generate_pricing(pc_sale, [datetime.datetime(2026, 4, 1)], [])
|
|
purchase.generate_pricing(pc_purchase, [datetime.datetime(2026, 4, 1)], [])
|
|
|
|
self.assertEqual(pricing_model.save.call_args_list[0].args[0][0].unfixed_qt, Decimal('10'))
|
|
self.assertEqual(pricing_model.save.call_args_list[1].args[0][0].unfixed_qt, Decimal('12'))
|
|
|
|
def test_trade_line_quantity_is_readonly(self):
|
|
'trade line quantity is a technical counter, not an editable business input'
|
|
SaleLine = Pool().get('sale.line')
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
|
|
self.assertTrue(SaleLine.quantity.readonly)
|
|
self.assertTrue(PurchaseLine.quantity.readonly)
|
|
|
|
def test_allow_modification_after_validation_line_readonly_rule(self):
|
|
'configuration flag unlocks lines only in quotation state'
|
|
Sale = Pool().get('sale.sale')
|
|
Purchase = Pool().get('purchase.purchase')
|
|
|
|
for model in (Sale, Purchase):
|
|
expression = model.lines.states['readonly']
|
|
self.assertTrue(PYSONDecoder({
|
|
'state': 'quotation',
|
|
'allow_modification_after_validation': False,
|
|
}).decode(expression))
|
|
self.assertFalse(PYSONDecoder({
|
|
'state': 'quotation',
|
|
'allow_modification_after_validation': True,
|
|
}).decode(expression))
|
|
self.assertTrue(PYSONDecoder({
|
|
'state': 'confirmed',
|
|
'allow_modification_after_validation': True,
|
|
}).decode(expression))
|
|
|
|
def test_purchase_line_initial_quantity_uses_theoretical_quantity(self):
|
|
'purchase line initializes technical quantity from contractual quantity'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = Mock(
|
|
quantity=None,
|
|
quantity_theorical=Decimal('42'),
|
|
lots=[],
|
|
)
|
|
|
|
self.assertTrue(PurchaseLine._sync_initial_quantity_from_theorical(line))
|
|
self.assertEqual(line.quantity, Decimal('42.00000'))
|
|
|
|
def test_sale_line_initial_quantity_uses_theoretical_quantity(self):
|
|
'sale line initializes technical quantity from contractual quantity'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = Mock(
|
|
quantity=Decimal('0'),
|
|
quantity_theorical=Decimal('24'),
|
|
lots=[],
|
|
)
|
|
|
|
self.assertTrue(SaleLine._sync_initial_quantity_from_theorical(line))
|
|
self.assertEqual(line.quantity, Decimal('24.00000'))
|
|
|
|
def test_trade_line_initial_quantity_does_not_override_existing_counter(self):
|
|
'initial sync keeps an existing technical quantity'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
physical = Mock(lot_type='virtual')
|
|
line = Mock(
|
|
quantity=Decimal('10'),
|
|
quantity_theorical=Decimal('20'),
|
|
lots=[physical],
|
|
)
|
|
|
|
self.assertFalse(PurchaseLine._sync_initial_quantity_from_theorical(line))
|
|
self.assertEqual(line.quantity, Decimal('10'))
|
|
|
|
def test_trade_line_initial_quantity_does_not_run_with_physical_lot(self):
|
|
'initial sync never overwrites the executed physical quantity'
|
|
SaleLine = Pool().get('sale.line')
|
|
physical = Mock(lot_type='physic')
|
|
line = Mock(
|
|
quantity=Decimal('0'),
|
|
quantity_theorical=Decimal('20'),
|
|
lots=[physical],
|
|
)
|
|
|
|
self.assertFalse(SaleLine._sync_initial_quantity_from_theorical(line))
|
|
self.assertEqual(line.quantity, Decimal('0'))
|
|
|
|
def test_purchase_line_amount_uses_theoretical_quantity_until_finished(self):
|
|
'purchase line amount uses contractual quantity while line remains open'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = PurchaseLine()
|
|
line.type = 'line'
|
|
line.quantity = Decimal('6')
|
|
line.quantity_theorical = Decimal('10')
|
|
line.finished = False
|
|
line.unit_price = Decimal('2')
|
|
line.premium = Decimal('0')
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
line.purchase = None
|
|
|
|
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
|
|
|
|
def test_purchase_line_amount_uses_physical_counter_when_finished(self):
|
|
'purchase line amount uses executed quantity once line is finished'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = PurchaseLine()
|
|
line.type = 'line'
|
|
line.quantity = Decimal('6')
|
|
line.quantity_theorical = Decimal('10')
|
|
line.finished = True
|
|
line.unit_price = Decimal('2')
|
|
line.premium = Decimal('0')
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
line.purchase = None
|
|
|
|
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
|
|
|
|
def test_purchase_line_amount_uses_purchase_weight_basis_when_finished(self):
|
|
'purchase line amount uses purchase weight basis for physical lots'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
bl = Mock(id=1)
|
|
unit = Mock()
|
|
lot = Mock(
|
|
lot_type='physic',
|
|
lot_hist=[Mock(quantity_type=bl)],
|
|
)
|
|
lot.get_current_quantity_converted.return_value = Decimal('8')
|
|
line = PurchaseLine()
|
|
line.type = 'line'
|
|
line.quantity = Decimal('6')
|
|
line.quantity_theorical = Decimal('10')
|
|
line.finished = True
|
|
line.unit_price = Decimal('2')
|
|
line.premium = Decimal('0')
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
line.purchase = Mock(wb=Mock(qt_type=bl))
|
|
line.unit = unit
|
|
line.lots = [lot]
|
|
|
|
self.assertEqual(line.on_change_with_amount(), Decimal('16'))
|
|
lot.get_current_quantity_converted.assert_called_once_with(1, unit)
|
|
|
|
def test_sale_line_amount_uses_theoretical_quantity_until_finished(self):
|
|
'sale line amount uses contractual quantity while line remains open'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = SaleLine()
|
|
line.type = 'line'
|
|
line.quantity = Decimal('6')
|
|
line.quantity_theorical = Decimal('10')
|
|
line.finished = False
|
|
line.unit_price = Decimal('2')
|
|
line.premium = Decimal('0')
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
line.sale = None
|
|
|
|
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
|
|
|
|
def test_sale_line_amount_uses_physical_counter_when_finished(self):
|
|
'sale line amount uses executed quantity once line is finished'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = SaleLine()
|
|
line.type = 'line'
|
|
line.quantity = Decimal('6')
|
|
line.quantity_theorical = Decimal('10')
|
|
line.finished = True
|
|
line.unit_price = Decimal('2')
|
|
line.premium = Decimal('0')
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
line.sale = None
|
|
|
|
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
|
|
|
|
def test_sale_line_amount_uses_sale_weight_basis_when_finished(self):
|
|
'sale line amount uses sale weight basis for physical lots'
|
|
SaleLine = Pool().get('sale.line')
|
|
bl = Mock(id=1)
|
|
lr = Mock(id=2)
|
|
unit = Mock()
|
|
lot = Mock(
|
|
lot_type='physic',
|
|
lot_hist=[Mock(quantity_type=bl), Mock(quantity_type=lr)],
|
|
)
|
|
lot.get_current_quantity_converted.return_value = Decimal('7')
|
|
line = SaleLine()
|
|
line.type = 'line'
|
|
line.quantity = Decimal('8')
|
|
line.quantity_theorical = Decimal('10')
|
|
line.finished = True
|
|
line.unit_price = Decimal('2')
|
|
line.premium = Decimal('0')
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
line.sale = Mock(wb=Mock(qt_type=lr))
|
|
line.unit = unit
|
|
line.lots = [lot]
|
|
|
|
self.assertEqual(line.on_change_with_amount(), Decimal('14'))
|
|
lot.get_current_quantity_converted.assert_called_once_with(2, unit)
|
|
|
|
def test_pricing_manual_fields_are_editable_except_eod(self):
|
|
'manual pricing rows only edit quantity and settlement while derived values stay computed'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
self.assertFalse(Pricing.quantity.readonly)
|
|
self.assertFalse(Pricing.settl_price.readonly)
|
|
self.assertTrue(Pricing.fixed_qt.readonly)
|
|
self.assertTrue(Pricing.fixed_qt_price.readonly)
|
|
self.assertTrue(Pricing.unfixed_qt.readonly)
|
|
self.assertTrue(Pricing.unfixed_qt_price.readonly)
|
|
self.assertTrue(Pricing.eod_price.readonly)
|
|
|
|
def test_pricing_component_domain_is_limited_to_owner_line(self):
|
|
'pricing component choices are limited to the purchase or sale line'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
self.assertEqual(Pricing.price_component.domain, [
|
|
('id', 'in', Eval('available_components', [])),
|
|
])
|
|
|
|
def test_pricing_defaults_single_manual_purchase_component(self):
|
|
'new purchase pricing rows default the only manual line component'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
owner = Mock(id=10)
|
|
component = Mock(id=33, auto=False)
|
|
component_model = Mock()
|
|
component_model.search.return_value = [component]
|
|
pool = Mock()
|
|
pool.get.return_value = component_model
|
|
pricing = Pricing()
|
|
pricing.line = owner
|
|
pricing.sale_line = None
|
|
pricing.price_component = None
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool',
|
|
return_value=pool):
|
|
pricing.on_change_line()
|
|
|
|
component_model.search.assert_called_once_with([('line', '=', owner)])
|
|
self.assertEqual(pricing.price_component, component)
|
|
|
|
def test_pricing_defaults_single_manual_sale_component(self):
|
|
'new sale pricing rows default the only manual line component'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
owner = Mock(id=10)
|
|
component = Mock(id=33, auto=False)
|
|
component_model = Mock()
|
|
component_model.search.return_value = [component]
|
|
pool = Mock()
|
|
pool.get.return_value = component_model
|
|
pricing = Pricing()
|
|
pricing.line = None
|
|
pricing.sale_line = owner
|
|
pricing.price_component = None
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool',
|
|
return_value=pool):
|
|
pricing.on_change_sale_line()
|
|
|
|
component_model.search.assert_called_once_with([
|
|
('sale_line', '=', owner)])
|
|
self.assertEqual(pricing.price_component, component)
|
|
|
|
def test_pricing_does_not_default_single_auto_component(self):
|
|
'new manual pricing rows do not default an auto component'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
owner = Mock(id=10)
|
|
component_model = Mock()
|
|
component_model.search.return_value = [Mock(id=33, auto=True)]
|
|
pool = Mock()
|
|
pool.get.return_value = component_model
|
|
pricing = Pricing()
|
|
pricing.line = owner
|
|
pricing.sale_line = None
|
|
pricing.price_component = None
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool',
|
|
return_value=pool):
|
|
pricing.on_change_line()
|
|
|
|
self.assertIsNone(pricing.price_component)
|
|
|
|
def test_pricing_component_default_triggers_copy_first_purchase_component(self):
|
|
'new purchase components inherit trigger values from the first component'
|
|
Component = Pool().get('pricing.component')
|
|
pricing_period = Mock(id=21)
|
|
application_period = Mock(id=22)
|
|
first_component = Mock(triggers=[
|
|
Mock(
|
|
pricing_period=pricing_period,
|
|
from_p=datetime.date(2026, 4, 1),
|
|
to_p=datetime.date(2026, 4, 5),
|
|
average=True,
|
|
last=False,
|
|
application_period=application_period,
|
|
from_a=datetime.date(2026, 5, 1),
|
|
to_a=datetime.date(2026, 5, 5),
|
|
),
|
|
])
|
|
|
|
with patch.object(Component, 'search',
|
|
return_value=[first_component]) as search:
|
|
values = Component._default_trigger_values(line=10)
|
|
|
|
search.assert_called_once_with(
|
|
[('line', '=', 10)], order=[('id', 'ASC')], limit=1)
|
|
self.assertEqual(values, [{
|
|
'pricing_period': 21,
|
|
'from_p': datetime.date(2026, 4, 1),
|
|
'to_p': datetime.date(2026, 4, 5),
|
|
'average': True,
|
|
'last': False,
|
|
'application_period': 22,
|
|
'from_a': datetime.date(2026, 5, 1),
|
|
'to_a': datetime.date(2026, 5, 5),
|
|
}])
|
|
|
|
def test_pricing_component_create_inherits_first_sale_component_triggers(self):
|
|
'component create copies triggers from the first sale line component'
|
|
Component = Pool().get('pricing.component')
|
|
first_component = Mock(triggers=[
|
|
Mock(
|
|
pricing_period=Mock(id=21),
|
|
from_p=datetime.date(2026, 4, 1),
|
|
to_p=datetime.date(2026, 4, 5),
|
|
average=True,
|
|
last=True,
|
|
application_period=None,
|
|
from_a=None,
|
|
to_a=None,
|
|
),
|
|
])
|
|
|
|
with patch.object(Component, 'search',
|
|
return_value=[first_component]) as search, patch(
|
|
'trytond.modules.purchase_trade.pricing.super') as super_mock:
|
|
Component.create([{'sale_line': 10, 'price_source_type': 'curve'}])
|
|
|
|
search.assert_called_once_with(
|
|
[('sale_line', '=', 10)], order=[('id', 'ASC')], limit=1)
|
|
created_values = super_mock.return_value.create.call_args.args[0][0]
|
|
self.assertEqual(created_values['triggers'], [('create', [{
|
|
'pricing_period': 21,
|
|
'from_p': datetime.date(2026, 4, 1),
|
|
'to_p': datetime.date(2026, 4, 5),
|
|
'average': True,
|
|
'last': True,
|
|
'application_period': None,
|
|
'from_a': None,
|
|
'to_a': None,
|
|
}])])
|
|
|
|
def test_pricing_component_create_keeps_explicit_triggers(self):
|
|
'component create does not replace triggers already provided'
|
|
Component = Pool().get('pricing.component')
|
|
explicit_triggers = [('create', [{'average': False}])]
|
|
|
|
with patch.object(Component, 'search') as search, patch(
|
|
'trytond.modules.purchase_trade.pricing.super') as super_mock:
|
|
Component.create([{
|
|
'line': 10,
|
|
'price_source_type': 'curve',
|
|
'triggers': explicit_triggers,
|
|
}])
|
|
|
|
search.assert_not_called()
|
|
created_values = super_mock.return_value.create.call_args.args[0][0]
|
|
self.assertIs(created_values['triggers'], explicit_triggers)
|
|
|
|
def test_pricing_component_must_belong_to_pricing_owner(self):
|
|
'pricing rows reject components from another purchase or sale line'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
owner = Mock(id=10)
|
|
other = Mock(id=11)
|
|
|
|
self.assertTrue(Pricing._component_matches_owner(Mock(
|
|
line=owner,
|
|
sale_line=None,
|
|
price_component=Mock(line=owner, sale_line=None),
|
|
)))
|
|
self.assertFalse(Pricing._component_matches_owner(Mock(
|
|
line=owner,
|
|
sale_line=None,
|
|
price_component=Mock(line=other, sale_line=None),
|
|
)))
|
|
self.assertTrue(Pricing._component_matches_owner(Mock(
|
|
line=None,
|
|
sale_line=owner,
|
|
price_component=Mock(line=None, sale_line=owner),
|
|
)))
|
|
self.assertFalse(Pricing._component_matches_owner(Mock(
|
|
line=None,
|
|
sale_line=owner,
|
|
price_component=Mock(line=None, sale_line=other),
|
|
)))
|
|
|
|
def test_purchase_and_sale_delivery_period_reject_from_after_to(self):
|
|
'delivery period dates must be chronological on purchase and sale lines'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
SaleLine = Pool().get('sale.line')
|
|
invalid = Mock(
|
|
from_del=datetime.date(2026, 5, 1),
|
|
to_del=datetime.date(2026, 4, 30),
|
|
)
|
|
valid = Mock(
|
|
from_del=datetime.date(2026, 4, 1),
|
|
to_del=datetime.date(2026, 4, 30),
|
|
)
|
|
|
|
self.assertTrue(PurchaseLine._has_invalid_delivery_period(invalid))
|
|
self.assertTrue(SaleLine._has_invalid_delivery_period(invalid))
|
|
self.assertFalse(PurchaseLine._has_invalid_delivery_period(valid))
|
|
self.assertFalse(SaleLine._has_invalid_delivery_period(valid))
|
|
|
|
purchase_line = PurchaseLine()
|
|
purchase_line.from_del = invalid.from_del
|
|
purchase_line.to_del = invalid.to_del
|
|
sale_line = SaleLine()
|
|
sale_line.from_del = invalid.from_del
|
|
sale_line.to_del = invalid.to_del
|
|
with self.assertRaises(UserError):
|
|
purchase_line.on_change_from_del()
|
|
with self.assertRaises(UserError):
|
|
sale_line.on_change_to_del()
|
|
with self.assertRaises(UserError):
|
|
PurchaseLine._check_delivery_period_values([valid], {
|
|
'from_del': invalid.from_del,
|
|
})
|
|
with self.assertRaises(UserError):
|
|
SaleLine._check_delivery_period_values([valid], {
|
|
'to_del': datetime.date(2026, 3, 31),
|
|
})
|
|
with self.assertRaises(UserError):
|
|
PurchaseLine._check_delivery_period_values([invalid], {
|
|
'quantity': Decimal('1'),
|
|
})
|
|
with self.assertRaises(UserError):
|
|
SaleLine._check_delivery_period_values([invalid], {
|
|
'quantity': Decimal('1'),
|
|
})
|
|
|
|
def test_purchase_and_sale_line_create_default_estimated_bl_date(self):
|
|
'new lines default missing estimated BL date from delivery period start'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
SaleLine = Pool().get('sale.line')
|
|
period = Mock(beg_date=datetime.date(2026, 5, 1))
|
|
purchase_values = {'del_period': period}
|
|
sale_values = {'del_period': period}
|
|
|
|
PurchaseLine._set_default_estimated_bl_date_values(purchase_values)
|
|
SaleLine._set_default_estimated_bl_date_values(sale_values)
|
|
|
|
self.assertEqual(purchase_values['estimated_date'], [('create', [{
|
|
'trigger': 'bldate',
|
|
'estimated_date': datetime.date(2026, 5, 1),
|
|
}])])
|
|
self.assertEqual(sale_values['estimated_date'], [('create', [{
|
|
'trigger': 'bldate',
|
|
'estimated_date': datetime.date(2026, 5, 1),
|
|
}])])
|
|
|
|
def test_purchase_and_sale_line_default_estimated_bl_date_accepts_tuple(self):
|
|
'new lines normalize tuple estimated date commands before appending'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
SaleLine = Pool().get('sale.line')
|
|
period = Mock(beg_date=datetime.date(2026, 5, 1))
|
|
existing = ('create', [{
|
|
'trigger': 'other',
|
|
'estimated_date': datetime.date(2026, 5, 9),
|
|
}])
|
|
purchase_values = {
|
|
'del_period': period,
|
|
'estimated_date': (existing,),
|
|
}
|
|
sale_values = {
|
|
'del_period': period,
|
|
'estimated_date': (existing,),
|
|
}
|
|
|
|
PurchaseLine._set_default_estimated_bl_date_values(purchase_values)
|
|
SaleLine._set_default_estimated_bl_date_values(sale_values)
|
|
|
|
expected = [existing, ('create', [{
|
|
'trigger': 'bldate',
|
|
'estimated_date': datetime.date(2026, 5, 1),
|
|
}])]
|
|
self.assertEqual(purchase_values['estimated_date'], expected)
|
|
self.assertEqual(sale_values['estimated_date'], expected)
|
|
|
|
def test_purchase_line_reporting_parent_fields_and_counts(self):
|
|
'purchase line reporting fields mirror parent values and relation counts'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = PurchaseLine()
|
|
line.purchase = Mock(
|
|
trader=Mock(id=10),
|
|
operator=Mock(id=11),
|
|
broker=Mock(id=12),
|
|
contract_template=Mock(id=13),
|
|
our_reference='OUR-PUR',
|
|
lc_date=datetime.date(2026, 5, 2),
|
|
product_origin='Origin',
|
|
)
|
|
line.price_components = [Mock(), Mock()]
|
|
line.price_pricing = [Mock()]
|
|
line.mtm = [Mock(), Mock(), Mock()]
|
|
line.lots = []
|
|
|
|
self.assertEqual(line.get_parent_field('parent_trader'), 10)
|
|
self.assertEqual(line.get_parent_field('parent_operator'), 11)
|
|
self.assertEqual(
|
|
line.get_parent_field('parent_contract_template'), 13)
|
|
self.assertEqual(line.get_parent_field('parent_our_reference'), 'OUR-PUR')
|
|
self.assertEqual(
|
|
line.get_relation_count('price_component_count'), 2)
|
|
self.assertEqual(line.get_relation_count('pricing_count'), 1)
|
|
self.assertEqual(line.get_relation_count('mtm_count'), 3)
|
|
self.assertEqual(line.get_relation_count('lot_count'), 0)
|
|
|
|
def test_sale_line_reporting_parent_fields_and_counts(self):
|
|
'sale line reporting fields mirror parent values and relation counts'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = SaleLine()
|
|
line.sale = Mock(
|
|
trader=Mock(id=20),
|
|
operator=Mock(id=21),
|
|
invoice_party=Mock(id=22),
|
|
shipment_party=Mock(id=23),
|
|
contract_template=Mock(id=24),
|
|
our_reference='OUR-SALE',
|
|
lc_date=datetime.date(2026, 5, 3),
|
|
product_origin='Origin',
|
|
)
|
|
line.price_components = [Mock()]
|
|
line.price_composition = [Mock(), Mock()]
|
|
line.price_pricing = []
|
|
line.fees = [Mock(), Mock(), Mock()]
|
|
|
|
self.assertEqual(line.get_parent_field('parent_trader'), 20)
|
|
self.assertEqual(line.get_parent_field('parent_operator'), 21)
|
|
self.assertEqual(line.get_parent_field('parent_invoice_party'), 22)
|
|
self.assertEqual(line.get_parent_field('parent_shipment_party'), 23)
|
|
self.assertEqual(line.get_parent_field('parent_our_reference'), 'OUR-SALE')
|
|
self.assertEqual(
|
|
line.get_relation_count('price_component_count'), 1)
|
|
self.assertEqual(
|
|
line.get_relation_count('price_composition_count'), 2)
|
|
self.assertEqual(line.get_relation_count('pricing_count'), 0)
|
|
self.assertEqual(line.get_relation_count('fee_count'), 3)
|
|
|
|
def test_purchase_and_sale_line_keep_explicit_estimated_bl_date(self):
|
|
'new lines keep explicit estimated BL date'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
SaleLine = Pool().get('sale.line')
|
|
period = Mock(beg_date=datetime.date(2026, 5, 1))
|
|
explicit = [('create', [{
|
|
'trigger': 'bldate',
|
|
'estimated_date': datetime.date(2026, 5, 9),
|
|
}])]
|
|
purchase_values = {'del_period': period, 'estimated_date': list(explicit)}
|
|
sale_values = {'del_period': period, 'estimated_date': list(explicit)}
|
|
|
|
PurchaseLine._set_default_estimated_bl_date_values(purchase_values)
|
|
SaleLine._set_default_estimated_bl_date_values(sale_values)
|
|
|
|
self.assertEqual(purchase_values['estimated_date'], explicit)
|
|
self.assertEqual(sale_values['estimated_date'], explicit)
|
|
|
|
def test_purchase_line_write_creates_missing_estimated_bl_date(self):
|
|
'purchase line write creates missing estimated BL date from period'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = Mock(
|
|
id=42,
|
|
del_period=Mock(beg_date=datetime.date(2026, 5, 1)),
|
|
estimated_date=[],
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
|
|
Estimated = Mock()
|
|
PoolMock.return_value.get.return_value = Estimated
|
|
PurchaseLine._create_missing_estimated_bl_dates([line])
|
|
|
|
Estimated.create.assert_called_once_with([{
|
|
'line': 42,
|
|
'trigger': 'bldate',
|
|
'estimated_date': datetime.date(2026, 5, 1),
|
|
}])
|
|
|
|
def test_sale_line_write_creates_missing_estimated_bl_date(self):
|
|
'sale line write creates missing estimated BL date from period'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = Mock(
|
|
id=43,
|
|
del_period=Mock(beg_date=datetime.date(2026, 5, 1)),
|
|
estimated_date=[],
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
|
|
Estimated = Mock()
|
|
PoolMock.return_value.get.return_value = Estimated
|
|
SaleLine._create_missing_estimated_bl_dates([line])
|
|
|
|
Estimated.create.assert_called_once_with([{
|
|
'sale_line': 43,
|
|
'trigger': 'bldate',
|
|
'estimated_date': datetime.date(2026, 5, 1),
|
|
}])
|
|
|
|
def test_sale_and_purchase_parent_write_check_embedded_line_commands(self):
|
|
'sale and purchase writes validate embedded one2many line commands'
|
|
Sale = Pool().get('sale.sale')
|
|
Purchase = Pool().get('purchase.purchase')
|
|
invalid = Mock(
|
|
from_del=datetime.date(2026, 4, 16),
|
|
to_del=datetime.date(2026, 4, 10),
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool:
|
|
SalePool.return_value.get.return_value = Mock(
|
|
browse=Mock(return_value=[invalid]))
|
|
with self.assertRaises(UserError):
|
|
Sale._check_lines_delivery_period_values({
|
|
'lines': [['write', [564], {'quantity': Decimal('1')}]],
|
|
})
|
|
|
|
with patch('trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
|
|
PurchasePool.return_value.get.return_value = Mock(
|
|
browse=Mock(return_value=[invalid]))
|
|
with self.assertRaises(UserError):
|
|
Purchase._check_lines_delivery_period_values({
|
|
'lines': [['add', [564]]],
|
|
})
|
|
|
|
def test_parent_write_checks_final_embedded_line_values(self):
|
|
'parent writes validate final line values after add and write commands'
|
|
Sale = Pool().get('sale.sale')
|
|
line = Mock(
|
|
from_del=datetime.date(2026, 4, 21),
|
|
to_del=datetime.date(2026, 4, 10),
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool:
|
|
SalePool.return_value.get.return_value = Mock(
|
|
browse=Mock(return_value=[line]))
|
|
Sale._check_lines_delivery_period_values({
|
|
'lines': [
|
|
['add', [564]],
|
|
['write', [564], {
|
|
'from_del': datetime.date(2026, 4, 6),
|
|
}],
|
|
],
|
|
})
|
|
|
|
def test_pricing_eod_uses_weighted_average_for_manual_rows(self):
|
|
'manual pricing eod uses the weighted average of fixed and unfixed legs'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
pricing = Pricing()
|
|
pricing.fixed_qt = Decimal('4')
|
|
pricing.fixed_qt_price = Decimal('100')
|
|
pricing.unfixed_qt = Decimal('6')
|
|
pricing.unfixed_qt_price = Decimal('110')
|
|
|
|
self.assertEqual(pricing.compute_eod_price(), Decimal('106.0000'))
|
|
|
|
def test_sale_and_purchase_eod_use_same_weighted_formula(self):
|
|
'auto sale/purchase eod helpers use the same weighted average formula'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
sale_pricing = Pricing()
|
|
sale_pricing.sale_line = Mock(quantity=Decimal('999'))
|
|
sale_pricing.fixed_qt = Decimal('4')
|
|
sale_pricing.fixed_qt_price = Decimal('100')
|
|
sale_pricing.unfixed_qt = Decimal('6')
|
|
sale_pricing.unfixed_qt_price = Decimal('110')
|
|
|
|
purchase_pricing = Pricing()
|
|
purchase_pricing.line = Mock(quantity_theorical=Decimal('999'))
|
|
purchase_pricing.fixed_qt = Decimal('4')
|
|
purchase_pricing.fixed_qt_price = Decimal('100')
|
|
purchase_pricing.unfixed_qt = Decimal('6')
|
|
purchase_pricing.unfixed_qt_price = Decimal('110')
|
|
|
|
self.assertEqual(sale_pricing.get_eod_price_sale(), Decimal('106.0000'))
|
|
self.assertEqual(
|
|
purchase_pricing.get_eod_price_purchase(), Decimal('106.0000'))
|
|
|
|
def test_pricing_sync_manual_last_uses_greatest_date_per_component_group(self):
|
|
'pricing rows keep one last by line/component, chosen by greatest pricing date'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
sale_line = Mock(id=10)
|
|
component = Mock(id=33)
|
|
first = Mock(
|
|
id=1,
|
|
price_component=component,
|
|
sale_line=sale_line,
|
|
line=None,
|
|
pricing_date=datetime.date(2026, 4, 10),
|
|
last=True,
|
|
)
|
|
second = Mock(
|
|
id=2,
|
|
price_component=component,
|
|
sale_line=sale_line,
|
|
line=None,
|
|
pricing_date=datetime.date(2026, 4, 9),
|
|
last=False,
|
|
)
|
|
|
|
with patch.object(Pricing, 'search', return_value=[second, first]), patch(
|
|
'trytond.modules.purchase_trade.pricing.super') as super_mock:
|
|
Pricing._sync_manual_last([first, second])
|
|
|
|
self.assertEqual(super_mock.return_value.write.call_args_list[0].args[0], [second])
|
|
self.assertEqual(super_mock.return_value.write.call_args_list[0].args[1], {'last': False})
|
|
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[0], [first])
|
|
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[1], {'last': True})
|
|
|
|
def test_pricing_sync_manual_values_recomputes_manual_group_from_quantity_and_settl(self):
|
|
'manual pricing rows derive fixed/unfixed values from quantity and settlement price'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
sale_line = Mock(id=10)
|
|
sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10'))
|
|
component = Mock(id=33, auto=False)
|
|
first = Mock(
|
|
id=1,
|
|
price_component=component,
|
|
sale_line=sale_line,
|
|
line=None,
|
|
pricing_date=datetime.date(2026, 4, 10),
|
|
quantity=Decimal('4'),
|
|
settl_price=Decimal('100'),
|
|
)
|
|
second = Mock(
|
|
id=2,
|
|
price_component=component,
|
|
sale_line=sale_line,
|
|
line=None,
|
|
pricing_date=datetime.date(2026, 4, 11),
|
|
quantity=Decimal('3'),
|
|
settl_price=Decimal('110'),
|
|
)
|
|
|
|
with patch.object(Pricing, 'search', return_value=[first, second]), patch(
|
|
'trytond.modules.purchase_trade.pricing.super') as super_mock:
|
|
Pricing._sync_manual_values([first, second])
|
|
|
|
first_values = super_mock.return_value.write.call_args_list[0].args[1]
|
|
self.assertEqual(first_values['fixed_qt'], Decimal('4'))
|
|
self.assertEqual(first_values['fixed_qt_price'], Decimal('100.0000'))
|
|
self.assertEqual(first_values['unfixed_qt'], Decimal('6'))
|
|
self.assertEqual(first_values['unfixed_qt_price'], Decimal('100'))
|
|
self.assertEqual(first_values['eod_price'], Decimal('100.0000'))
|
|
self.assertFalse(first_values['last'])
|
|
|
|
second_values = super_mock.return_value.write.call_args_list[1].args[1]
|
|
self.assertEqual(second_values['fixed_qt'], Decimal('7'))
|
|
self.assertEqual(second_values['fixed_qt_price'], Decimal('104.2857'))
|
|
self.assertEqual(second_values['unfixed_qt'], Decimal('3'))
|
|
self.assertEqual(second_values['unfixed_qt_price'], Decimal('110'))
|
|
self.assertEqual(second_values['eod_price'], Decimal('106.0000'))
|
|
self.assertTrue(second_values['last'])
|
|
|
|
def test_pricing_sync_manual_values_uses_curve_last_price_for_unfixed(self):
|
|
'manual pricing rows use the component curve latest price for the unfixed leg'
|
|
Pricing = Pool().get('pricing.pricing')
|
|
|
|
currency = Mock()
|
|
unit = Mock()
|
|
sale_line = Mock(
|
|
id=10,
|
|
sale=Mock(currency=currency),
|
|
unit=unit,
|
|
)
|
|
sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10'))
|
|
component = Mock(
|
|
id=33,
|
|
auto=False,
|
|
price_source_type='curve',
|
|
price_index=Mock(),
|
|
)
|
|
component.get_price.return_value = Decimal('120')
|
|
pricing = Mock(
|
|
id=1,
|
|
price_component=component,
|
|
sale_line=sale_line,
|
|
line=None,
|
|
pricing_date=datetime.date(2026, 4, 10),
|
|
quantity=Decimal('4'),
|
|
settl_price=Decimal('100'),
|
|
)
|
|
|
|
with patch.object(Pricing, 'search', return_value=[pricing]), patch(
|
|
'trytond.modules.purchase_trade.pricing.super') as super_mock:
|
|
Pricing._sync_manual_values([pricing])
|
|
|
|
values = super_mock.return_value.write.call_args.args[1]
|
|
self.assertEqual(values['unfixed_qt_price'], Decimal('120.0000'))
|
|
self.assertEqual(values['eod_price'], Decimal('112.0000'))
|
|
component.get_price.assert_called_once_with(
|
|
datetime.date(2026, 4, 10), unit, currency, True)
|
|
|
|
def test_pricing_trigger_manual_from_to_generates_dates_without_calendar(self):
|
|
'manual pricing From/To uses calendar days when no calendar is selected'
|
|
Trigger = Pool().get('pricing.trigger')
|
|
trigger = Trigger()
|
|
trigger.pricing_period = None
|
|
trigger.from_p = datetime.date(2026, 4, 1)
|
|
trigger.to_p = datetime.date(2026, 4, 3)
|
|
trigger.getprice = Mock(side_effect=lambda date: {
|
|
'date': date,
|
|
'price': Decimal('100'),
|
|
'avg': Decimal('100'),
|
|
'avg_minus_1': Decimal('100'),
|
|
'isAvg': False,
|
|
})
|
|
|
|
dates, prices = trigger.getPricingListDates(None)
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 4, 1),
|
|
datetime.date(2026, 4, 2),
|
|
datetime.date(2026, 4, 3),
|
|
])
|
|
self.assertEqual(len(prices), 3)
|
|
|
|
def test_pricing_period_every_positive_returns_weekdays_after_trigger(self):
|
|
'Every Friday and 4 returns the four Fridays from/after trigger date'
|
|
Period = Pool().get('pricing.period')
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.every = 'friday'
|
|
period.nb_quotation_after = 4
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 4, 1))
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 4, 3),
|
|
datetime.date(2026, 4, 10),
|
|
datetime.date(2026, 4, 17),
|
|
datetime.date(2026, 4, 24),
|
|
])
|
|
|
|
def test_pricing_period_every_before_returns_weekdays_before_trigger(self):
|
|
'Every Friday with before returns the four Fridays before trigger date'
|
|
Period = Pool().get('pricing.period')
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.every = 'friday'
|
|
period.nb_quotation_before = 4
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 4, 30))
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 4, 3),
|
|
datetime.date(2026, 4, 10),
|
|
datetime.date(2026, 4, 17),
|
|
datetime.date(2026, 4, 24),
|
|
])
|
|
|
|
def test_pricing_period_blank_every_after_uses_available_dates_after_trigger(self):
|
|
'Blank Every and Nb quotes after returns dates after trigger'
|
|
Period = Pool().get('pricing.period')
|
|
calendar = Mock()
|
|
excluded = {
|
|
datetime.date(2026, 5, 14),
|
|
datetime.date(2026, 5, 16),
|
|
}
|
|
calendar.IsQuote = Mock(
|
|
side_effect=lambda date: date.date() not in excluded)
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.every = None
|
|
period.nb_quotation_after = 4
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar)
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 5, 13),
|
|
datetime.date(2026, 5, 15),
|
|
datetime.date(2026, 5, 17),
|
|
datetime.date(2026, 5, 18),
|
|
])
|
|
|
|
def test_pricing_period_blank_every_include_counts_trigger_plus_after_dates(self):
|
|
'Blank Every with Inc. returns trigger date plus quotes after'
|
|
Period = Pool().get('pricing.period')
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.include = True
|
|
period.every = None
|
|
period.nb_quotation_after = 4
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 5, 12),
|
|
datetime.date(2026, 5, 13),
|
|
datetime.date(2026, 5, 14),
|
|
datetime.date(2026, 5, 15),
|
|
datetime.date(2026, 5, 16),
|
|
])
|
|
|
|
def test_pricing_period_blank_every_before_uses_available_dates_before_trigger(self):
|
|
'Blank Every and Nb quotes before returns dates before trigger'
|
|
Period = Pool().get('pricing.period')
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.every = None
|
|
period.nb_quotation_before = 4
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 5, 8),
|
|
datetime.date(2026, 5, 9),
|
|
datetime.date(2026, 5, 10),
|
|
datetime.date(2026, 5, 11),
|
|
])
|
|
|
|
def test_pricing_period_blank_every_returns_asymmetric_dates(self):
|
|
'Blank Every returns independent quotations before and after trigger'
|
|
Period = Pool().get('pricing.period')
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.include = False
|
|
period.every = None
|
|
period.nb_quotation_before = 1
|
|
period.nb_quotation_after = 3
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 5, 11),
|
|
datetime.date(2026, 5, 13),
|
|
datetime.date(2026, 5, 14),
|
|
datetime.date(2026, 5, 15),
|
|
])
|
|
|
|
def test_pricing_period_blank_every_before_and_after_include_adds_trigger_date(self):
|
|
'Blank Every with Inc. adds trigger date between both sides'
|
|
Period = Pool().get('pricing.period')
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.include = True
|
|
period.every = None
|
|
period.nb_quotation_before = 2
|
|
period.nb_quotation_after = 2
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 5, 10),
|
|
datetime.date(2026, 5, 11),
|
|
datetime.date(2026, 5, 12),
|
|
datetime.date(2026, 5, 13),
|
|
datetime.date(2026, 5, 14),
|
|
])
|
|
|
|
def test_pricing_period_blank_every_before_and_after_skips_calendar_exclusions(self):
|
|
'Blank Every uses available quotations around trigger date'
|
|
Period = Pool().get('pricing.period')
|
|
calendar = Mock()
|
|
excluded = {
|
|
datetime.date(2026, 5, 11),
|
|
datetime.date(2026, 5, 13),
|
|
}
|
|
calendar.IsQuote = Mock(
|
|
side_effect=lambda date: date.date() not in excluded)
|
|
period = Period()
|
|
period.trigger = 'bldate'
|
|
period.include = True
|
|
period.every = None
|
|
period.nb_quotation_before = 1
|
|
period.nb_quotation_after = 2
|
|
|
|
_, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar)
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 5, 10),
|
|
datetime.date(2026, 5, 12),
|
|
datetime.date(2026, 5, 14),
|
|
datetime.date(2026, 5, 15),
|
|
])
|
|
|
|
def test_pricing_trigger_application_period_defaults_to_pricing_period(self):
|
|
'application period defaults to pricing period without being cleared'
|
|
Trigger = Pool().get('pricing.trigger')
|
|
trigger = Trigger()
|
|
trigger.pricing_period = Mock(id=21)
|
|
trigger.application_period = None
|
|
|
|
self.assertEqual(
|
|
trigger.on_change_with_application_period(),
|
|
trigger.pricing_period)
|
|
|
|
def test_pricing_trigger_application_period_keeps_existing_value(self):
|
|
'application period keeps explicit value when pricing period changes'
|
|
Trigger = Pool().get('pricing.trigger')
|
|
application_period = Mock(id=22)
|
|
trigger = Trigger()
|
|
trigger.pricing_period = Mock(id=21)
|
|
trigger.application_period = application_period
|
|
|
|
self.assertEqual(
|
|
trigger.on_change_with_application_period(),
|
|
application_period)
|
|
|
|
def test_purchase_line_get_avg_accepts_decimal_matrix_prices(self):
|
|
'purchase pricing averages Decimal matrix prices without float mixing'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = PurchaseLine()
|
|
prices = [
|
|
{'price': Decimal('45.0000')},
|
|
{'price': Decimal('55.0000')},
|
|
]
|
|
|
|
result = line.get_avg(prices)
|
|
|
|
self.assertEqual(result[0]['avg'], Decimal('45.0000'))
|
|
self.assertEqual(result[1]['avg_minus_1'], Decimal('45.0000'))
|
|
self.assertEqual(result[1]['avg'], Decimal('50.0000'))
|
|
|
|
def test_sale_line_get_avg_accepts_decimal_matrix_prices(self):
|
|
'sale pricing averages Decimal matrix prices without float mixing'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = SaleLine()
|
|
prices = [
|
|
{'price': Decimal('45.0000')},
|
|
{'price': Decimal('55.0000')},
|
|
]
|
|
|
|
result = line.get_avg(prices)
|
|
|
|
self.assertEqual(result[0]['avg'], Decimal('45.0000'))
|
|
self.assertEqual(result[1]['avg_minus_1'], Decimal('45.0000'))
|
|
self.assertEqual(result[1]['avg'], Decimal('50.0000'))
|
|
|
|
def test_pricing_trigger_manual_application_defaults_to_pricing_from_to(self):
|
|
'empty manual application From/To falls back to manual pricing From/To'
|
|
Trigger = Pool().get('pricing.trigger')
|
|
trigger = Trigger()
|
|
trigger.pricing_period = None
|
|
trigger.application_period = None
|
|
trigger.from_p = datetime.date(2026, 4, 1)
|
|
trigger.to_p = datetime.date(2026, 4, 2)
|
|
trigger.from_a = None
|
|
trigger.to_a = None
|
|
|
|
dates, prices = trigger.getApplicationListDates(None)
|
|
|
|
self.assertEqual(
|
|
[date.date() for date in dates],
|
|
[
|
|
datetime.date(2026, 4, 1),
|
|
datetime.date(2026, 4, 2),
|
|
])
|
|
self.assertEqual(prices, [])
|
|
|
|
def test_pricing_trigger_on_change_copies_manual_pricing_dates_to_application(self):
|
|
'manual pricing From/To is copied to empty application From/To in the UI'
|
|
Trigger = Pool().get('pricing.trigger')
|
|
trigger = Trigger()
|
|
trigger.application_period = None
|
|
trigger.from_p = datetime.date(2026, 4, 1)
|
|
trigger.to_p = datetime.date(2026, 4, 30)
|
|
trigger.from_a = None
|
|
trigger.to_a = None
|
|
|
|
trigger.on_change_from_p()
|
|
trigger.on_change_to_p()
|
|
|
|
self.assertEqual(trigger.from_a, datetime.date(2026, 4, 1))
|
|
self.assertEqual(trigger.to_a, datetime.date(2026, 4, 30))
|
|
|
|
def test_pricing_trigger_average_defaults_to_true_and_last_modes(self):
|
|
'pricing trigger defaults to Avg and exposes AL/RL last modes'
|
|
Trigger = Pool().get('pricing.trigger')
|
|
|
|
self.assertTrue(Trigger.default_average())
|
|
self.assertEqual(Trigger.last.string, 'AL')
|
|
self.assertEqual(Trigger.relative_last.string, 'RL')
|
|
|
|
@with_transaction()
|
|
def test_import_forward_prices_copies_value_to_ohl_fields(self):
|
|
'forward import uses price values for open, low, mid and high prices'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
sheet = ElementTree.fromstring('''
|
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
<sheetData>
|
|
<row r="1">
|
|
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
|
|
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
|
|
<c r="C1" t="inlineStr"><is><t>2026-03</t></is></c>
|
|
</row>
|
|
<row r="2">
|
|
<c r="A2" t="inlineStr"><is><t>ICE Cotton</t></is></c>
|
|
<c r="B2" t="inlineStr"><is><t>2026-03-01</t></is></c>
|
|
<c r="C2"><v>75.25</v></c>
|
|
</row>
|
|
</sheetData>
|
|
</worksheet>
|
|
''')
|
|
rows = sheet.findall('.//s:sheetData/s:row', ns)
|
|
|
|
result = ImportPrices._read_forward_price_rows(rows, [], ns)
|
|
|
|
self.assertEqual(len(result), 1)
|
|
self.assertEqual(result[0]['price_value'], '75.25')
|
|
self.assertEqual(result[0]['open_price'], '75.25')
|
|
self.assertEqual(result[0]['low_price'], '75.25')
|
|
self.assertEqual(result[0]['mid_price'], '75.25')
|
|
self.assertEqual(result[0]['high_price'], '75.25')
|
|
|
|
def test_import_historical_prices_computes_mid_from_low_high(self):
|
|
'historical import computes mid price from low and high prices'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
sheet = ElementTree.fromstring('''
|
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
<sheetData>
|
|
<row r="1">
|
|
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
|
|
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
|
|
<c r="C1" t="inlineStr"><is><t>High Price</t></is></c>
|
|
<c r="D1" t="inlineStr"><is><t>Low Price</t></is></c>
|
|
<c r="E1" t="inlineStr"><is><t>Open Price</t></is></c>
|
|
<c r="F1" t="inlineStr"><is><t>Price Value</t></is></c>
|
|
</row>
|
|
<row r="2">
|
|
<c r="A2" t="inlineStr"><is><t>ARGUS-CFR Brasil</t></is></c>
|
|
<c r="B2"><v>46191</v></c>
|
|
<c r="C2"><v>482</v></c>
|
|
<c r="D2"><v>460</v></c>
|
|
<c r="E2"><v>470</v></c>
|
|
<c r="F2"><v>470</v></c>
|
|
</row>
|
|
</sheetData>
|
|
</worksheet>
|
|
''')
|
|
rows = sheet.findall('.//s:sheetData/s:row', ns)
|
|
|
|
result = ImportPrices._read_historical_price_rows(rows, [], ns)
|
|
values = ImportPrices._price_value_values(
|
|
Mock(id=1), result[0], datetime.date(2026, 6, 18))
|
|
|
|
self.assertNotIn('mid_price', result[0])
|
|
self.assertEqual(values['mid_price'], 471)
|
|
|
|
def test_import_forward_price_values_compute_mid_from_low_high(self):
|
|
'forward import value builder computes mid price from low and high'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
|
|
values = ImportPrices._price_value_values(
|
|
Mock(id=1), {
|
|
'high_price': '482',
|
|
'low_price': '460',
|
|
'mid_price': '0',
|
|
'open_price': '470',
|
|
'price_value': '470',
|
|
},
|
|
datetime.date(2026, 6, 18))
|
|
|
|
self.assertEqual(values['mid_price'], 471)
|
|
|
|
@with_transaction()
|
|
def test_import_historical_all_sources_uses_mapping_table(self):
|
|
'all sources import maps source columns to standard price values'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
sheet = ElementTree.fromstring('''
|
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
<sheetData>
|
|
<row r="1">
|
|
<c r="A1" t="inlineStr"><is><t>Copyright</t></is></c>
|
|
</row>
|
|
<row r="2">
|
|
<c r="A2" t="inlineStr"><is><t>combined_x000D_
|
|
description</t></is></c>
|
|
<c r="B2" t="inlineStr"><is><t>Original Mid</t></is></c>
|
|
<c r="C2" t="inlineStr"><is><t>Original High</t></is></c>
|
|
<c r="D2" t="inlineStr"><is><t>Original Low</t></is></c>
|
|
</row>
|
|
<row r="3">
|
|
<c r="A3"><v>46191</v></c>
|
|
<c r="B3"><v>470</v></c>
|
|
<c r="C3"><v>482</v></c>
|
|
<c r="D3"><v>460</v></c>
|
|
</row>
|
|
</sheetData>
|
|
</worksheet>
|
|
''')
|
|
rows = sheet.findall('.//s:sheetData/s:row', ns)
|
|
mapping = [
|
|
{
|
|
'excel_tab': 'Argus Download',
|
|
'original_price_index': 'Original Mid',
|
|
'source': 'ARGUS',
|
|
'column_destination': 'mid_price',
|
|
'standard_price_index': 'ARGUS-CFR Brasil',
|
|
'index_description': 'Brazil CFR',
|
|
},
|
|
{
|
|
'excel_tab': 'Argus Download',
|
|
'original_price_index': 'Original High',
|
|
'source': 'ARGUS',
|
|
'column_destination': 'high_price',
|
|
'standard_price_index': 'ARGUS-CFR Brasil',
|
|
'index_description': 'Brazil CFR',
|
|
},
|
|
{
|
|
'excel_tab': 'Argus Download',
|
|
'original_price_index': 'Original Low',
|
|
'source': 'ARGUS',
|
|
'column_destination': 'low_price',
|
|
'standard_price_index': 'ARGUS-CFR Brasil',
|
|
'index_description': 'Brazil CFR',
|
|
},
|
|
{
|
|
'excel_tab': 'Acuity Download',
|
|
'original_price_index': 'Missing Header',
|
|
'source': 'ACUITY',
|
|
'column_destination': 'mid_price',
|
|
'standard_price_index': 'ACUITY-Missing',
|
|
'index_description': 'Missing',
|
|
},
|
|
]
|
|
|
|
result = ImportPrices._read_historical_all_sources_rows(
|
|
{'Argus Download': rows}, mapping, [], ns)
|
|
|
|
self.assertEqual(len(result), 1)
|
|
self.assertEqual(result[0]['price_index'], 'ARGUS-CFR Brasil')
|
|
self.assertEqual(result[0]['price_date'], '46191')
|
|
self.assertEqual(result[0]['mid_price'], '470')
|
|
self.assertEqual(result[0]['price_value'], '470')
|
|
self.assertEqual(result[0]['open_price'], '470')
|
|
self.assertEqual(result[0]['high_price'], '482')
|
|
self.assertEqual(result[0]['low_price'], '460')
|
|
self.assertEqual(result[0]['_source'], 'ARGUS')
|
|
self.assertEqual(result[0]['_price_desc'], 'Brazil CFR')
|
|
values = ImportPrices._price_value_values(
|
|
Mock(id=1), result[0], datetime.date(2026, 6, 18))
|
|
self.assertEqual(values['mid_price'], 470)
|
|
|
|
def test_import_xlsx_uses_selected_parser(self):
|
|
'price import selects parser from requested file structure'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
workbook = BytesIO()
|
|
with zipfile.ZipFile(workbook, 'w') as archive:
|
|
archive.writestr('xl/workbook.xml', '''
|
|
<workbook
|
|
xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
<sheets>
|
|
<sheet name="Prices" sheetId="1" r:id="rId1"/>
|
|
</sheets>
|
|
</workbook>
|
|
''')
|
|
archive.writestr('xl/_rels/workbook.xml.rels', '''
|
|
<Relationships
|
|
xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Target="worksheets/sheet1.xml"/>
|
|
</Relationships>
|
|
''')
|
|
archive.writestr('xl/worksheets/sheet1.xml', '''
|
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
<sheetData>
|
|
<row r="1">
|
|
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
|
|
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
|
|
</row>
|
|
<row r="2">
|
|
<c r="A2" t="inlineStr"><is><t>ARGUS-CFR Brasil</t></is></c>
|
|
<c r="B2"><v>46191</v></c>
|
|
</row>
|
|
</sheetData>
|
|
</worksheet>
|
|
''')
|
|
data = workbook.getvalue()
|
|
|
|
with patch.object(
|
|
ImportPrices, '_read_historical_price_rows',
|
|
return_value=['historical']) as historical, \
|
|
patch.object(
|
|
ImportPrices, '_read_historical_all_sources_price_rows',
|
|
return_value=['all_sources']) as all_sources, \
|
|
patch.object(
|
|
ImportPrices, '_read_forward_price_rows',
|
|
return_value=['forward']) as forward:
|
|
self.assertEqual(
|
|
ImportPrices._read_xlsx(
|
|
data, file_structure='historical'),
|
|
['historical'])
|
|
self.assertEqual(
|
|
ImportPrices._read_xlsx(
|
|
data, file_structure='historical_all_sources'),
|
|
['all_sources'])
|
|
self.assertEqual(
|
|
ImportPrices._read_xlsx(data, file_structure='forward'),
|
|
['forward'])
|
|
|
|
self.assertEqual(historical.call_count, 1)
|
|
self.assertEqual(all_sources.call_count, 1)
|
|
self.assertEqual(forward.call_count, 1)
|
|
|
|
def test_import_historical_all_sources_requires_exact_tab_match(self):
|
|
'all sources import ignores similarly named source sheets'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
|
|
result = ImportPrices._matching_sheet_name(
|
|
{
|
|
'Acuity Download CODELCO': [],
|
|
},
|
|
'Acuity Download')
|
|
|
|
self.assertIsNone(result)
|
|
|
|
def test_import_price_index_values_uses_mapping_metadata(self):
|
|
'created price indexes use mapped description and source area'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
area = Mock(id=8)
|
|
|
|
with patch.object(
|
|
ImportPrices, '_first_record',
|
|
side_effect=lambda model, domain: (
|
|
area if model == 'price.area' else None)):
|
|
values = ImportPrices._price_index_values(
|
|
'ARGUS-CFR Brasil',
|
|
{'_price_desc': 'Brazil CFR', '_source': 'ARGUS'})
|
|
|
|
self.assertEqual(values['price_index'], 'ARGUS-CFR Brasil')
|
|
self.assertEqual(values['price_desc'], 'Brazil CFR')
|
|
self.assertEqual(values['price_area'], 8)
|
|
|
|
def test_import_price_date_accepts_dot_format(self):
|
|
'price import accepts dd.mm.yyyy dates'
|
|
ImportPrices = Pool().get('purchase_trade.import_prices')
|
|
|
|
result = ImportPrices._as_date('18.06.2026')
|
|
|
|
self.assertEqual(result, datetime.date(2026, 6, 18))
|
|
|
|
def test_pricing_component_matrix_returns_generic_line_price(self):
|
|
'matrix pricing can use an unconditional matrix line as a component price'
|
|
Component = Pool().get('pricing.component')
|
|
component = Component()
|
|
component.price_source_type = 'matrix'
|
|
component.price_matrix = Mock(
|
|
id=10,
|
|
valid_from=datetime.date(2026, 1, 1),
|
|
valid_to=datetime.date(2026, 12, 31),
|
|
unit=None,
|
|
currency=None,
|
|
)
|
|
component.line = Mock(
|
|
product=Mock(),
|
|
purchase=Mock(from_location=Mock(), to_location=Mock()))
|
|
component.sale_line = None
|
|
matrix_line = Mock(
|
|
origin=None,
|
|
destination=None,
|
|
product=None,
|
|
quality=None,
|
|
price_value=Decimal('45'))
|
|
matrix_model = Mock(search=Mock(return_value=[matrix_line]))
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
|
|
PricingPool.return_value.get.return_value = matrix_model
|
|
price = component.get_price(
|
|
datetime.date(2026, 4, 1), Mock(), Mock(), False)
|
|
|
|
self.assertEqual(price, Decimal('45.0000'))
|
|
|
|
def test_pricing_component_curve_passes_relative_last(self):
|
|
'curve pricing can request the price relative last before target date'
|
|
Component = Pool().get('pricing.component')
|
|
price_index = Mock()
|
|
component = Component()
|
|
component.price_source_type = 'curve'
|
|
component.price_index = price_index
|
|
price_model = Mock(return_value=price_index)
|
|
price_index.get_price = Mock(return_value=Decimal('101'))
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
|
|
PricingPool.return_value.get.return_value = price_model
|
|
price = component.get_price(
|
|
datetime.date(2026, 4, 1), Mock(), Mock(), False, True)
|
|
|
|
self.assertEqual(price, Decimal('101'))
|
|
price_index.get_price.assert_called_once_with(
|
|
datetime.date(2026, 4, 1), ANY, ANY, False, True)
|
|
|
|
def test_price_get_price_relative_last_uses_latest_before_date(self):
|
|
'relative last picks latest available price before target date'
|
|
Price = Pool().get('price.price')
|
|
price = Price()
|
|
price.id = 42
|
|
price.price_values = [Mock()]
|
|
price.get_price_per_qt = Mock(return_value=Decimal('88'))
|
|
previous_value = Mock(price_value=Decimal('88'))
|
|
price_value_model = Mock()
|
|
price_value_model.search = Mock(side_effect=[[], [previous_value]])
|
|
|
|
with patch('trytond.modules.price.price.Pool') as PricePool:
|
|
PricePool.return_value.get.return_value = price_value_model
|
|
result = price.get_price(
|
|
datetime.date(2026, 4, 10), Mock(), Mock(),
|
|
last=False, relative_last=True)
|
|
|
|
self.assertEqual(result, Decimal('88'))
|
|
price_value_model.search.assert_any_call([
|
|
('price', '=', 42),
|
|
('price_date', '<=', '2026-04-10'),
|
|
], order=[('price_date', 'DESC')])
|
|
|
|
def test_pricing_component_matrix_prefers_specific_line_over_generic(self):
|
|
'matrix pricing keeps generic lines as fallback below matching lines'
|
|
Component = Pool().get('pricing.component')
|
|
origin = Mock(id=1)
|
|
component = Component()
|
|
component.price_source_type = 'matrix'
|
|
component.price_matrix = Mock(
|
|
id=10,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
unit=None,
|
|
currency=None,
|
|
)
|
|
component.line = Mock(
|
|
product=Mock(id=20),
|
|
purchase=Mock(from_location=origin, to_location=Mock(id=2)))
|
|
component.sale_line = None
|
|
generic = Mock(
|
|
origin=None,
|
|
destination=None,
|
|
product=None,
|
|
quality=None,
|
|
price_value=Decimal('45'))
|
|
specific = Mock(
|
|
origin=origin,
|
|
destination=None,
|
|
product=None,
|
|
quality=None,
|
|
price_value=Decimal('43'))
|
|
matrix_model = Mock(search=Mock(return_value=[generic, specific]))
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
|
|
PricingPool.return_value.get.return_value = matrix_model
|
|
price = component.get_price(
|
|
datetime.date(2026, 4, 1), Mock(), Mock(), False)
|
|
|
|
self.assertEqual(price, Decimal('43.0000'))
|
|
|
|
def test_pricing_component_matrix_uses_matrix_calendar_fallback(self):
|
|
'matrix calendar is used when the component has no own calendar'
|
|
Component = Pool().get('pricing.component')
|
|
calendar = Mock()
|
|
component = Component()
|
|
component.calendar = None
|
|
component.price_matrix = Mock(calendar=calendar)
|
|
|
|
self.assertEqual(component.get_calendar(), calendar)
|
|
|
|
def test_pricing_component_fixed_returns_flat_price(self):
|
|
'fixed pricing returns the component flat price'
|
|
Component = Pool().get('pricing.component')
|
|
component = Component()
|
|
component.price_source_type = 'fixed'
|
|
component.fixed_price = Decimal('125.25')
|
|
currency = Mock(id=1)
|
|
component.fixed_currency = currency
|
|
|
|
self.assertEqual(
|
|
component.get_price(
|
|
datetime.date(2026, 4, 1), Mock(), currency, False),
|
|
Decimal('125.2500'))
|
|
|
|
def test_pricing_component_fixed_converts_currency(self):
|
|
'fixed pricing converts from component currency to requested currency'
|
|
Component = Pool().get('pricing.component')
|
|
component = Component()
|
|
component.price_source_type = 'fixed'
|
|
component.fixed_price = Decimal('100')
|
|
component.fixed_currency = Mock(id=1)
|
|
target_currency = Mock(id=2)
|
|
currency_model = Mock(
|
|
_get_rate=Mock(return_value={
|
|
component.fixed_currency.id: Decimal('1.2')}))
|
|
|
|
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
|
|
PricingPool.return_value.get.return_value = currency_model
|
|
price = component.get_price(
|
|
datetime.date(2026, 4, 1), Mock(), target_currency, False)
|
|
|
|
self.assertEqual(price, Decimal('120.0000'))
|
|
|
|
def test_sale_and_purchase_trader_operator_domains_use_explicit_categories(self):
|
|
'sale and purchase trader/operator fields are filtered by TRADER/OPERATOR categories'
|
|
Sale = Pool().get('sale.sale')
|
|
Purchase = Pool().get('purchase.purchase')
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
|
|
self.assertEqual(
|
|
Sale.trader.domain, [('categories.name', '=', 'TRADER')])
|
|
self.assertEqual(
|
|
Sale.operator.domain, [('categories.name', '=', 'OPERATOR')])
|
|
self.assertEqual(
|
|
Purchase.trader.domain, [('categories.name', '=', 'TRADER')])
|
|
self.assertEqual(
|
|
Purchase.operator.domain, [('categories.name', '=', 'OPERATOR')])
|
|
self.assertEqual(
|
|
ShipmentIn.operator.domain, [('categories.name', '=', 'OPERATOR')])
|
|
|
|
def test_purchase_form_has_pnl_graph_tab_after_pnl(self):
|
|
'purchase form exposes a rich graph for historical PnL and MTM'
|
|
path = Path('modules/purchase_trade/view/purchase_form.xml')
|
|
root = ElementTree.parse(path).getroot()
|
|
pages = root.findall(".//xpath[@expr=\"/form/notebook/page[@id='info']\"]//page")
|
|
page_ids = [page.get('id') for page in pages]
|
|
pnl_index = page_ids.index('pnl')
|
|
|
|
self.assertEqual(page_ids[pnl_index + 1], 'pnl_graph')
|
|
|
|
graph_field = pages[pnl_index + 1].find("field[@name='pnl_graph']")
|
|
self.assertIsNotNone(graph_field)
|
|
self.assertEqual(graph_field.get('widget'), 'rich_graph')
|
|
self.assertEqual(graph_field.get('records_field'), 'pnl_graph')
|
|
self.assertEqual(graph_field.get('date_field'), 'valuation_date')
|
|
self.assertEqual(graph_field.get('series'), 'pnl,mtm')
|
|
self.assertEqual(
|
|
graph_field.get('view_ids'),
|
|
'purchase_trade.purchase_pnl_graph_view_tree')
|
|
self.assertEqual(graph_field.get('labels'), 'pnl:Pnl,mtm:Mtm')
|
|
self.assertEqual(graph_field.get('hidden_series'), 'pnl_graph')
|
|
|
|
value_fields = pages[pnl_index + 1].findall("field[@name='pnl_graph']")
|
|
self.assertEqual(value_fields[1].get('mode'), 'tree')
|
|
self.assertEqual(
|
|
value_fields[1].get('view_ids'),
|
|
'purchase_trade.purchase_pnl_graph_view_tree')
|
|
|
|
def test_shipment_in_pnl_lines_use_physical_and_open_lots(self):
|
|
'shipment PnL uses valuation lines from physical and open lot links'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.id = 99
|
|
physical_lot = Mock(id=1)
|
|
open_purchase_lot = Mock(id=2)
|
|
open_sale_lot = Mock(id=3)
|
|
shipment.incoming_moves = [
|
|
Mock(lot=physical_lot),
|
|
Mock(lot=None),
|
|
]
|
|
shipment.lotqt = [
|
|
Mock(lot_p=open_purchase_lot, lot_s=open_sale_lot),
|
|
Mock(lot_p=open_purchase_lot, lot_s=None),
|
|
]
|
|
valuation_model = Mock()
|
|
valuation_model.search.return_value = [Mock(id=10), Mock(id=11)]
|
|
|
|
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = valuation_model
|
|
self.assertEqual(shipment.get_pnl_lines(None), [10, 11])
|
|
|
|
valuation_model.search.assert_called_once_with(
|
|
[('shipment_in', '=', 99)],
|
|
order=[('date', 'DESC'), ('id', 'DESC')])
|
|
|
|
def test_sale_and_purchase_tolerance_option_defaults_to_empty(self):
|
|
'sale and purchase tolerance option defaults to empty'
|
|
Sale = Pool().get('sale.sale')
|
|
Purchase = Pool().get('purchase.purchase')
|
|
|
|
self.assertEqual(Sale.default_tolerance_option(), '')
|
|
self.assertEqual(Purchase.default_tolerance_option(), '')
|
|
|
|
def test_purchase_line_premium_sums_decomposition_base_amounts(self):
|
|
'purchase line premium is the sum of premium decomposition base amounts'
|
|
Line = Pool().get('purchase.line')
|
|
line = Line()
|
|
line.premium_decomposition = [
|
|
Mock(base_amount=Decimal('12.50')),
|
|
Mock(base_amount=Decimal('-2.25')),
|
|
]
|
|
|
|
self.assertEqual(line.get_premium('premium'), Decimal('10.2500'))
|
|
|
|
def test_sale_line_premium_price_falls_back_to_decomposition(self):
|
|
'sale line premium price works when the function field is not loaded'
|
|
class LineWithoutPremium:
|
|
premium_decomposition = [
|
|
Mock(base_amount=Decimal('7.25')),
|
|
Mock(base_amount=Decimal('-1.00')),
|
|
]
|
|
|
|
get_premium = sale_module.SaleLine.get_premium
|
|
|
|
line = LineWithoutPremium()
|
|
|
|
self.assertEqual(
|
|
sale_module.SaleLine._get_premium_price(line),
|
|
Decimal('6.2500'))
|
|
|
|
def test_purchase_and_sale_line_premium_fields_are_readonly_totals(self):
|
|
'line premium fields are readonly totals from premium decomposition'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
SaleLine = Pool().get('sale.line')
|
|
|
|
self.assertTrue(PurchaseLine.premium.readonly)
|
|
self.assertTrue(SaleLine.premium.readonly)
|
|
|
|
def test_premium_composition_base_amount_converts_on_contract_date(self):
|
|
'premium composition base amount converts to contract currency at spot date'
|
|
Premium = Pool().get('premium.composition')
|
|
premium = Premium()
|
|
source_currency = Mock(id=1)
|
|
target_currency = Mock(id=2)
|
|
premium.premium = Decimal('100')
|
|
premium.currency = source_currency
|
|
premium.line = Mock(purchase=Mock(
|
|
currency=target_currency,
|
|
purchase_date=datetime.date(2026, 5, 1)))
|
|
currency_model = Mock(
|
|
compute=Mock(return_value=Decimal('120')))
|
|
|
|
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = currency_model
|
|
amount = premium.get_base_amount('base_amount')
|
|
|
|
self.assertEqual(amount, Decimal('120.000000'))
|
|
|
|
def test_fee_rule_matches_purchase_line_conditions(self):
|
|
'fee rule matches purchase line conditions'
|
|
party = Mock(id=1)
|
|
product = Mock(id=2)
|
|
incoterm = Mock(id=3)
|
|
from_location = Mock(id=4)
|
|
to_location = Mock(id=5)
|
|
plan = Mock(id=6)
|
|
contract = Mock(
|
|
party=party,
|
|
incoterm=incoterm,
|
|
from_location=from_location,
|
|
to_location=to_location,
|
|
plan=plan,
|
|
)
|
|
line = Mock(product=product, purchase=contract, sale=None)
|
|
rule = fee_module.FeeRule()
|
|
rule.apply_on = 'purchase_line'
|
|
rule.party = party
|
|
rule.line_product = product
|
|
rule.incoterm_code = 'FOB'
|
|
incoterm.code = 'FOB'
|
|
rule.from_location = from_location
|
|
rule.to_location = to_location
|
|
rule.execution_plan = plan
|
|
|
|
self.assertTrue(rule._matches_line(line, 'purchase_line'))
|
|
|
|
def test_fee_rule_matches_counterparty_category_parent(self):
|
|
'fee rule can match a counterparty by parent category'
|
|
parent_category = Mock(id=1, parent=None)
|
|
child_category = Mock(id=2, parent=parent_category)
|
|
party = Mock(id=3, categories=[child_category])
|
|
contract = Mock(
|
|
party=party,
|
|
incoterm=None,
|
|
from_location=None,
|
|
to_location=None,
|
|
plan=None,
|
|
)
|
|
line = Mock(product=None, purchase=contract, sale=None)
|
|
rule = fee_module.FeeRule()
|
|
rule.apply_on = 'purchase_line'
|
|
rule.party = None
|
|
rule.party_category = parent_category
|
|
rule.line_product = None
|
|
rule.incoterm_code = None
|
|
rule.from_location = None
|
|
rule.to_location = None
|
|
rule.execution_plan = None
|
|
|
|
self.assertTrue(rule._matches_line(line, 'purchase_line'))
|
|
|
|
def test_fee_rule_builds_budgeted_sale_fee_values(self):
|
|
'fee rule builds budgeted fee values for a sale line'
|
|
currency = Mock(id=1)
|
|
unit = Mock(id=2)
|
|
party = Mock(id=3)
|
|
product = Mock(id=4)
|
|
fee_product = Mock(id=5)
|
|
sale = Mock(party=party, currency=currency)
|
|
line = Mock(
|
|
id=10,
|
|
product=product,
|
|
sale=sale,
|
|
purchase=None,
|
|
unit=unit,
|
|
quantity_theorical=Decimal('100'),
|
|
quantity=Decimal('90'),
|
|
)
|
|
rule = fee_module.FeeRule()
|
|
rule.id = 20
|
|
rule.fee_product = fee_product
|
|
rule.supplier = None
|
|
rule.p_r = 'pay'
|
|
rule.mode = 'perqt'
|
|
rule.price = Decimal('12.50')
|
|
rule.quantity = None
|
|
rule.currency = None
|
|
rule.unit = None
|
|
rule.qt_state = None
|
|
rule.weight_type = 'brut'
|
|
|
|
values = rule._fee_values(line, 'sale_line')
|
|
|
|
self.assertEqual(values['source_rule'], 20)
|
|
self.assertTrue(values['generated_by_rule'])
|
|
self.assertEqual(values['type'], 'budgeted')
|
|
self.assertEqual(values['sale_line'], 10)
|
|
self.assertEqual(values['product'], 5)
|
|
self.assertEqual(values['supplier'], 3)
|
|
self.assertEqual(values['currency'], 1)
|
|
self.assertEqual(values['unit'], 2)
|
|
self.assertEqual(values['quantity'], Decimal('100'))
|
|
|
|
def test_sale_line_basis_price_and_progress_use_manual_summary_without_component(self):
|
|
'sale line basis values use manual summary rows even without a component'
|
|
SaleLine = Pool().get('sale.line')
|
|
summary_model = Mock()
|
|
summary_model.search.side_effect = [
|
|
[Mock(price=Decimal('150'), progress=1, price_component=None)],
|
|
[Mock(price=Decimal('150'), progress=1, price_component=None)],
|
|
]
|
|
|
|
line = SaleLine()
|
|
line.id = 1
|
|
line.price_type = 'basis'
|
|
line.price_components = []
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = summary_model
|
|
|
|
self.assertEqual(line.get_basis_price(), Decimal('150.0000'))
|
|
self.assertEqual(line.get_progress('progress'), 1)
|
|
|
|
def test_purchase_line_basis_price_and_progress_use_manual_summary_without_component(self):
|
|
'purchase line basis values use manual summary rows even without a component'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
summary_model = Mock()
|
|
summary_model.search.side_effect = [
|
|
[Mock(price=Decimal('150'), progress=1, price_component=None)],
|
|
[Mock(price=Decimal('150'), progress=1, price_component=None)],
|
|
]
|
|
|
|
line = PurchaseLine()
|
|
line.id = 1
|
|
line.price_type = 'basis'
|
|
line.price_components = []
|
|
line.enable_linked_currency = False
|
|
line.linked_currency = None
|
|
|
|
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = summary_model
|
|
|
|
self.assertEqual(line.get_basis_price(), Decimal('150.0000'))
|
|
self.assertEqual(line.get_progress('progress'), 1)
|
|
|
|
def test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases(self):
|
|
'sale line write increases virtual lot and open lot.qt when contractual qty grows'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = Mock(id=1, quantity_theorical=Decimal('10'))
|
|
line.quantity = Decimal('10')
|
|
line.unit = Mock()
|
|
line.fees = []
|
|
vlot = Mock(id=99, lot_type='virtual')
|
|
vlot.get_current_quantity_converted.return_value = Decimal('10')
|
|
line.lots = [vlot]
|
|
lqt = Mock(lot_quantity=Decimal('10'))
|
|
|
|
lot_model = Mock()
|
|
lotqt_model = Mock()
|
|
lotqt_model.search.side_effect = [[lqt], []]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.sale.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.sale.super'
|
|
) as super_mock, patch.object(SaleLine, 'save') as save:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': lot_model,
|
|
'lot.qt': lotqt_model,
|
|
}[name]
|
|
|
|
def fake_super_write(*args):
|
|
for records, values in zip(args[::2], args[1::2]):
|
|
if 'quantity_theorical' in values:
|
|
self.assertEqual(values['quantity'], Decimal('12.00000'))
|
|
for record in records:
|
|
record.quantity_theorical = values['quantity_theorical']
|
|
record.quantity = values['quantity']
|
|
|
|
super_mock.return_value.write.side_effect = fake_super_write
|
|
|
|
SaleLine.write([line], {'quantity_theorical': Decimal('12')})
|
|
|
|
self.assertEqual(lqt.lot_quantity, Decimal('12'))
|
|
self.assertEqual(line.quantity, Decimal('12.00000'))
|
|
vlot.set_current_quantity.assert_called_once_with(
|
|
Decimal('12'), Decimal('12'), 1)
|
|
lot_model.save.assert_called()
|
|
lotqt_model.save.assert_called()
|
|
save.assert_not_called()
|
|
|
|
def test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity(self):
|
|
'sale line write blocks contractual qty decrease when open lot.qt is insufficient'
|
|
SaleLine = Pool().get('sale.line')
|
|
line = Mock(id=2, quantity_theorical=Decimal('10'))
|
|
line.quantity = Decimal('10')
|
|
line.fees = []
|
|
vlot = Mock(id=100, lot_type='virtual')
|
|
vlot.get_current_quantity_converted.return_value = Decimal('10')
|
|
line.lots = [vlot]
|
|
lqt = Mock(lot_quantity=Decimal('1'))
|
|
matched_lqt = Mock(lot_quantity=Decimal('9'))
|
|
|
|
lot_model = Mock()
|
|
lotqt_model = Mock()
|
|
lotqt_model.search.side_effect = [[lqt], [matched_lqt]]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.sale.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.sale.super'
|
|
) as super_mock, patch.object(SaleLine, 'save') as save:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': lot_model,
|
|
'lot.qt': lotqt_model,
|
|
}[name]
|
|
|
|
def fake_super_write(*args):
|
|
for records, values in zip(args[::2], args[1::2]):
|
|
if 'quantity_theorical' in values:
|
|
self.assertEqual(values['quantity'], Decimal('8.00000'))
|
|
for record in records:
|
|
record.quantity_theorical = values['quantity_theorical']
|
|
record.quantity = values['quantity']
|
|
|
|
super_mock.return_value.write.side_effect = fake_super_write
|
|
|
|
with self.assertRaises(UserError):
|
|
SaleLine.write([line], {'quantity_theorical': Decimal('8')})
|
|
save.assert_not_called()
|
|
|
|
def test_purchase_line_write_syncs_open_lot_qt_with_physical_lots(self):
|
|
'purchase line write keeps open lot.qt net of existing physical lots'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = Mock(id=4, quantity_theorical=Decimal('10000'))
|
|
line.quantity = Decimal('10000')
|
|
line.unit = Mock()
|
|
line.fees = []
|
|
vlot = Mock(id=102, lot_type='virtual')
|
|
vlot.get_current_quantity_converted.return_value = Decimal('10000')
|
|
physical = Mock(lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('10000')
|
|
line.lots = [vlot, physical]
|
|
lqt = Mock(lot_quantity=Decimal('10000'))
|
|
|
|
lot_model = Mock()
|
|
lotqt_model = Mock()
|
|
lotqt_model.search.side_effect = [[lqt], []]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.purchase.super'
|
|
) as super_mock, patch.object(PurchaseLine, 'save') as save:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': lot_model,
|
|
'lot.qt': lotqt_model,
|
|
}[name]
|
|
|
|
def fake_super_write(*args):
|
|
for records, values in zip(args[::2], args[1::2]):
|
|
if 'quantity_theorical' in values:
|
|
for record in records:
|
|
record.quantity_theorical = values['quantity_theorical']
|
|
|
|
super_mock.return_value.write.side_effect = fake_super_write
|
|
|
|
PurchaseLine.write(
|
|
[line], {'quantity_theorical': Decimal('20000')})
|
|
|
|
self.assertEqual(lqt.lot_quantity, Decimal('10000'))
|
|
self.assertEqual(line.quantity, Decimal('10000'))
|
|
vlot.set_current_quantity.assert_not_called()
|
|
lot_model.save.assert_not_called()
|
|
lotqt_model.save.assert_not_called()
|
|
save.assert_not_called()
|
|
|
|
def test_purchase_line_write_syncs_quantity_without_physical_lots(self):
|
|
'purchase line write keeps quantity equal to contractual qty without physical lots'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = Mock(id=6, quantity_theorical=Decimal('1500'))
|
|
line.quantity = Decimal('1500')
|
|
line.unit = Mock()
|
|
line.fees = []
|
|
vlot = Mock(id=104, lot_type='virtual')
|
|
vlot.get_current_quantity_converted.return_value = Decimal('1500')
|
|
line.lots = [vlot]
|
|
free_lqt = Mock(lot_quantity=Decimal('50'))
|
|
allocated_lqts = [
|
|
Mock(lot_quantity=Decimal('400')),
|
|
Mock(lot_quantity=Decimal('500')),
|
|
Mock(lot_quantity=Decimal('550')),
|
|
]
|
|
|
|
lot_model = Mock()
|
|
lotqt_model = Mock()
|
|
lotqt_model.search.side_effect = [[free_lqt], allocated_lqts]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.purchase.super'
|
|
) as super_mock, patch.object(PurchaseLine, 'save') as save:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': lot_model,
|
|
'lot.qt': lotqt_model,
|
|
}[name]
|
|
|
|
def fake_super_write(*args):
|
|
for records, values in zip(args[::2], args[1::2]):
|
|
if 'quantity_theorical' in values:
|
|
self.assertEqual(values['quantity'], Decimal('2000.00000'))
|
|
for record in records:
|
|
record.quantity_theorical = values['quantity_theorical']
|
|
record.quantity = values['quantity']
|
|
|
|
super_mock.return_value.write.side_effect = fake_super_write
|
|
|
|
PurchaseLine.write(
|
|
[line], {'quantity_theorical': Decimal('2000')})
|
|
|
|
self.assertEqual(line.quantity, Decimal('2000.00000'))
|
|
self.assertEqual(free_lqt.lot_quantity, Decimal('550.00000'))
|
|
vlot.set_current_quantity.assert_called_once_with(
|
|
Decimal('2000.00000'), Decimal('2000.00000'), 1)
|
|
save.assert_not_called()
|
|
|
|
def test_purchase_line_write_syncs_virtual_fee_quantity(self):
|
|
'purchase line write updates fee quantity when only virtual lot exists'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
fee = Mock()
|
|
line = Mock(id=5, quantity_theorical=Decimal('20000'))
|
|
line.quantity = Decimal('20000')
|
|
line.unit = Mock()
|
|
line.fees = [fee]
|
|
vlot = Mock(id=103, lot_type='virtual')
|
|
vlot.get_current_quantity_converted.return_value = Decimal('20000')
|
|
line.lots = [vlot]
|
|
lqt = Mock(lot_quantity=Decimal('20000'))
|
|
|
|
lot_model = Mock()
|
|
lotqt_model = Mock()
|
|
lotqt_model.search.side_effect = [[lqt], []]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.purchase.super'
|
|
) as super_mock, patch.object(PurchaseLine, 'save') as save:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': lot_model,
|
|
'lot.qt': lotqt_model,
|
|
}[name]
|
|
|
|
def fake_super_write(*args):
|
|
for records, values in zip(args[::2], args[1::2]):
|
|
if 'quantity_theorical' in values:
|
|
self.assertEqual(values['quantity'], Decimal('10000.00000'))
|
|
for record in records:
|
|
record.quantity_theorical = values['quantity_theorical']
|
|
record.quantity = values['quantity']
|
|
|
|
super_mock.return_value.write.side_effect = fake_super_write
|
|
|
|
PurchaseLine.write(
|
|
[line], {'quantity_theorical': Decimal('10000')})
|
|
|
|
self.assertEqual(lqt.lot_quantity, Decimal('10000.00000'))
|
|
fee.sync_quantity_from_lots.assert_called_once_with()
|
|
fee.adjust_purchase_values.assert_called_once_with()
|
|
save.assert_not_called()
|
|
|
|
def test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot(self):
|
|
'purchase line write does not re-add quantity when initializing contractual qty'
|
|
PurchaseLine = Pool().get('purchase.line')
|
|
line = Mock(id=3, quantity_theorical=None)
|
|
line.quantity = Decimal('10')
|
|
vlot = Mock(id=101, lot_type='virtual')
|
|
vlot.get_current_quantity_converted.return_value = Decimal('10')
|
|
line.lots = [vlot]
|
|
|
|
lot_model = Mock()
|
|
lotqt_model = Mock()
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.purchase.Pool'
|
|
) as PoolMock, patch(
|
|
'trytond.modules.purchase_trade.purchase.super'
|
|
) as super_mock, patch.object(PurchaseLine, 'save') as save:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'lot.lot': lot_model,
|
|
'lot.qt': lotqt_model,
|
|
}[name]
|
|
|
|
def fake_super_write(*args):
|
|
for records, values in zip(args[::2], args[1::2]):
|
|
if 'quantity_theorical' in values:
|
|
for record in records:
|
|
record.quantity_theorical = values['quantity_theorical']
|
|
record.quantity = values['quantity']
|
|
|
|
super_mock.return_value.write.side_effect = fake_super_write
|
|
|
|
PurchaseLine.write(
|
|
[line], {'quantity_theorical': Decimal('10')})
|
|
|
|
vlot.set_current_quantity.assert_not_called()
|
|
lot_model.save.assert_not_called()
|
|
lotqt_model.save.assert_not_called()
|
|
save.assert_not_called()
|
|
|
|
def test_party_execution_achieved_percent_uses_real_area_statistics(self):
|
|
'party execution achieved percent reflects the controller share in its area'
|
|
PartyExecution = Pool().get('party.execution')
|
|
execution = PartyExecution()
|
|
execution.party = Mock(id=1)
|
|
execution.area = Mock(id=10)
|
|
|
|
shipments = [
|
|
Mock(controller=Mock(id=1)),
|
|
Mock(controller=Mock(id=2)),
|
|
Mock(controller=Mock(id=1)),
|
|
Mock(controller=Mock(id=2)),
|
|
Mock(controller=Mock(id=1)),
|
|
]
|
|
shipment_model = Mock()
|
|
shipment_model.search.return_value = shipments
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.party.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = shipment_model
|
|
|
|
self.assertEqual(
|
|
execution.get_percent('achieved_percent'),
|
|
Decimal('60.00'))
|
|
|
|
def test_get_controller_prioritizes_controller_farthest_from_target(self):
|
|
'shipment controller selection prioritizes the most under-target rule'
|
|
Shipment = Pool().get('stock.shipment.in')
|
|
Party = Pool().get('party.party')
|
|
PartyExecution = Pool().get('party.execution')
|
|
|
|
shipment = Shipment()
|
|
shipment.to_location = Mock(
|
|
country=Mock(region=Mock(id=20, parent=Mock(id=10, parent=None))))
|
|
|
|
party_a = Party()
|
|
party_a.id = 1
|
|
rule_a = PartyExecution()
|
|
rule_a.party = party_a
|
|
rule_a.area = Mock(id=10)
|
|
rule_a.percent = Decimal('80')
|
|
rule_a.compute_achieved_percent = Mock(return_value=Decimal('40'))
|
|
party_a.execution = [rule_a]
|
|
|
|
party_b = Party()
|
|
party_b.id = 2
|
|
rule_b = PartyExecution()
|
|
rule_b.party = party_b
|
|
rule_b.area = Mock(id=10)
|
|
rule_b.percent = Decimal('50')
|
|
rule_b.compute_achieved_percent = Mock(return_value=Decimal('45'))
|
|
party_b.execution = [rule_b]
|
|
|
|
category_model = Mock()
|
|
category_model.search.return_value = [Mock(id=99)]
|
|
party_category_model = Mock()
|
|
party_category_model.search.return_value = [
|
|
Mock(party=party_b),
|
|
Mock(party=party_a),
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.stock.Pool'
|
|
) as PoolMock:
|
|
def get_model(name):
|
|
return {
|
|
'party.category': category_model,
|
|
'party.party-party.category': party_category_model,
|
|
}[name]
|
|
|
|
PoolMock.return_value.get.side_effect = get_model
|
|
|
|
self.assertIs(shipment.get_controller(), party_a)
|
|
|
|
def test_weight_report_get_source_shipment_rejects_multiple_shipments(self):
|
|
'weight report export must not guess when the same WR is linked twice'
|
|
WeightReport = Pool().get('weight.report')
|
|
report = WeightReport()
|
|
report.id = 7
|
|
|
|
shipment_wr_model = Mock()
|
|
shipment_wr_model.search.return_value = [
|
|
Mock(shipment_in=Mock(id=1)),
|
|
Mock(shipment_in=Mock(id=2)),
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.weight_report.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = shipment_wr_model
|
|
with self.assertRaises(UserError):
|
|
report.get_source_shipment()
|
|
|
|
def test_weight_report_remote_context_requires_controller_and_returned_id(self):
|
|
'weight report export checks the shipment prerequisites before calling FastAPI'
|
|
WeightReport = Pool().get('weight.report')
|
|
report = WeightReport()
|
|
report.bales = 100
|
|
report.report_date = Mock(strftime=Mock(return_value='20260406'))
|
|
report.weight_date = Mock(strftime=Mock(return_value='20260406'))
|
|
|
|
shipment = Mock(
|
|
controller=None,
|
|
returned_id='RET-001',
|
|
agent=Mock(),
|
|
to_location=Mock(),
|
|
)
|
|
|
|
with self.assertRaises(UserError):
|
|
report.validate_remote_weight_report_context(shipment)
|
|
|
|
shipment.controller = Mock()
|
|
shipment.returned_id = None
|
|
with self.assertRaises(UserError):
|
|
report.validate_remote_weight_report_context(shipment)
|
|
|
|
def test_weight_report_create_from_json_accepts_missing_weight_date(self):
|
|
'weight report import keeps working when OCR returns null for weight_date'
|
|
WeightReport = Pool().get('weight.report')
|
|
party_model = Mock()
|
|
vessel_model = Mock()
|
|
location_model = Mock()
|
|
party_model.getPartyByName.side_effect = lambda name: Mock(id={
|
|
'SELLER': 1,
|
|
'BUYER': 2,
|
|
'CARRIER': 3,
|
|
}[name])
|
|
vessel_model.search.return_value = []
|
|
location_model.search.return_value = []
|
|
|
|
payload = {
|
|
'lab': 'LAB',
|
|
'report': {
|
|
'reference': 'REF',
|
|
'file_no': 'FILE',
|
|
'date': '28 October 2025',
|
|
},
|
|
'contract': {},
|
|
'parties': {
|
|
'seller': 'SELLER',
|
|
'buyer': 'BUYER',
|
|
'carrier': 'CARRIER',
|
|
},
|
|
'shipment': {
|
|
'bl_no': 'BL-1',
|
|
'weighing_place': 'PORT',
|
|
'weighing_method': 'METHOD',
|
|
'bales': 10,
|
|
},
|
|
'weights': {
|
|
'weight_date': None,
|
|
'gross_landed_kg': '10',
|
|
'tare_kg': '1',
|
|
'net_landed_kg': '9',
|
|
'invoice_net_kg': '9',
|
|
'gain_loss_kg': '0',
|
|
'gain_loss_percent': '0',
|
|
}
|
|
}
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.weight_report.Pool') as PoolMock, patch.object(
|
|
WeightReport, 'create', return_value=[payload]) as create_mock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'party.party': party_model,
|
|
'trade.vessel': vessel_model,
|
|
'stock.location': location_model,
|
|
}[name]
|
|
|
|
created = WeightReport.create_from_json(payload)
|
|
|
|
self.assertEqual(created, payload)
|
|
values = create_mock.call_args.args[0][0]
|
|
self.assertIsNone(values['weight_date'])
|
|
self.assertEqual(values['report_date'].isoformat(), '2025-10-28')
|
|
|
|
def test_weight_report_create_from_json_accepts_bales_with_thousand_separator(self):
|
|
'weight report import accepts OCR bale counts formatted like 1.100'
|
|
WeightReport = Pool().get('weight.report')
|
|
party_model = Mock()
|
|
vessel_model = Mock()
|
|
location_model = Mock()
|
|
party_model.getPartyByName.side_effect = lambda name: Mock(id={
|
|
'SELLER': 1,
|
|
'BUYER': 2,
|
|
'CARRIER': 3,
|
|
}[name])
|
|
vessel_model.search.return_value = []
|
|
location_model.search.return_value = []
|
|
|
|
payload = {
|
|
'lab': 'LAB',
|
|
'report': {
|
|
'reference': 'REF',
|
|
'file_no': 'FILE',
|
|
'date': '02 Apr 2026',
|
|
},
|
|
'contract': {},
|
|
'parties': {
|
|
'seller': 'SELLER',
|
|
'buyer': 'BUYER',
|
|
'carrier': 'CARRIER',
|
|
},
|
|
'shipment': {
|
|
'bl_no': 'BL-1',
|
|
'weighing_place': 'PORT',
|
|
'weighing_method': 'METHOD',
|
|
'bales': '1.100',
|
|
},
|
|
'weights': {
|
|
'weight_date': None,
|
|
'gross_landed_kg': '10',
|
|
'tare_kg': '1',
|
|
'net_landed_kg': '9',
|
|
'invoice_net_kg': '9',
|
|
'gain_loss_kg': '0',
|
|
'gain_loss_percent': '0',
|
|
}
|
|
}
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.weight_report.Pool') as PoolMock, patch.object(
|
|
WeightReport, 'create', return_value=[payload]) as create_mock:
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'party.party': party_model,
|
|
'trade.vessel': vessel_model,
|
|
'stock.location': location_model,
|
|
}[name]
|
|
|
|
created = WeightReport.create_from_json(payload)
|
|
|
|
self.assertEqual(created, payload)
|
|
values = create_mock.call_args.args[0][0]
|
|
self.assertEqual(values['bales'], 1100)
|
|
|
|
def test_invoice_report_uses_invoice_template_from_configuration(self):
|
|
'invoice report path is resolved from purchase_trade configuration'
|
|
report_class = Pool().get('account.invoice', type='report')
|
|
config_model = Mock()
|
|
config_model.search.return_value = [
|
|
Mock(
|
|
sale_report_template='sale_melya.fodt',
|
|
sale_commission_report_template='sale/sale_commission_ict.fodt',
|
|
sale_bill_report_template='bill_melya.fodt',
|
|
sale_final_report_template='sale_final_melya.fodt',
|
|
invoice_report_template='invoice_melya.fodt',
|
|
invoice_cndn_report_template='invoice_ict_final.fodt',
|
|
invoice_commission_cndn_report_template=(
|
|
'commission__ict_final.fodt'),
|
|
invoice_prepayment_report_template='prepayment.fodt',
|
|
invoice_packing_list_report_template='packing_list.fodt',
|
|
invoice_payment_order_report_template='payment_order.fodt',
|
|
purchase_report_template='purchase_melya.fodt',
|
|
purchase_commission_report_template=(
|
|
'purchase/purchase_commission_ict.fodt'),
|
|
)
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.invoice.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = config_model
|
|
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Invoice',
|
|
'report': 'account_invoice/invoice.fodt',
|
|
}),
|
|
'account_invoice/invoice_melya.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Prepayment',
|
|
'report': 'account_invoice/prepayment.fodt',
|
|
}),
|
|
'account_invoice/prepayment.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'CN/DN',
|
|
'report': 'account_invoice/invoice_ict_final.fodt',
|
|
}),
|
|
'account_invoice/invoice_ict_final.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'CN/DN Commission',
|
|
'report': 'account_invoice/commission__ict_final.fodt',
|
|
}),
|
|
'account_invoice/commission__ict_final.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Commission invoice Sale',
|
|
'report': 'account_invoice/sale_commission_ict.fodt',
|
|
}),
|
|
'account_invoice/sale_commission_ict.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Commission invoice Purchase',
|
|
'report': 'account_invoice/purchase_commission_ict.fodt',
|
|
}),
|
|
'account_invoice/purchase_commission_ict.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Payment Order',
|
|
'report': 'account_invoice/payment_order.fodt',
|
|
}),
|
|
'account_invoice/payment_order.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Packing List',
|
|
'report': 'account_invoice/packing_list.fodt',
|
|
}),
|
|
'account_invoice/packing_list.fodt')
|
|
|
|
def test_invoice_report_raises_when_template_is_missing(self):
|
|
'invoice report must fail clearly when no template is configured'
|
|
report_class = Pool().get('account.invoice', type='report')
|
|
config_model = Mock()
|
|
config_model.search.return_value = [
|
|
Mock(
|
|
invoice_report_template='',
|
|
invoice_cndn_report_template='',
|
|
invoice_commission_cndn_report_template='',
|
|
sale_commission_report_template='',
|
|
purchase_commission_report_template='',
|
|
invoice_prepayment_report_template='',
|
|
invoice_packing_list_report_template='',
|
|
invoice_payment_order_report_template='',
|
|
)
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.invoice.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = config_model
|
|
with self.assertRaises(UserError):
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Invoice',
|
|
'report': 'account_invoice/invoice.fodt',
|
|
})
|
|
with self.assertRaises(UserError):
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Commission invoice Sale',
|
|
'report': 'account_invoice/sale_commission_ict.fodt',
|
|
})
|
|
with self.assertRaises(UserError):
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Commission invoice Purchase',
|
|
'report': 'account_invoice/purchase_commission_ict.fodt',
|
|
})
|
|
with self.assertRaises(UserError):
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Payment Order',
|
|
'report': 'account_invoice/payment_order.fodt',
|
|
})
|
|
with self.assertRaises(UserError):
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Packing List',
|
|
'report': 'account_invoice/packing_list.fodt',
|
|
})
|
|
|
|
def test_sale_report_uses_templates_from_configuration(self):
|
|
'sale report paths are resolved from purchase_trade configuration'
|
|
report_class = Pool().get('sale.sale', type='report')
|
|
config_model = Mock()
|
|
config_model.search.return_value = [
|
|
Mock(
|
|
sale_report_template='sale_melya.fodt',
|
|
sale_commission_report_template='sale_commission_ict.fodt',
|
|
sale_bill_report_template='bill_melya.fodt',
|
|
sale_final_report_template='sale_final_melya.fodt',
|
|
)
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.invoice.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = config_model
|
|
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Sale',
|
|
'report': 'sale/sale.fodt',
|
|
}),
|
|
'sale/sale_melya.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Bill',
|
|
'report': 'sale/bill.fodt',
|
|
}),
|
|
'sale/bill_melya.fodt')
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Sale (final)',
|
|
'report': 'sale/sale_final.fodt',
|
|
}),
|
|
'sale/sale_final_melya.fodt')
|
|
|
|
def test_purchase_report_uses_template_from_configuration(self):
|
|
'purchase report path is resolved from purchase_trade configuration'
|
|
report_class = Pool().get('purchase.purchase', type='report')
|
|
config_model = Mock()
|
|
config_model.search.return_value = [
|
|
Mock(
|
|
purchase_report_template='purchase_melya.fodt',
|
|
purchase_commission_report_template=(
|
|
'purchase_commission_ict.fodt'),
|
|
)
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.invoice.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = config_model
|
|
|
|
self.assertEqual(
|
|
report_class._resolve_configured_report_path({
|
|
'name': 'Purchase',
|
|
'report': 'purchase/purchase.fodt',
|
|
}),
|
|
'purchase/purchase_melya.fodt')
|
|
|
|
def test_shipment_reports_use_templates_from_configuration(self):
|
|
'shipment report paths are resolved from purchase_trade configuration'
|
|
shipping_report = Pool().get('stock.shipment.in.shipping', type='report')
|
|
insurance_report = Pool().get('stock.shipment.in.insurance', type='report')
|
|
coo_report = Pool().get('stock.shipment.in.coo', type='report')
|
|
packing_report = Pool().get('stock.shipment.in.packing_list', type='report')
|
|
linkage_report = Pool().get('stock.shipment.in.linkage', type='report')
|
|
config_model = Mock()
|
|
config_model.search.return_value = [
|
|
Mock(
|
|
shipment_shipping_report_template='si_custom.fodt',
|
|
shipment_insurance_report_template='insurance_custom.fodt',
|
|
shipment_coo_report_template='coo_custom.fodt',
|
|
shipment_packing_list_report_template='packing_list_custom.fodt',
|
|
shipment_linkage_report_template='linkage_custom.fodt',
|
|
)
|
|
]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.stock.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.return_value = config_model
|
|
|
|
self.assertEqual(
|
|
shipping_report._resolve_configured_report_path({
|
|
'name': 'Shipping instructions',
|
|
'report': 'stock/si.fodt',
|
|
}),
|
|
'stock/si_custom.fodt')
|
|
self.assertEqual(
|
|
insurance_report._resolve_configured_report_path({
|
|
'name': 'Insurance',
|
|
'report': 'stock/insurance.fodt',
|
|
}),
|
|
'stock/insurance_custom.fodt')
|
|
self.assertEqual(
|
|
coo_report._resolve_configured_report_path({
|
|
'name': 'COO',
|
|
'report': 'stock/coo.fodt',
|
|
}),
|
|
'stock/coo_custom.fodt')
|
|
self.assertEqual(
|
|
packing_report._resolve_configured_report_path({
|
|
'name': 'Packing List',
|
|
'report': 'stock/packing_list.fodt',
|
|
}),
|
|
'stock/packing_list_custom.fodt')
|
|
self.assertEqual(
|
|
linkage_report._resolve_configured_report_path({
|
|
'name': 'Linkage',
|
|
'report': 'stock/linkage.fodt',
|
|
}),
|
|
'stock/linkage_custom.fodt')
|
|
|
|
def test_configuration_syncs_report_menu_labels(self):
|
|
'document template configuration updates report menu labels'
|
|
Configuration = Pool().get('purchase_trade.configuration')
|
|
config = Mock(
|
|
sale_report_label='Offer',
|
|
sale_bill_report_label='',
|
|
invoice_report_label='',
|
|
invoice_cndn_report_label='',
|
|
invoice_prepayment_report_label='',
|
|
invoice_packing_list_report_label='Packing Slip',
|
|
invoice_payment_order_report_label='Wire Order',
|
|
purchase_report_label='',
|
|
shipment_shipping_report_label='',
|
|
shipment_insurance_report_label='',
|
|
shipment_coo_report_label='Certificate of Origin',
|
|
shipment_packing_list_report_label='',
|
|
shipment_linkage_report_label='Linkage Summary',
|
|
)
|
|
action_sale = Mock(spec=['name'])
|
|
action_sale.name = 'Proforma'
|
|
action_bill = Mock(spec=['name'])
|
|
action_bill.name = 'Old Draft'
|
|
action_invoice = Mock(spec=['name'])
|
|
action_invoice.name = 'Invoice'
|
|
action_cndn = Mock(spec=['name'])
|
|
action_cndn.name = 'CN/DN'
|
|
action_prepayment = Mock(spec=['name'])
|
|
action_prepayment.name = 'Prepayment'
|
|
action_payment_order = Mock(spec=['name'])
|
|
action_payment_order.name = 'Payment Order'
|
|
action_invoice_packing = Mock(spec=['name'])
|
|
action_invoice_packing.name = 'Packing List'
|
|
action_purchase = Mock(spec=['name'])
|
|
action_purchase.name = 'Purchase'
|
|
action_shipping = Mock(spec=['name'])
|
|
action_shipping.name = 'Shipping instructions'
|
|
action_insurance = Mock(spec=['name'])
|
|
action_insurance.name = 'Insurance'
|
|
action_coo = Mock(spec=['name'])
|
|
action_coo.name = 'COO'
|
|
action_packing = Mock(spec=['name'])
|
|
action_packing.name = 'Packing List'
|
|
action_linkage = Mock(spec=['name'])
|
|
action_linkage.name = 'Linkage'
|
|
actions = {
|
|
1: action_sale,
|
|
2: action_bill,
|
|
3: action_invoice,
|
|
4: action_cndn,
|
|
5: action_prepayment,
|
|
6: action_payment_order,
|
|
7: action_invoice_packing,
|
|
8: action_purchase,
|
|
9: action_shipping,
|
|
10: action_insurance,
|
|
11: action_coo,
|
|
12: action_packing,
|
|
13: action_linkage,
|
|
}
|
|
|
|
model_data = Mock()
|
|
model_data.get_id.side_effect = list(actions.keys())
|
|
action_report = Mock()
|
|
action_report.side_effect = actions.__getitem__
|
|
|
|
def get_model(name):
|
|
return {
|
|
'ir.model.data': model_data,
|
|
'ir.action.report': action_report,
|
|
}[name]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.configuration.Pool'
|
|
) as PoolMock:
|
|
PoolMock.return_value.get.side_effect = get_model
|
|
|
|
Configuration._sync_report_labels([config])
|
|
|
|
self.assertEqual(
|
|
action_report.write.call_args.args,
|
|
(
|
|
[actions[1]], {'name': 'Offer'},
|
|
[actions[2]], {'name': 'Draft'},
|
|
[actions[7]], {'name': 'Packing Slip'},
|
|
[actions[6]], {'name': 'Wire Order'},
|
|
[actions[11]], {'name': 'Certificate of Origin'},
|
|
[actions[13]], {'name': 'Linkage Summary'},
|
|
))
|
|
|
|
def test_shipment_insurance_helpers_use_fee_and_controller(self):
|
|
'shipment insurance helpers read insurance fee and shipment context'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.number = 'IN/0001'
|
|
shipment.bl_number = 'BL-001'
|
|
shipment.from_location = Mock(name='LIVERPOOL')
|
|
shipment.to_location = Mock(name='LE HAVRE')
|
|
shipment.vessel = Mock(vessel_name='MV ATLANTIC')
|
|
shipment.controller = Mock(rec_name='CONTROL UNION')
|
|
shipment.supplier = Mock(rec_name='MELYA SA')
|
|
|
|
sale_party = Mock(rec_name='SGT FR')
|
|
sale = Mock(
|
|
id=1,
|
|
party=sale_party,
|
|
report_melya_proforma_number='P-12 S-34',
|
|
)
|
|
product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON')
|
|
line = Mock(product=product, sale=sale)
|
|
invoice = Mock(number='INV-001')
|
|
snapshot_unit = Mock(symbol='mt', rec_name='MT')
|
|
invoice_line = Mock(
|
|
invoice=invoice,
|
|
lot_weight_snapshots=[
|
|
Mock(net_quantity=Decimal('24.0000'), unit=snapshot_unit),
|
|
],
|
|
)
|
|
lot = Mock(
|
|
id=1,
|
|
lot_type='physic',
|
|
sale_line=line,
|
|
line=None,
|
|
sale_invoice_line=invoice_line,
|
|
sale_invoice_line_prov=None,
|
|
invoice_line=None,
|
|
invoice_line_prov=None,
|
|
)
|
|
move = Mock(lot=lot, product=product)
|
|
shipment.incoming_moves = [move]
|
|
shipment.moves = [move]
|
|
|
|
insurance_fee = Mock()
|
|
insurance_fee.product = Mock(name='Insurance')
|
|
insurance_fee.currency = Mock(rec_name='USD')
|
|
insurance_fee.get_amount.return_value = Decimal('1234.56')
|
|
insurance_fee.supplier = Mock(rec_name='HELVETIA')
|
|
shipment.fees = [insurance_fee]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.stock.Pool'
|
|
) as PoolMock:
|
|
date_model = Mock()
|
|
date_model.today.return_value = datetime.date(2026, 4, 6)
|
|
config_model = Mock()
|
|
PoolMock.return_value.get.side_effect = lambda name: {
|
|
'ir.date': date_model,
|
|
'purchase_trade.configuration': config_model,
|
|
}[name]
|
|
shipment.company = Mock(
|
|
party=Mock(
|
|
rec_name='MELYA SA',
|
|
address_get=Mock(return_value=Mock(
|
|
city='GENEVA',
|
|
party_name='MELYA INTERNATIONAL TRADING SA')),
|
|
)
|
|
)
|
|
|
|
self.assertEqual(
|
|
shipment.report_insurance_certificate_number, 'BL-001')
|
|
self.assertEqual(
|
|
shipment.report_insurance_account_of,
|
|
'MELYA INTERNATIONAL TRADING SA')
|
|
self.assertEqual(
|
|
shipment.report_insurance_goods_description,
|
|
'24mt COTTON UPLAND - RAW WHITE COTTON')
|
|
self.assertEqual(
|
|
shipment.report_insurance_order_reference, 'P-12 S-34')
|
|
self.assertEqual(
|
|
shipment.report_insurance_invoice_number, 'INV-001')
|
|
self.assertEqual(
|
|
shipment.report_insurance_amount, 'USD 1234.56')
|
|
self.assertEqual(
|
|
shipment.report_insurance_surveyor, 'CONTROL UNION')
|
|
self.assertEqual(
|
|
shipment.report_insurance_issue_place_and_date,
|
|
'GENEVA, 06-04-2026')
|
|
|
|
def test_shipment_insurance_amount_fallback_uses_lot_and_incoming_moves(self):
|
|
'insurance amount falls back to lot unit price and shipment quantities'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
|
|
purchase_currency = Mock(rec_name='USD')
|
|
purchase = Mock(currency=purchase_currency)
|
|
line = Mock(unit_price=Decimal('100'), purchase=purchase)
|
|
lot = Mock(line=line)
|
|
lot.get_current_quantity_converted = Mock(return_value=Decimal('5'))
|
|
move = Mock(quantity=Decimal('0'), unit_price=None, currency=None, lot=lot)
|
|
shipment.incoming_moves = [move]
|
|
shipment.fees = []
|
|
|
|
self.assertEqual(
|
|
shipment.report_insurance_incoming_amount, 'USD 500.00')
|
|
self.assertEqual(
|
|
shipment.report_insurance_amount_insured, 'USD 550.00')
|
|
self.assertEqual(
|
|
shipment.report_insurance_amount, 'USD 550.00')
|
|
|
|
def test_shipment_linkage_helpers_use_matched_purchase_sale_lot(self):
|
|
'linkage helpers expose matched purchase and sale values'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.bl_date = datetime.date(2026, 4, 30)
|
|
freight_product = SimpleNamespace(
|
|
rec_name='[Maritime Freight] Maritime freight',
|
|
name='[Maritime Freight] Maritime freight')
|
|
shipment.fees = [
|
|
SimpleNamespace(
|
|
id=201, product=freight_product, price=Decimal('10'),
|
|
quantity=Decimal('1'), mode='lumpsum', p_r='pay'),
|
|
SimpleNamespace(
|
|
id=202, product=freight_product, price=Decimal('15'),
|
|
quantity=Decimal('1'), mode='lumpsum', p_r='pay'),
|
|
]
|
|
shipment.moves = []
|
|
shipment.lotqt = []
|
|
|
|
currency = SimpleNamespace(code='USD')
|
|
unit = SimpleNamespace(symbol='MT')
|
|
product = SimpleNamespace(
|
|
rec_name='Sulphuric Acid',
|
|
name='Sulphuric Acid',
|
|
code='H2SO4')
|
|
purchase = SimpleNamespace(
|
|
id=1,
|
|
number='P-10',
|
|
reference='26.0001',
|
|
currency=currency,
|
|
party=SimpleNamespace(rec_name='SUPPLIER SA'),
|
|
to_location=SimpleNamespace(rec_name='Odda'),
|
|
incoterm=SimpleNamespace(code='FOB'),
|
|
operator=SimpleNamespace(rec_name='Operator A'))
|
|
sale = SimpleNamespace(
|
|
id=2,
|
|
number='S-20',
|
|
reference='26.0001',
|
|
currency=currency,
|
|
party=SimpleNamespace(rec_name='CLIENT SA'),
|
|
to_location=SimpleNamespace(rec_name='Mejillones'),
|
|
incoterm=SimpleNamespace(code='CFR'))
|
|
period = SimpleNamespace(rec_name='Chile FY26')
|
|
purchase_line = SimpleNamespace(
|
|
id=11,
|
|
product=product,
|
|
purchase=purchase,
|
|
quantity_theorical=Decimal('100'),
|
|
quantity=Decimal('100'),
|
|
unit_price=Decimal('80'),
|
|
unit=unit,
|
|
price_type='fixed',
|
|
del_period=period,
|
|
period_at='laycan',
|
|
from_del=datetime.date(2026, 4, 30),
|
|
to_del=datetime.date(2026, 5, 8),
|
|
fees=[],
|
|
mtm=[])
|
|
sale_line = SimpleNamespace(
|
|
id=12,
|
|
product=product,
|
|
sale=sale,
|
|
quantity_theorical=Decimal('100'),
|
|
quantity=Decimal('100'),
|
|
unit_price=Decimal('100'),
|
|
unit=unit,
|
|
price_type='fixed',
|
|
del_period=period,
|
|
period_at='laycan',
|
|
from_del=datetime.date(2026, 4, 30),
|
|
to_del=datetime.date(2026, 4, 30),
|
|
fees=[],
|
|
mtm=[])
|
|
lot = SimpleNamespace(
|
|
id=101,
|
|
lot_type='physic',
|
|
line=purchase_line,
|
|
sale_line=sale_line)
|
|
shipment.incoming_moves = [SimpleNamespace(lot=lot)]
|
|
|
|
self.assertEqual(
|
|
shipment.report_linkage_title, 'Linkage P-10/S-20 26.0001')
|
|
self.assertEqual(shipment.report_linkage_desk, 'Sulfuric Acid')
|
|
self.assertEqual(shipment.report_linkage_book, 'H2SO4 Chile FY26')
|
|
self.assertEqual(
|
|
shipment.report_linkage_bl_date, 'Thursday, April 30, 2026')
|
|
self.assertIn('Purchases', shipment.report_linkage_summary_groups)
|
|
self.assertIn('Sales', shipment.report_linkage_summary_groups)
|
|
self.assertIn('-8,000.000', shipment.report_linkage_summary_estimated)
|
|
self.assertIn('10,000.000', shipment.report_linkage_summary_estimated)
|
|
summary_rows = shipment._get_report_linkage_summary_rows()
|
|
freight_rows = [
|
|
row for row in summary_rows
|
|
if row[0] == 'Costs' and row[1] == 'Maritime freight']
|
|
self.assertEqual(len(freight_rows), 1)
|
|
self.assertEqual(freight_rows[0][2], Decimal('-25'))
|
|
self.assertIn('P-10', shipment.report_linkage_movement_references)
|
|
self.assertIn('S-20', shipment.report_linkage_movement_references)
|
|
self.assertIn('100 MT', shipment.report_linkage_movement_quantities)
|
|
self.assertIn('80.000 USD/MT', shipment.report_linkage_pricing_input_prices)
|
|
self.assertIn('100.000 USD/MT', shipment.report_linkage_pricing_input_prices)
|
|
|
|
def test_linkage_report_does_not_extend_lotqt_model(self):
|
|
'linkage report must not alter the lot.qt ORM model'
|
|
self.assertFalse(hasattr(stock_module, 'LotQt'))
|
|
|
|
def test_linkage_report_final_validates_ordered_matching_fee(self):
|
|
'linkage final keeps budgeted estimate and validates matching ordered fee'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.bl_date = datetime.date(2026, 4, 30)
|
|
shipment.fees = []
|
|
shipment.moves = []
|
|
shipment.lotqt = []
|
|
|
|
currency = SimpleNamespace(id=1, rec_name='USD')
|
|
unit = SimpleNamespace(symbol='MT')
|
|
product = SimpleNamespace(id=1, rec_name='Sulphuric Acid')
|
|
purchase = SimpleNamespace(
|
|
id=10,
|
|
number='P-10',
|
|
reference='26.0001',
|
|
currency=currency,
|
|
party=SimpleNamespace(id=20, rec_name='SUPPLIER SA'),
|
|
to_location=SimpleNamespace(rec_name='Odda'),
|
|
incoterm=SimpleNamespace(code='FOB'))
|
|
purchase_line = SimpleNamespace(
|
|
id=11,
|
|
product=product,
|
|
purchase=purchase,
|
|
quantity_theorical=Decimal('100'),
|
|
quantity=Decimal('100'),
|
|
unit_price=Decimal('80'),
|
|
unit=unit,
|
|
fees=[],
|
|
mtm=[])
|
|
fee_product = SimpleNamespace(id=30, name='Maritime freight')
|
|
supplier = SimpleNamespace(id=40, rec_name='FREIGHT CO')
|
|
budgeted_fee = SimpleNamespace(
|
|
id=201,
|
|
type='budgeted',
|
|
line=purchase_line,
|
|
sale_line=None,
|
|
product=fee_product,
|
|
supplier=supplier,
|
|
price=Decimal('100'),
|
|
quantity=Decimal('1'),
|
|
mode='lumpsum',
|
|
p_r='pay')
|
|
ordered_fee = SimpleNamespace(
|
|
id=202,
|
|
type='ordered',
|
|
line=purchase_line,
|
|
sale_line=None,
|
|
product=fee_product,
|
|
supplier=supplier,
|
|
price=Decimal('125'),
|
|
quantity=Decimal('1'),
|
|
mode='lumpsum',
|
|
p_r='pay')
|
|
other_ordered_fee = SimpleNamespace(
|
|
id=203,
|
|
type='ordered',
|
|
line=purchase_line,
|
|
sale_line=None,
|
|
product=fee_product,
|
|
supplier=supplier,
|
|
price=Decimal('999'),
|
|
quantity=Decimal('1'),
|
|
mode='lumpsum',
|
|
p_r='pay')
|
|
purchase_line.fees = [budgeted_fee, ordered_fee, other_ordered_fee]
|
|
lot = SimpleNamespace(
|
|
id=101,
|
|
lot_type='physic',
|
|
line=purchase_line,
|
|
sale_line=None)
|
|
other_lot = SimpleNamespace(
|
|
id=102,
|
|
lot_type='physic',
|
|
line=purchase_line,
|
|
sale_line=None)
|
|
budgeted_fee.lots = [lot]
|
|
ordered_fee.lots = [lot]
|
|
other_ordered_fee.lots = [other_lot]
|
|
shipment.incoming_moves = [SimpleNamespace(lot=lot)]
|
|
|
|
provisional_fee_row = [
|
|
row for row in shipment._get_report_linkage_summary_rows()
|
|
if row[0] == 'Costs' and row[1] == 'Maritime freight'][0]
|
|
self.assertEqual(provisional_fee_row[2], Decimal('-100'))
|
|
self.assertEqual(provisional_fee_row[3], Decimal('-125'))
|
|
|
|
shipment.report_linkage_status = 'FINAL'
|
|
final_fee_row = [
|
|
row for row in shipment._get_report_linkage_summary_rows()
|
|
if row[0] == 'Costs' and row[1] == 'Maritime freight'][0]
|
|
self.assertEqual(final_fee_row[2], Decimal('-100'))
|
|
self.assertEqual(final_fee_row[3], Decimal('-125'))
|
|
|
|
def test_lot_report_linkage_rejects_non_physical_lines(self):
|
|
'lot.report linkage report is only available for physical lines'
|
|
linkage_report = Pool().get('lot.report.linkage', type='report')
|
|
lot_report_model = Mock()
|
|
lot_report_model.browse.return_value = [
|
|
SimpleNamespace(
|
|
id=10000001,
|
|
rec_name='Open quantity',
|
|
r_lot_type='virtual',
|
|
r_lot_p=None,
|
|
r_lot_s=None,
|
|
)
|
|
]
|
|
|
|
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = lot_report_model
|
|
|
|
with self.assertRaises(UserError):
|
|
linkage_report._get_records([10000001], 'lot.report', {})
|
|
|
|
def test_lot_report_linkage_helpers_use_physical_lot(self):
|
|
'lot.report linkage report exposes data from the selected physical lot'
|
|
currency = SimpleNamespace(code='USD')
|
|
unit = SimpleNamespace(symbol='MT')
|
|
product = SimpleNamespace(
|
|
rec_name='Sulphuric Acid',
|
|
name='Sulphuric Acid',
|
|
code='H2SO4')
|
|
purchase = SimpleNamespace(
|
|
id=1,
|
|
number='P-10',
|
|
reference='26.0001',
|
|
currency=currency,
|
|
party=SimpleNamespace(rec_name='SUPPLIER SA'),
|
|
company=SimpleNamespace(party=SimpleNamespace(rec_name='TRADING SA')),
|
|
to_location=SimpleNamespace(rec_name='Odda'),
|
|
incoterm=SimpleNamespace(code='FOB'),
|
|
operator=SimpleNamespace(rec_name='Operator A'))
|
|
sale = SimpleNamespace(
|
|
id=2,
|
|
number='S-20',
|
|
reference='26.0001',
|
|
currency=currency,
|
|
party=SimpleNamespace(rec_name='CLIENT SA'),
|
|
to_location=SimpleNamespace(rec_name='Mejillones'),
|
|
incoterm=SimpleNamespace(code='CFR'))
|
|
period = SimpleNamespace(rec_name='Chile FY26')
|
|
purchase_line = SimpleNamespace(
|
|
id=11,
|
|
product=product,
|
|
purchase=purchase,
|
|
quantity_theorical=Decimal('100'),
|
|
quantity=Decimal('100'),
|
|
unit_price=Decimal('80'),
|
|
unit=unit,
|
|
price_type='fixed',
|
|
del_period=period,
|
|
period_at='laycan',
|
|
from_del=datetime.date(2026, 4, 30),
|
|
to_del=datetime.date(2026, 5, 8),
|
|
fees=[],
|
|
mtm=[])
|
|
sale_line = SimpleNamespace(
|
|
id=12,
|
|
product=product,
|
|
sale=sale,
|
|
quantity_theorical=Decimal('100'),
|
|
quantity=Decimal('100'),
|
|
unit_price=Decimal('100'),
|
|
unit=unit,
|
|
price_type='fixed',
|
|
del_period=period,
|
|
period_at='laycan',
|
|
from_del=datetime.date(2026, 4, 30),
|
|
to_del=datetime.date(2026, 4, 30),
|
|
fees=[],
|
|
mtm=[])
|
|
physical_lot = SimpleNamespace(
|
|
id=101,
|
|
rec_name='LOT-101',
|
|
line=purchase_line,
|
|
sale_line=sale_line,
|
|
lot_shipment_in=SimpleNamespace(
|
|
bl_date=datetime.date(2026, 4, 30),
|
|
fees=[]),
|
|
move=None)
|
|
lot_report = SimpleNamespace(
|
|
id=101,
|
|
rec_name='Physical lot',
|
|
r_lot_type='physic',
|
|
r_lot_p=physical_lot,
|
|
r_lot_s=None)
|
|
record = stock_module.LotReportLinkageRecord(
|
|
physical_lot, lot_report)
|
|
|
|
self.assertEqual(
|
|
record.report_linkage_title,
|
|
'Linkage P-10/S-20 26.0001 - PROVISIONAL')
|
|
final_record = stock_module.LotReportLinkageRecord(
|
|
physical_lot, lot_report, linkage_status='FINAL')
|
|
self.assertEqual(
|
|
final_record.report_linkage_title,
|
|
'Linkage P-10/S-20 26.0001 - FINAL')
|
|
self.assertEqual(record.report_linkage_bl_date, 'Thursday, April 30, 2026')
|
|
self.assertIn('Purchases', record.report_linkage_summary_groups)
|
|
self.assertIn('Sales', record.report_linkage_summary_groups)
|
|
self.assertIn('P-10', record.report_linkage_movement_references)
|
|
self.assertIn('S-20', record.report_linkage_movement_references)
|
|
self.assertIn('80.000 USD/MT', record.report_linkage_pricing_input_prices)
|
|
self.assertIn('100.000 USD/MT', record.report_linkage_pricing_input_prices)
|
|
|
|
def test_shipment_insurance_contact_surveyor_prefers_shipment_surveyor(self):
|
|
'insurance contact surveyor property uses shipment surveyor first'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.surveyor = Mock(rec_name='SGS')
|
|
shipment.controller = Mock(rec_name='CONTROL UNION')
|
|
shipment.fees = []
|
|
|
|
self.assertEqual(
|
|
shipment.report_insurance_contact_surveyor, 'SGS')
|
|
self.assertEqual(
|
|
shipment.report_insurance_surveyor, 'SGS')
|
|
|
|
def test_shipment_insurance_order_reference_falls_back_to_lot_qt(self):
|
|
'insurance order reference can read sale deal through matching lot.qt'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
sale = Mock(
|
|
id=1,
|
|
report_melya_proforma_number='',
|
|
report_deal='P-12 S-34',
|
|
)
|
|
sale_lot = Mock(sale_line=Mock(sale=sale))
|
|
shipment.incoming_moves = []
|
|
shipment.moves = []
|
|
shipment.lotqt = [Mock(lot_s=sale_lot)]
|
|
|
|
self.assertEqual(
|
|
shipment.report_insurance_order_reference, 'P-12 S-34')
|
|
|
|
def test_shipment_packing_helpers_use_today_and_trade_unit(self):
|
|
'packing list helpers expose today date and trade line unit'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
shipment = ShipmentIn()
|
|
shipment.quantity = Decimal('1010')
|
|
shipment.unit = Mock(symbol='KGS', rec_name='KGS')
|
|
|
|
trade_line = Mock()
|
|
trade_line.unit = Mock(symbol='MT', rec_name='MT')
|
|
lot = Mock(line=trade_line, sale_line=None)
|
|
lot.get_current_quantity = Mock(return_value=Decimal('1010'))
|
|
lot.get_current_gross_quantity = Mock(return_value=Decimal('1012'))
|
|
move = Mock(lot=lot, quantity=Decimal('0'))
|
|
shipment.incoming_moves = [move]
|
|
shipment.moves = [move]
|
|
|
|
with patch(
|
|
'trytond.modules.purchase_trade.stock.Pool'
|
|
) as PoolMock:
|
|
date_model = Mock()
|
|
date_model.today.return_value = datetime.date(2026, 4, 8)
|
|
PoolMock.return_value.get.return_value = date_model
|
|
|
|
self.assertEqual(
|
|
shipment.report_packing_today_date, 'April 8, 2026')
|
|
self.assertEqual(
|
|
shipment.report_packing_weight_unit, 'MT')
|
|
|
|
def test_sale_report_multi_line_helpers_aggregate_all_lines(self):
|
|
'sale report helpers aggregate quantity, price lines and shipment periods'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
def make_line(quantity, period, linked_price):
|
|
line = Mock()
|
|
line.type = 'line'
|
|
line.quantity = quantity
|
|
line.note = ''
|
|
line.price_type = 'priced'
|
|
line.unit_price = Decimal('0')
|
|
line.linked_price = Decimal(linked_price)
|
|
line.linked_currency = Mock(rec_name='USC')
|
|
line.linked_unit = Mock(rec_name='POUND')
|
|
line.unit = Mock(rec_name='MT')
|
|
line.del_period = Mock(description=period)
|
|
line.get_pricing_text = f'ON ICE Cotton #2 {period}'
|
|
line.lots = []
|
|
return line
|
|
|
|
sale = Sale()
|
|
sale.currency = Mock(rec_name='USD')
|
|
sale.lines = [
|
|
make_line('1000', 'MARCH 2026', '72.5000'),
|
|
make_line('1000', 'MAY 2026', '70.2500'),
|
|
]
|
|
|
|
self.assertEqual(sale.report_total_quantity, '2000.0')
|
|
self.assertEqual(sale.report_quantity_unit_upper, 'MT')
|
|
self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS')
|
|
self.assertEqual(sale.report_nb_bale, '')
|
|
self.assertEqual(
|
|
sale.report_quantity_lines.splitlines(),
|
|
[
|
|
'1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026',
|
|
'1000.0 MT (ONE THOUSAND METRIC TONS) - MAY 2026',
|
|
])
|
|
self.assertEqual(
|
|
sale.report_shipment_periods.splitlines(),
|
|
['MARCH 2026', 'MAY 2026'])
|
|
self.assertEqual(
|
|
sale.report_price_lines.splitlines(),
|
|
[
|
|
'USC 72.5000 PER POUND (SEVENTY TWO USC AND FIFTY CENTS) ON ICE Cotton #2 MARCH 2026',
|
|
'USC 70.2500 PER POUND (SEVENTY USC AND TWENTY FIVE CENTS) ON ICE Cotton #2 MAY 2026',
|
|
])
|
|
|
|
def test_sale_report_price_composition_lines_uses_composition(self):
|
|
'sale report exposes price composition lines for invoice_melya'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
line = Mock()
|
|
line.type = 'line'
|
|
line.price_type = 'priced'
|
|
line.linked_currency = None
|
|
line.linked_unit = None
|
|
line.unit = Mock(rec_name='MT')
|
|
line.quantity = Decimal('25')
|
|
line.unit_price = Decimal('2525')
|
|
line.amount = Decimal('63750')
|
|
line.lots = []
|
|
line.price_composition = [
|
|
Mock(component='FOB', price=Decimal('2500')),
|
|
Mock(component='FREIGHT', price=None),
|
|
]
|
|
|
|
sale = Sale()
|
|
sale.currency = Mock(rec_name='EUR')
|
|
sale.lines = [line]
|
|
|
|
self.assertEqual(
|
|
sale.report_price_composition_lines.splitlines(),
|
|
[
|
|
'FOB value in EUR',
|
|
'FREIGHT value in EUR',
|
|
])
|
|
self.assertEqual(
|
|
sale.report_price_composition_rows,
|
|
[
|
|
{
|
|
'label': 'FOB value in EUR',
|
|
'amount': Decimal('62500'),
|
|
'amount_text': '62,500.00',
|
|
},
|
|
{
|
|
'label': 'FREIGHT value in EUR',
|
|
'amount': Decimal('1250'),
|
|
'amount_text': '1,250.00',
|
|
},
|
|
])
|
|
|
|
def test_invoice_report_melya_price_composition_uses_invoice_quantity(self):
|
|
'invoice_melya price composition applies the invoiced quantity'
|
|
Invoice = Pool().get('account.invoice')
|
|
unit = Mock(id=1, rec_name='MT')
|
|
sale = Mock(currency=Mock(rec_name='EUR'), lines=[])
|
|
sale_line = Mock(
|
|
sale=sale,
|
|
unit=unit,
|
|
amount=Decimal('63750'),
|
|
price_composition=[
|
|
Mock(component='FOB', price=Decimal('2500')),
|
|
Mock(component='FREIGHT', price=None),
|
|
])
|
|
invoice_line = Mock(
|
|
type='line',
|
|
origin=sale_line,
|
|
unit=unit,
|
|
quantity=Decimal('20'),
|
|
amount=Decimal('51000'),
|
|
currency=Mock(rec_name='EUR'),
|
|
invoice=None,
|
|
lot=None,
|
|
lot_weight_snapshots=[])
|
|
invoice = Invoice()
|
|
invoice.lines = [invoice_line]
|
|
invoice.sales = [sale]
|
|
|
|
self.assertEqual(
|
|
invoice.report_melya_rate_rows,
|
|
[
|
|
{
|
|
'label': 'FOB value in EUR',
|
|
'amount': Decimal('50000'),
|
|
'amount_text': '50,000.00',
|
|
},
|
|
{
|
|
'label': 'FREIGHT value in EUR',
|
|
'amount': Decimal('1000'),
|
|
'amount_text': '1,000.00',
|
|
},
|
|
])
|
|
|
|
def test_invoice_report_rate_lines_prefers_sale_price_composition(self):
|
|
'invoice_melya rate block uses sale price composition when available'
|
|
Invoice = Pool().get('account.invoice')
|
|
sale = Mock(
|
|
report_price_composition_rows=[
|
|
{'label': 'FOB value in EUR', 'amount': Decimal('2500')},
|
|
{'label': 'FREIGHT value in EUR', 'amount': Decimal('25')},
|
|
])
|
|
invoice = Invoice()
|
|
invoice.sales = [sale]
|
|
|
|
self.assertEqual(
|
|
invoice.report_rate_lines.splitlines(),
|
|
[
|
|
'FOB value in EUR',
|
|
'FREIGHT value in EUR',
|
|
])
|
|
self.assertEqual(
|
|
invoice.report_rate_rows,
|
|
[
|
|
{'label': 'FOB value in EUR', 'amount': Decimal('2500')},
|
|
{'label': 'FREIGHT value in EUR', 'amount': Decimal('25')},
|
|
])
|
|
|
|
def test_invoice_report_melya_rate_rows_has_no_price_fallback(self):
|
|
'invoice_melya rate block stays empty without price composition'
|
|
Invoice = Pool().get('account.invoice')
|
|
line = Mock(
|
|
type='line',
|
|
report_rate_currency_upper='USD',
|
|
report_rate_value=Decimal('2050'),
|
|
report_rate_unit_upper='MT',
|
|
report_rate_price_words='TWO THOUSAND FIFTY DOLLARS',
|
|
report_rate_pricing_text='',
|
|
)
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
|
|
self.assertEqual(invoice.report_melya_rate_rows, [])
|
|
self.assertEqual(
|
|
invoice.report_rate_rows,
|
|
[{
|
|
'label': 'USD 2050.0000 PER MT '
|
|
'(TWO THOUSAND FIFTY DOLLARS)',
|
|
'amount': None,
|
|
}])
|
|
|
|
def test_sale_commission_invoice_uses_linked_perqt_fee_rate(self):
|
|
'sale commission invoice shows linked fee price per sale quantity'
|
|
Invoice = Pool().get('account.invoice')
|
|
sale = SimpleNamespace(
|
|
currency=SimpleNamespace(rec_name='USD'),
|
|
untaxed_amount=Decimal('7960'),
|
|
total_amount=Decimal('7960'))
|
|
sale_line = SimpleNamespace(sale=sale)
|
|
lot = SimpleNamespace(id=1, sale_line=sale_line, line=None)
|
|
fee = SimpleNamespace(
|
|
mode='perqt',
|
|
price=Decimal('0.02'),
|
|
enable_linked_currency=True,
|
|
linked_price=Decimal('2'),
|
|
linked_currency=SimpleNamespace(rec_name='USC'),
|
|
linked_unit=SimpleNamespace(rec_name='Kilogram'),
|
|
currency=SimpleNamespace(rec_name='USD'),
|
|
unit=SimpleNamespace(rec_name='MT'),
|
|
sale_line=sale_line,
|
|
line=None,
|
|
lots=[lot],
|
|
_get_effective_fee_lots=lambda: [lot])
|
|
invoice = Invoice()
|
|
invoice.currency = SimpleNamespace(rec_name='USD')
|
|
invoice.lines = [
|
|
SimpleNamespace(type='line', fee=fee, lot=lot, origin=None)]
|
|
|
|
self.assertEqual(
|
|
invoice.report_sale_commission_rate_line,
|
|
'2 USC/Kilogram')
|
|
self.assertEqual(invoice.report_sale_commission_secondary_rate_line, '')
|
|
|
|
def test_purchase_commission_invoice_uses_purchase_amount_for_percent_fee(self):
|
|
'purchase commission invoice shows percent fee on purchase amount'
|
|
Invoice = Pool().get('account.invoice')
|
|
purchase = SimpleNamespace(
|
|
currency=SimpleNamespace(rec_name='USD'),
|
|
untaxed_amount=Decimal('7960'),
|
|
total_amount=Decimal('7960'))
|
|
purchase_line = SimpleNamespace(purchase=purchase)
|
|
lot = SimpleNamespace(id=1, line=purchase_line, sale_line=None)
|
|
fee = SimpleNamespace(
|
|
mode='pprice',
|
|
price=Decimal('1.5'),
|
|
enable_linked_currency=False,
|
|
currency=SimpleNamespace(rec_name='USD'),
|
|
unit=SimpleNamespace(rec_name='MT'),
|
|
sale_line=None,
|
|
line=purchase_line,
|
|
lots=[lot],
|
|
_get_effective_fee_lots=lambda: [lot])
|
|
invoice = Invoice()
|
|
invoice.currency = SimpleNamespace(rec_name='USD')
|
|
invoice.lines = [
|
|
SimpleNamespace(type='line', fee=fee, lot=lot, origin=None)]
|
|
|
|
self.assertEqual(
|
|
invoice.report_purchase_commission_rate_line,
|
|
'1.5 % on purchase amount 7960.00 USD')
|
|
self.assertEqual(
|
|
invoice.report_purchase_commission_secondary_rate_line, '')
|
|
|
|
def test_commission_cndn_weight_adjustment_uses_fee_lot_weights(self):
|
|
'commission note adjustment uses provisional and current lot weights'
|
|
Invoice = Pool().get('account.invoice')
|
|
unit = SimpleNamespace(id=1, rec_name='MT', symbol='MT')
|
|
provisional_line = SimpleNamespace(
|
|
quantity=Decimal('400'), unit=unit)
|
|
lot = SimpleNamespace(
|
|
id=1,
|
|
sale_invoice_line_prov=provisional_line,
|
|
sale_invoice_padding=Decimal('0'),
|
|
get_current_quantity_converted=lambda state=0, unit=None: Decimal('397'))
|
|
fee = SimpleNamespace(
|
|
enable_linked_currency=False,
|
|
mode='perqt',
|
|
price=Decimal('7.85719'),
|
|
currency=SimpleNamespace(rec_name='USD'),
|
|
unit=unit,
|
|
sale_line=SimpleNamespace(sale=SimpleNamespace()),
|
|
line=None,
|
|
lots=[lot],
|
|
_get_effective_fee_lots=lambda: [lot])
|
|
invoice = Invoice()
|
|
invoice.lines = [
|
|
SimpleNamespace(type='line', fee=fee, quantity=Decimal('1'))]
|
|
|
|
self.assertEqual(
|
|
invoice.report_commission_invoice_weight_display, '400.00')
|
|
self.assertEqual(
|
|
invoice.report_commission_landed_weight_display, '397.00')
|
|
self.assertEqual(
|
|
invoice.report_commission_weight_difference_display, '3.00')
|
|
self.assertEqual(invoice.report_commission_adjustment_unit_upper, 'MT')
|
|
self.assertEqual(
|
|
invoice.report_commission_adjustment_rate_line,
|
|
'7.8572 USD / MT')
|
|
self.assertEqual(invoice.report_commission_tax_rate_line, 'VAT 0% RATE')
|
|
|
|
def test_fee_delete_detects_generated_ordered_purchase(self):
|
|
'ordered fee deletion targets only its generated service purchase'
|
|
Fee = Pool().get('fee.fee')
|
|
|
|
fee = Fee()
|
|
fee.id = 10
|
|
fee.type = 'ordered'
|
|
purchase = Mock(
|
|
id=20,
|
|
line_type='service',
|
|
lines=[Mock(fee_=fee)])
|
|
fee.purchase = purchase
|
|
|
|
self.assertEqual(
|
|
Fee._get_generated_purchases_to_delete([fee]),
|
|
[purchase])
|
|
|
|
def test_fee_delete_ignores_unrelated_purchase(self):
|
|
'ordered fee deletion does not delete purchases with unrelated lines'
|
|
Fee = Pool().get('fee.fee')
|
|
|
|
fee = Fee()
|
|
fee.id = 10
|
|
fee.type = 'ordered'
|
|
purchase = Mock(
|
|
id=20,
|
|
line_type='service',
|
|
lines=[Mock(fee_=Mock(id=99))])
|
|
fee.purchase = purchase
|
|
|
|
self.assertEqual(Fee._get_generated_purchases_to_delete([fee]), [])
|
|
|
|
def test_fee_report_invoice_delegates_to_fee_invoice(self):
|
|
'fee report invoice button invoices the underlying fees'
|
|
FeeReport = Pool().get('fee.report')
|
|
report = FeeReport()
|
|
report.id = 10
|
|
fee_model = Mock()
|
|
fees = [Mock(id=10)]
|
|
fee_model.browse.return_value = fees
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = fee_model
|
|
|
|
FeeReport.invoice([report])
|
|
|
|
fee_model.browse.assert_called_once_with([10])
|
|
fee_model.invoice.assert_called_once_with(fees)
|
|
|
|
def test_fee_report_lot_invoice_status_uses_effective_lots(self):
|
|
'fee report consolidates invoicing over the effective linked lots'
|
|
FeeReport = Pool().get('fee.report')
|
|
invoice = SimpleNamespace(id=1, state='posted')
|
|
invoiced = SimpleNamespace(
|
|
invoice_line=SimpleNamespace(invoice=invoice),
|
|
invoice_line_prov=None)
|
|
not_invoiced = SimpleNamespace(
|
|
invoice_line=None, invoice_line_prov=None)
|
|
fee = SimpleNamespace(
|
|
_get_effective_fee_lots=lambda: [invoiced, not_invoiced])
|
|
|
|
lots, count, invoices = FeeReport._lot_invoice_data(fee)
|
|
|
|
self.assertEqual(lots, [invoiced, not_invoiced])
|
|
self.assertEqual(count, 1)
|
|
self.assertEqual(invoices, [invoice])
|
|
|
|
def test_fee_report_payment_status_consolidates_invoice_payments(self):
|
|
'fee report marks mixed paid and unpaid invoices as partially paid'
|
|
FeeReport = Pool().get('fee.report')
|
|
paid = SimpleNamespace(
|
|
state='paid', payment_lines=[SimpleNamespace()],
|
|
amount_to_pay=Decimal('0'))
|
|
unpaid = SimpleNamespace(
|
|
state='posted', payment_lines=[],
|
|
amount_to_pay=Decimal('10'))
|
|
|
|
self.assertEqual(
|
|
FeeReport._invoice_payment_status([paid, unpaid]), 'partial')
|
|
self.assertEqual(FeeReport._invoice_payment_status([paid]), 'paid')
|
|
self.assertEqual(FeeReport._invoice_payment_status([unpaid]), 'not')
|
|
|
|
def test_fee_report_payment_status_includes_fee_dn_cn(self):
|
|
'fee payment status distinguishes invoice and DN/CN payments'
|
|
FeeReport = Pool().get('fee.report')
|
|
report = FeeReport()
|
|
report.id = 10
|
|
paid = SimpleNamespace(
|
|
id=1, state='paid', payment_lines=[SimpleNamespace()],
|
|
amount_to_pay=Decimal('0'))
|
|
unpaid_dn_cn = SimpleNamespace(
|
|
id=2, state='posted', payment_lines=[],
|
|
amount_to_pay=Decimal('5'))
|
|
fee = Mock(dn_cn=unpaid_dn_cn)
|
|
fee.get_invoice.return_value = paid
|
|
fee_model = Mock(return_value=fee)
|
|
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = fee_model
|
|
|
|
self.assertEqual(
|
|
report.get_fee_payment_status('r_fee_payment_status'),
|
|
'invoice_paid')
|
|
|
|
fee.get_invoice.assert_called_once_with('inv')
|
|
|
|
unpaid_dn_cn.payment_lines = [SimpleNamespace()]
|
|
unpaid_dn_cn.amount_to_pay = Decimal('0')
|
|
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = fee_model
|
|
|
|
self.assertEqual(
|
|
report.get_fee_payment_status('r_fee_payment_status'),
|
|
'all_paid')
|
|
|
|
unpaid = SimpleNamespace(
|
|
state='posted', payment_lines=[],
|
|
amount_to_pay=Decimal('5'))
|
|
self.assertEqual(
|
|
FeeReport._fee_payment_status(unpaid, unpaid), 'not_paid')
|
|
self.assertEqual(
|
|
FeeReport._fee_payment_status(unpaid, unpaid_dn_cn),
|
|
'dn_cn_paid')
|
|
|
|
def test_sale_report_converts_mixed_units_for_total_and_words(self):
|
|
'sale report totals prefer the virtual lot unit as common unit'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
mt = Mock(id=1, rec_name='MT')
|
|
kg = Mock(id=2, rec_name='KILOGRAM')
|
|
|
|
line_mt = Mock()
|
|
line_mt.type = 'line'
|
|
line_mt.quantity = Decimal('1000')
|
|
line_mt.unit = mt
|
|
line_mt.del_period = Mock(description='MARCH 2026')
|
|
line_mt.lots = []
|
|
|
|
virtual = Mock(lot_type='virtual', lot_unit_line=kg)
|
|
virtual.get_hist_quantity.return_value = (
|
|
Decimal('1000000'),
|
|
Decimal('1000000'),
|
|
)
|
|
line_kg = Mock()
|
|
line_kg.type = 'line'
|
|
line_kg.quantity = Decimal('1000')
|
|
line_kg.unit = mt
|
|
line_kg.del_period = Mock(description='MAY 2026')
|
|
line_kg.lots = [virtual]
|
|
|
|
sale = Sale()
|
|
sale.lines = [line_mt, line_kg]
|
|
|
|
uom_model = Mock()
|
|
uom_model.compute_qty.side_effect = (
|
|
lambda from_unit, qty, to_unit: (
|
|
qty * 1000
|
|
if getattr(from_unit, 'rec_name', None) == 'MT'
|
|
and getattr(to_unit, 'rec_name', None) == 'KILOGRAM'
|
|
else (
|
|
qty / 1000
|
|
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
|
|
and getattr(to_unit, 'rec_name', None) == 'MT'
|
|
else qty
|
|
)
|
|
))
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = uom_model
|
|
|
|
self.assertEqual(sale.report_total_quantity, '2000000.0')
|
|
self.assertEqual(sale.report_quantity_unit_upper, 'KILOGRAM')
|
|
self.assertEqual(sale.report_qt, 'TWO MILLION KILOGRAMS')
|
|
self.assertEqual(
|
|
sale.report_quantity_lines.splitlines(),
|
|
[
|
|
'1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026',
|
|
'1000000.0 KILOGRAM (ONE MILLION KILOGRAMS) - MAY 2026',
|
|
])
|
|
|
|
def test_sale_report_total_unit_falls_back_when_multiple_virtual_lots(self):
|
|
'sale report common unit uses virtual only when there is a single one'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
mt = Mock(id=1, rec_name='MT')
|
|
kg = Mock(id=2, rec_name='KILOGRAM')
|
|
|
|
line_mt = Mock(type='line', quantity=Decimal('1000'), unit=mt)
|
|
line_mt.del_period = Mock(description='MARCH 2026')
|
|
line_mt.lots = []
|
|
|
|
virtual_a = Mock(lot_type='virtual', lot_unit_line=kg)
|
|
virtual_a.get_hist_quantity.return_value = (
|
|
Decimal('1000000'),
|
|
Decimal('1000000'),
|
|
)
|
|
virtual_b = Mock(lot_type='virtual', lot_unit_line=kg)
|
|
virtual_b.get_hist_quantity.return_value = (
|
|
Decimal('1000000'),
|
|
Decimal('1000000'),
|
|
)
|
|
line_kg = Mock(type='line', quantity=Decimal('1000'), unit=mt)
|
|
line_kg.del_period = Mock(description='MAY 2026')
|
|
line_kg.lots = [virtual_a, virtual_b]
|
|
|
|
sale = Sale()
|
|
sale.lines = [line_mt, line_kg]
|
|
|
|
uom_model = Mock()
|
|
uom_model.compute_qty.side_effect = (
|
|
lambda from_unit, qty, to_unit: (
|
|
qty / 1000
|
|
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
|
|
and getattr(to_unit, 'rec_name', None) == 'MT'
|
|
else qty
|
|
))
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = uom_model
|
|
|
|
self.assertEqual(sale.report_quantity_unit_upper, 'MT')
|
|
self.assertEqual(sale.report_total_quantity, '2000.0')
|
|
self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS')
|
|
|
|
def test_report_product_fields_expose_name_and_description(self):
|
|
'sale and invoice templates use stable product name/description helpers'
|
|
Sale = Pool().get('sale.sale')
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON')
|
|
sale_line = Mock(product=product)
|
|
invoice_line = Mock(product=product)
|
|
|
|
sale = Sale()
|
|
sale.lines = [sale_line]
|
|
|
|
invoice = Invoice()
|
|
invoice.lines = [invoice_line]
|
|
invoice._get_report_trade_line = Mock(return_value=sale_line)
|
|
|
|
self.assertEqual(sale.report_product_name, 'COTTON UPLAND')
|
|
self.assertEqual(sale.report_product_description, 'RAW WHITE COTTON')
|
|
self.assertEqual(invoice.report_product_name, 'COTTON UPLAND')
|
|
self.assertEqual(invoice.report_product_description, 'RAW WHITE COTTON')
|
|
|
|
def test_invoice_melya_reference_uses_sale_deal_number(self):
|
|
'invoice_melya Reference uses the sale deal number'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = Invoice()
|
|
sale = Mock(report_deal='P-12 S-34')
|
|
invoice._get_report_deal_sale = Mock(return_value=sale)
|
|
|
|
self.assertEqual(invoice.report_contract_number, 'P-12/S-34')
|
|
|
|
def test_invoice_melya_bank_uses_sale_our_bank_account(self):
|
|
'invoice_melya bank block uses the sale company bank account'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
address = Mock(
|
|
party_full_name='UBS SWITZERLAND AG',
|
|
name='Case Postale',
|
|
street='',
|
|
postal_code='CH-1211 Geneve 2',
|
|
city='',
|
|
full_address='UBS SWITZERLAND AG\nCase Postale\n'
|
|
'CH-1211 Geneve 2',
|
|
)
|
|
party = Mock(rec_name='UBS')
|
|
party.address_get.return_value = address
|
|
bank = Mock(party=party, bic='UBSWCHZH80A')
|
|
account = Mock(
|
|
bank=bank,
|
|
numbers=[Mock(type='iban', number='CH36 0021 5215 2487 7461 D')],
|
|
)
|
|
sale = Mock(our_bank_account=account)
|
|
|
|
invoice = Invoice()
|
|
invoice._get_report_deal_sale = Mock(return_value=sale)
|
|
|
|
self.assertEqual(invoice.report_melya_bank_name, 'UBS SWITZERLAND AG')
|
|
self.assertEqual(
|
|
invoice.report_melya_bank_address,
|
|
'Case Postale\nCH-1211 Geneve 2')
|
|
self.assertEqual(
|
|
invoice.report_melya_bank_iban,
|
|
'CH36 0021 5215 2487 7461 D')
|
|
self.assertEqual(invoice.report_melya_bank_swift, 'UBSWCHZH80A')
|
|
self.assertEqual(
|
|
invoice.report_melya_bank_details_line,
|
|
'BANK DETAILS:\t\tUBS SWITZERLAND AG')
|
|
self.assertEqual(
|
|
invoice.report_melya_bank_account_line,
|
|
'IBAN : CH36 0021 5215 2487 7461 D, Swift Code: UBSWCHZH80A')
|
|
|
|
def test_sale_melya_bank_uses_our_bank_account(self):
|
|
'sale_melya bank block uses the sale company bank account'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
address = Mock(
|
|
party_full_name='UBS SWITZERLAND AG',
|
|
name='Case Postale',
|
|
street='',
|
|
postal_code='CH-1211 Geneve 2',
|
|
city='',
|
|
)
|
|
party = Mock(
|
|
rec_name='UBS SWITZERLAND AG',
|
|
address_get=Mock(return_value=address),
|
|
)
|
|
bank = Mock(party=party, bic='UBSWCHZH80A')
|
|
account = Mock(
|
|
bank=bank,
|
|
numbers=[Mock(type='iban', number='CH36 0021 5215 2487 7461 D')],
|
|
)
|
|
|
|
sale = Sale()
|
|
sale.our_bank_account = account
|
|
|
|
self.assertEqual(sale.report_melya_bank_name, 'UBS SWITZERLAND AG')
|
|
self.assertEqual(
|
|
sale.report_melya_bank_address,
|
|
'Case Postale CH-1211 Geneve 2')
|
|
self.assertEqual(
|
|
sale.report_melya_bank_iban,
|
|
'CH36 0021 5215 2487 7461 D')
|
|
self.assertEqual(sale.report_melya_bank_swift, 'UBSWCHZH80A')
|
|
self.assertEqual(
|
|
sale.report_melya_bank_swift_line,
|
|
'Swift Code: UBSWCHZH80A')
|
|
self.assertEqual(
|
|
sale.report_melya_bank_iban_line,
|
|
'IBAN : CH36 0021 5215 2487 7461 D')
|
|
self.assertEqual(
|
|
sale.report_melya_bank_account_line,
|
|
'IBAN : CH36 0021 5215 2487 7461 D, Swift Code: UBSWCHZH80A')
|
|
|
|
def test_invoice_melya_maturity_date_uses_lines_to_pay(self):
|
|
'invoice_melya maturity date uses the earliest line to pay maturity'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = Invoice()
|
|
invoice.lines_to_pay = [
|
|
Mock(maturity_date=datetime.date(2026, 9, 9)),
|
|
Mock(maturity_date=datetime.date(2026, 8, 9)),
|
|
Mock(maturity_date=None),
|
|
]
|
|
|
|
self.assertEqual(
|
|
invoice.report_melya_maturity_date,
|
|
datetime.date(2026, 8, 9))
|
|
|
|
def test_sale_report_deal_uses_purchase_and_sale_numbers(self):
|
|
'sale deal number includes the linked purchase number'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
purchase = Mock(number='12')
|
|
lot = Mock(line=Mock(purchase=purchase))
|
|
sale = Sale()
|
|
sale.number = '88'
|
|
sale.lines = [Mock(lots=[lot])]
|
|
|
|
self.assertEqual(sale.report_deal, 'P-12 S-88')
|
|
self.assertEqual(sale.report_melya_proforma_number, 'P-12 S-88')
|
|
|
|
def test_sale_report_deal_falls_back_to_lot_qt_matching(self):
|
|
'sale deal number finds purchase through lot.qt matching'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
class SaleLot:
|
|
id = 99
|
|
line = None
|
|
|
|
purchase = Mock(number='12')
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(lot_p=Mock(line=Mock(purchase=purchase)))]
|
|
|
|
sale = Sale()
|
|
sale.number = '88'
|
|
sale.lines = [Mock(lots=[SaleLot()])]
|
|
|
|
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = lot_qt_model
|
|
|
|
self.assertEqual(sale.report_deal, 'P-12 S-88')
|
|
|
|
lot_qt_model.search.assert_called_once_with([
|
|
('lot_s', '=', 99),
|
|
('lot_p', '>', 0),
|
|
], limit=1)
|
|
|
|
def test_contract_factory_uses_one_line_per_selected_source(self):
|
|
'matched multi-lot contract creation keeps one generated line per source lot'
|
|
contract_detail = Mock(quantity=Decimal('2000'))
|
|
ct = Mock(matched=True)
|
|
line_a = Mock()
|
|
line_b = Mock()
|
|
sources = [
|
|
{'lot': Mock(), 'trade_line': line_a, 'quantity': Decimal('1000')},
|
|
{'lot': Mock(), 'trade_line': line_b, 'quantity': Decimal('1000')},
|
|
]
|
|
|
|
result = ContractFactory._get_line_sources(contract_detail, sources, ct)
|
|
|
|
self.assertEqual(len(result), 2)
|
|
self.assertEqual([r['quantity'] for r in result], [
|
|
Decimal('1000'), Decimal('1000')])
|
|
self.assertTrue(all(r['use_source_delivery'] for r in result))
|
|
|
|
def test_contract_factory_rejects_multi_lot_quantity_mismatch(self):
|
|
'matched multi-lot contract creation rejects totals that do not match the selection'
|
|
contract_detail = Mock(quantity=Decimal('1500'))
|
|
ct = Mock(matched=True)
|
|
sources = [
|
|
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')},
|
|
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')},
|
|
]
|
|
|
|
with self.assertRaises(UserError):
|
|
ContractFactory._get_line_sources(contract_detail, sources, ct)
|
|
|
|
def test_contract_factory_rejects_total_above_selected_open_quantity(self):
|
|
'matched create contracts cannot consume more than selected open quantity'
|
|
contracts = [
|
|
Mock(quantity=Decimal('100')),
|
|
Mock(quantity=Decimal('100')),
|
|
]
|
|
ct = Mock(matched=True)
|
|
sources = [
|
|
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('100')},
|
|
]
|
|
|
|
with self.assertRaises(UserError):
|
|
ContractFactory._validate_requested_quantity(contracts, sources, ct)
|
|
|
|
def test_contract_factory_uses_party_tolerance_when_line_is_empty(self):
|
|
'create contracts falls back to counterparty tolerance'
|
|
party = Mock(tol_min=Decimal('2'), tol_max=Decimal('5'))
|
|
contract_detail = Mock(party=party, tol_min=None, tol_max=None)
|
|
|
|
self.assertEqual(
|
|
ContractFactory._get_tolerance(contract_detail, 'tol_min'),
|
|
Decimal('2'))
|
|
self.assertEqual(
|
|
ContractFactory._get_tolerance(contract_detail, 'tol_max'),
|
|
Decimal('5'))
|
|
|
|
def test_contract_factory_uses_base_crop_when_line_is_empty(self):
|
|
'hidden crop falls back to the source contract crop'
|
|
crop = Mock()
|
|
contract_detail = Mock(crop=None)
|
|
base_contract = Mock(crop=crop)
|
|
|
|
self.assertEqual(
|
|
ContractFactory._get_crop(contract_detail, base_contract),
|
|
crop)
|
|
|
|
def test_contract_factory_locations_fallback_to_source_contract(self):
|
|
'create contracts keeps required locations when wizard line is empty'
|
|
source_from = Mock(type='supplier')
|
|
source_to = Mock(type='storage')
|
|
contract_detail = Mock(from_location=None, to_location=None)
|
|
base_contract = Mock(
|
|
from_location=source_from, to_location=source_to)
|
|
|
|
from_location, to_location = ContractFactory._get_locations(
|
|
contract_detail, base_contract, 'Sale')
|
|
|
|
self.assertEqual(from_location, source_to)
|
|
self.assertEqual(to_location, source_to)
|
|
|
|
def test_contract_factory_required_header_fields_fallback_to_defaults(self):
|
|
'create contracts fills required header fields outside the normal form'
|
|
contract = Mock()
|
|
party = Mock(wb=None, association=None, certif=None)
|
|
base_contract = Mock(wb=None, association=None, certif=None)
|
|
Contract = Mock()
|
|
Contract.default_wb.return_value = 1
|
|
Contract.default_association.return_value = 2
|
|
Contract.default_certif.return_value = 3
|
|
|
|
with patch('trytond.modules.purchase_trade.service.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = Contract
|
|
|
|
ContractFactory._apply_party_data(
|
|
contract, party, base_contract, 'Purchase')
|
|
|
|
self.assertEqual(contract.wb, 1)
|
|
self.assertEqual(contract.association, 2)
|
|
self.assertEqual(contract.certif, 3)
|
|
|
|
def test_contract_factory_incoterm_fallback_to_source_contract(self):
|
|
'create contracts keeps required incoterm when wizard line is empty'
|
|
incoterm = Mock()
|
|
contract_detail = Mock(incoterm=None)
|
|
base_contract = Mock(incoterm=incoterm)
|
|
|
|
self.assertEqual(
|
|
ContractFactory._get_contract_value(
|
|
contract_detail, base_contract, 'incoterm'),
|
|
incoterm)
|
|
|
|
def test_contract_factory_currency_unit_fallback_to_hidden_defaults(self):
|
|
'create contracts can price even when currency/unit selector is empty'
|
|
contract_detail = Mock(
|
|
currency_unit=None,
|
|
currency=Mock(id=7),
|
|
unit=Mock(id=9),
|
|
)
|
|
base_contract = Mock(currency=Mock(id=3))
|
|
|
|
self.assertEqual(
|
|
ContractFactory._get_currency_unit_parts(
|
|
contract_detail, base_contract),
|
|
['7', '9'])
|
|
|
|
def test_contract_factory_delivery_dates_fallback_to_period(self):
|
|
'hidden delivery dates are still derived from delivery period'
|
|
period = Mock(beg_date='2026-05-01', end_date='2026-05-31')
|
|
class ContractDetailMock:
|
|
del_period = period
|
|
|
|
contract_detail = ContractDetailMock()
|
|
line = Mock()
|
|
|
|
ContractFactory._apply_delivery(line, contract_detail, {})
|
|
|
|
self.assertEqual(line.from_del, '2026-05-01')
|
|
self.assertEqual(line.to_del, '2026-05-31')
|
|
|
|
def test_contract_factory_shipment_origin_accepts_partial_context(self):
|
|
'custom duplicate matched mirror can omit shipment fields'
|
|
class ContractsStartMock:
|
|
pass
|
|
|
|
self.assertIsNone(
|
|
ContractFactory._get_shipment_origin(ContractsStartMock()))
|
|
|
|
def test_custom_duplicate_resets_finished_line(self):
|
|
'custom duplicate keeps duplicated lots visible in lots management'
|
|
line = Mock(
|
|
product=Mock(),
|
|
quantity=None,
|
|
quantity_theorical=None,
|
|
unit_price=None,
|
|
price_type=None,
|
|
finished=True,
|
|
fees=[],
|
|
price_components=[],
|
|
)
|
|
record = Mock(lines=[line])
|
|
options = Mock(
|
|
quantity=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
currency=Mock(),
|
|
clear_fees=False,
|
|
clear_pricing_components=False,
|
|
)
|
|
line.id = 1
|
|
|
|
duplicate_module.TradeCustomDuplicate._apply_line_options(
|
|
record, options)
|
|
|
|
self.assertFalse(line.finished)
|
|
line.save.assert_called_once()
|
|
|
|
def test_custom_duplicate_creates_purchase_open_virtual_lot(self):
|
|
'custom duplicate creates the purchase opening lot.qt explicitly'
|
|
saved_lots = []
|
|
virtual_parts = []
|
|
|
|
class FakeLot:
|
|
def __init__(self, id=None):
|
|
self.id = id
|
|
|
|
@classmethod
|
|
def save(cls, lots):
|
|
for lot in lots:
|
|
lot.id = 42
|
|
saved_lots.append(lot)
|
|
|
|
def createVirtualPart(self, quantity, shipment, sale_lot):
|
|
virtual_parts.append((self.id, quantity, shipment, sale_lot))
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
class FakeLotQtHist:
|
|
pass
|
|
|
|
class FakeLotQtType:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return [Mock()]
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {
|
|
'lot.lot': FakeLot,
|
|
'lot.qt': FakeLotQt,
|
|
'lot.qt.hist': FakeLotQtHist,
|
|
'lot.qt.type': FakeLotQtType,
|
|
}[name]
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=10,
|
|
lots=[],
|
|
product=Mock(type='goods'),
|
|
unit=Mock(),
|
|
quantity=Decimal('0'),
|
|
quantity_theorical=Decimal('125'),
|
|
fees=[],
|
|
purchase=Mock(),
|
|
)
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
lot = duplicate_module.TradeCustomDuplicate._ensure_open_virtual_lot(
|
|
line)
|
|
|
|
self.assertEqual(lot.id, 42)
|
|
self.assertEqual(saved_lots[0].line, 10)
|
|
self.assertEqual(saved_lots[0].lot_quantity, Decimal('125.00000'))
|
|
self.assertEqual(virtual_parts, [
|
|
(42, Decimal('125.00000'), None, None)])
|
|
|
|
def test_custom_duplicate_creates_sale_open_virtual_lot(self):
|
|
'custom duplicate creates the sale opening lot.qt explicitly'
|
|
saved_lots = []
|
|
virtual_parts = []
|
|
|
|
class FakeLot:
|
|
def __init__(self, id=None):
|
|
self.id = id
|
|
|
|
@classmethod
|
|
def save(cls, lots):
|
|
for lot in lots:
|
|
lot.id = 43
|
|
saved_lots.append(lot)
|
|
|
|
def createVirtualPart(self, quantity, shipment, lot_s, mode):
|
|
virtual_parts.append((self.id, quantity, shipment, lot_s, mode))
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
class FakeLotQtHist:
|
|
pass
|
|
|
|
class FakeLotQtType:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {
|
|
'lot.lot': FakeLot,
|
|
'lot.qt': FakeLotQt,
|
|
'lot.qt.hist': FakeLotQtHist,
|
|
'lot.qt.type': FakeLotQtType,
|
|
}[name]
|
|
line = SimpleNamespace(
|
|
__name__='sale.line',
|
|
id=11,
|
|
lots=[],
|
|
product=Mock(type='goods'),
|
|
unit=Mock(),
|
|
quantity=Decimal('0'),
|
|
quantity_theorical=Decimal('50'),
|
|
fees=[],
|
|
sale=Mock(),
|
|
)
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
lot = duplicate_module.TradeCustomDuplicate._ensure_open_virtual_lot(
|
|
line)
|
|
|
|
self.assertEqual(lot.id, 43)
|
|
self.assertEqual(saved_lots[0].sale_line, 11)
|
|
self.assertEqual(saved_lots[0].lot_quantity, Decimal('50.00000'))
|
|
self.assertEqual(virtual_parts, [
|
|
(43, Decimal('50.00000'), None, 43, 'only sale')])
|
|
|
|
def test_custom_duplicate_defaults_mirror_from_matched_sale(self):
|
|
'custom duplicate can default mirror party from the matched sale'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
sale_party = SimpleNamespace(id=2)
|
|
sale_currency = SimpleNamespace(id=30)
|
|
sale_payment_term = SimpleNamespace(id=31)
|
|
sale_incoterm = SimpleNamespace(id=32)
|
|
sale_unit = SimpleNamespace(id=33)
|
|
purchase_lot = SimpleNamespace(id=10)
|
|
sale = SimpleNamespace(
|
|
party=sale_party,
|
|
currency=sale_currency,
|
|
payment_term=sale_payment_term,
|
|
incoterm=sale_incoterm)
|
|
sale_line = SimpleNamespace(
|
|
id=20,
|
|
sale=sale,
|
|
unit=sale_unit,
|
|
quantity=Decimal('99'),
|
|
quantity_theorical=Decimal('99'),
|
|
unit_price=Decimal('52'),
|
|
price_type='basis')
|
|
lot_qt = SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line))
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return [lot_qt]
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[purchase_lot],
|
|
unit=SimpleNamespace(id=3),
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
record = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=SimpleNamespace(id=4),
|
|
payment_term=SimpleNamespace(id=5),
|
|
incoterm=SimpleNamespace(id=6),
|
|
)
|
|
wizard = duplicate_module.TradeCustomDuplicate()
|
|
wizard.records = [record]
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
values = wizard.default_start(None)
|
|
|
|
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
|
|
self.assertEqual(values['mirror_party'], sale_party.id)
|
|
self.assertEqual(values['mirror_currency'], sale_currency.id)
|
|
self.assertEqual(values['mirror_payment_term'], sale_payment_term.id)
|
|
self.assertEqual(values['mirror_incoterm'], sale_incoterm.id)
|
|
self.assertEqual(values['mirror_quantity'], Decimal('99'))
|
|
self.assertEqual(values['mirror_unit'], sale_unit.id)
|
|
self.assertEqual(values['mirror_unit_price'], Decimal('52'))
|
|
self.assertEqual(values['mirror_price_type'], 'basis')
|
|
|
|
def test_custom_duplicate_defaults_new_mirror_from_source_purchase(self):
|
|
'new mirror options default from the duplicated source contract'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
purchase_currency = SimpleNamespace(id=4)
|
|
purchase_payment_term = SimpleNamespace(id=5)
|
|
purchase_incoterm = SimpleNamespace(id=6)
|
|
unit = SimpleNamespace(id=3)
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[],
|
|
unit=unit,
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
record = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=purchase_currency,
|
|
payment_term=purchase_payment_term,
|
|
incoterm=purchase_incoterm,
|
|
btb=None,
|
|
)
|
|
wizard = duplicate_module.TradeCustomDuplicate()
|
|
wizard.records = [record]
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
values = wizard.default_start(None)
|
|
|
|
self.assertEqual(values['mirror_mode'], 'new_mirror')
|
|
self.assertEqual(values['mirror_party'], purchase_party.id)
|
|
self.assertEqual(values['mirror_currency'], purchase_currency.id)
|
|
self.assertEqual(values['mirror_payment_term'], purchase_payment_term.id)
|
|
self.assertEqual(values['mirror_incoterm'], purchase_incoterm.id)
|
|
self.assertEqual(values['mirror_quantity'], Decimal('100'))
|
|
self.assertEqual(values['mirror_unit'], unit.id)
|
|
self.assertEqual(values['mirror_unit_price'], Decimal('25'))
|
|
self.assertEqual(values['mirror_price_type'], 'priced')
|
|
|
|
def test_custom_duplicate_starts_from_lot_report_purchase(self):
|
|
'custom duplicate can resolve a Lots Management row to its purchase'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
purchase_currency = SimpleNamespace(id=4)
|
|
purchase_payment_term = SimpleNamespace(id=5)
|
|
purchase_incoterm = SimpleNamespace(id=6)
|
|
unit = SimpleNamespace(id=3)
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[],
|
|
unit=unit,
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
purchase = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=purchase_currency,
|
|
payment_term=purchase_payment_term,
|
|
incoterm=purchase_incoterm,
|
|
btb=None,
|
|
)
|
|
line.purchase = purchase
|
|
lot_report = SimpleNamespace(
|
|
__name__='lot.report',
|
|
r_purchase=purchase,
|
|
r_sale=None,
|
|
r_lot_p=None,
|
|
r_lot_s=None,
|
|
r_lot_himself=None,
|
|
)
|
|
wizard = duplicate_module.TradeCustomDuplicate()
|
|
wizard.records = [lot_report]
|
|
|
|
values = wizard.default_start(None)
|
|
|
|
self.assertEqual(values['source_model'], 'purchase.purchase')
|
|
self.assertEqual(values['source'], 'purchase.purchase,12')
|
|
self.assertFalse(values['duplicate_pair_available'])
|
|
self.assertEqual(values['mirror_mode'], 'new_mirror')
|
|
|
|
def test_custom_duplicate_lot_report_marks_pair_available(self):
|
|
'Lots Management custom duplicate enables pair mode when matched'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
sale_party = SimpleNamespace(id=2)
|
|
unit = SimpleNamespace(id=3)
|
|
sale = SimpleNamespace(
|
|
party=sale_party,
|
|
currency=SimpleNamespace(id=30),
|
|
payment_term=SimpleNamespace(id=31),
|
|
incoterm=SimpleNamespace(id=32))
|
|
sale_line = SimpleNamespace(
|
|
id=20,
|
|
sale=sale,
|
|
unit=unit,
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('50'),
|
|
price_type='priced')
|
|
physical_lot = SimpleNamespace(
|
|
id=10,
|
|
lot_type='physic',
|
|
sale_line=sale_line)
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[physical_lot],
|
|
unit=unit,
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
purchase = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=SimpleNamespace(id=4),
|
|
payment_term=SimpleNamespace(id=5),
|
|
incoterm=SimpleNamespace(id=6),
|
|
)
|
|
lot_report = SimpleNamespace(
|
|
__name__='lot.report',
|
|
r_purchase=purchase,
|
|
r_sale=None,
|
|
r_lot_p=physical_lot,
|
|
r_lot_s=None,
|
|
r_lot_himself=None,
|
|
)
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
|
|
wizard = duplicate_module.TradeCustomDuplicate()
|
|
wizard.records = [lot_report]
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
values = wizard.default_start(None)
|
|
|
|
self.assertTrue(values['duplicate_pair_available'])
|
|
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
|
|
self.assertEqual(values['mirror_party'], sale_party.id)
|
|
|
|
def test_custom_duplicate_mirror_mode_change_refreshes_source_defaults(self):
|
|
'changing mirror mode refreshes mirror fields from the source contract'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
purchase_currency = SimpleNamespace(id=4)
|
|
purchase_payment_term = SimpleNamespace(id=5)
|
|
purchase_incoterm = SimpleNamespace(id=6)
|
|
unit = SimpleNamespace(id=3)
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[],
|
|
unit=unit,
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
record = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=purchase_currency,
|
|
payment_term=purchase_payment_term,
|
|
incoterm=purchase_incoterm,
|
|
)
|
|
start = duplicate_module.TradeCustomDuplicateStart()
|
|
start.source = record
|
|
start.mirror_mode = 'new_mirror'
|
|
start.mirror_party = SimpleNamespace(id=99)
|
|
|
|
start.on_change_mirror_mode()
|
|
|
|
self.assertIs(start.mirror_party, purchase_party)
|
|
self.assertIs(start.mirror_currency, purchase_currency)
|
|
self.assertIs(start.mirror_payment_term, purchase_payment_term)
|
|
self.assertIs(start.mirror_incoterm, purchase_incoterm)
|
|
self.assertEqual(start.mirror_quantity, Decimal('100'))
|
|
self.assertIs(start.mirror_unit, unit)
|
|
self.assertEqual(start.mirror_unit_price, Decimal('25'))
|
|
self.assertEqual(start.mirror_price_type, 'priced')
|
|
|
|
def test_custom_duplicate_defaults_mirror_from_back_to_back_sale(self):
|
|
'custom duplicate can use the back-to-back sale as matched pair'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
sale_party = SimpleNamespace(id=2)
|
|
sale_line = SimpleNamespace(
|
|
__name__='sale.line',
|
|
id=20,
|
|
type='line',
|
|
product=Mock())
|
|
sale = SimpleNamespace(
|
|
__name__='sale.sale',
|
|
id=21,
|
|
lines=[sale_line],
|
|
party=sale_party)
|
|
sale_line.sale = sale
|
|
btb = SimpleNamespace(sale=[sale], purchase=[])
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[],
|
|
unit=SimpleNamespace(id=3),
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
record = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=SimpleNamespace(id=4),
|
|
payment_term=SimpleNamespace(id=5),
|
|
incoterm=SimpleNamespace(id=6),
|
|
btb=btb,
|
|
)
|
|
wizard = duplicate_module.TradeCustomDuplicate()
|
|
wizard.records = [record]
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
values = wizard.default_start(None)
|
|
|
|
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
|
|
self.assertEqual(values['mirror_party'], sale_party.id)
|
|
|
|
def test_custom_duplicate_defaults_mirror_from_physical_lot_sale(self):
|
|
'custom duplicate can use the sale_line of a physical lot'
|
|
purchase_party = SimpleNamespace(id=1)
|
|
sale_party = SimpleNamespace(id=2)
|
|
sale = SimpleNamespace(party=sale_party)
|
|
sale_line = SimpleNamespace(id=20, sale=sale)
|
|
physical_lot = SimpleNamespace(
|
|
id=10,
|
|
lot_type='physic',
|
|
sale_line=sale_line)
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
type='line',
|
|
product=Mock(),
|
|
lots=[physical_lot],
|
|
unit=SimpleNamespace(id=3),
|
|
quantity=Decimal('100'),
|
|
quantity_theorical=Decimal('100'),
|
|
unit_price=Decimal('25'),
|
|
price_type='priced',
|
|
)
|
|
record = SimpleNamespace(
|
|
__name__='purchase.purchase',
|
|
id=12,
|
|
lines=[line],
|
|
party=purchase_party,
|
|
currency=SimpleNamespace(id=4),
|
|
payment_term=SimpleNamespace(id=5),
|
|
incoterm=SimpleNamespace(id=6),
|
|
)
|
|
wizard = duplicate_module.TradeCustomDuplicate()
|
|
wizard.records = [record]
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
values = wizard.default_start(None)
|
|
|
|
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
|
|
self.assertEqual(values['mirror_party'], sale_party.id)
|
|
|
|
def test_custom_duplicate_warns_and_uses_first_counterpart(self):
|
|
'custom duplicate warns when several matched counterparts exist'
|
|
sale_line_a = SimpleNamespace(id=20, sale=SimpleNamespace(id=30))
|
|
sale_line_b = SimpleNamespace(id=21, sale=SimpleNamespace(id=31))
|
|
lot_qts = [
|
|
SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line_a)),
|
|
SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line_b)),
|
|
]
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return lot_qts
|
|
|
|
class FakeWarning:
|
|
@classmethod
|
|
def check(cls, name):
|
|
return True
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {
|
|
'lot.qt': FakeLotQt,
|
|
'res.user.warning': FakeWarning,
|
|
}[name]
|
|
line = SimpleNamespace(
|
|
__name__='purchase.line',
|
|
id=11,
|
|
lots=[SimpleNamespace(id=10)],
|
|
)
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
with self.assertRaises(UserWarning):
|
|
duplicate_module.TradeCustomDuplicate._matched_counterpart_line(
|
|
line, strict=False, warn=True)
|
|
|
|
class AcknowledgedWarning:
|
|
@classmethod
|
|
def check(cls, name):
|
|
return False
|
|
|
|
pool.get.side_effect = lambda name: {
|
|
'lot.qt': FakeLotQt,
|
|
'res.user.warning': AcknowledgedWarning,
|
|
}[name]
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
matched_line = (
|
|
duplicate_module.TradeCustomDuplicate._matched_counterpart_line(
|
|
line, strict=False, warn=True))
|
|
|
|
self.assertIs(matched_line, sale_line_a)
|
|
|
|
def test_sale_line_validate_uses_pool_fee_lots_model(self):
|
|
'sale line validation uses the real fee.lots model for new lot fees'
|
|
created_fee_lots = []
|
|
saved_lots = []
|
|
|
|
class FakeFeeLots:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
@classmethod
|
|
def save(cls, records):
|
|
created_fee_lots.extend(records)
|
|
|
|
class FakeLot:
|
|
def __init__(self):
|
|
self.id = 42
|
|
|
|
@classmethod
|
|
def save(cls, records):
|
|
saved_lots.extend(records)
|
|
|
|
class FakeLotQtHist:
|
|
pass
|
|
|
|
class FakeLotQtType:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
return []
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {
|
|
'fee.lots': FakeFeeLots,
|
|
'lot.qt.hist': FakeLotQtHist,
|
|
'lot.qt.type': FakeLotQtType,
|
|
'lot.lot': FakeLot,
|
|
'lot.qt': Mock(search=Mock(return_value=[])),
|
|
}[name]
|
|
line = Mock(
|
|
id=11,
|
|
product=Mock(type='goods'),
|
|
lots=[],
|
|
fees=[SimpleNamespace(id=7)],
|
|
unit=Mock(),
|
|
quantity_theorical=Decimal('25'),
|
|
created_by_code=False,
|
|
price_components=[],
|
|
fee_=None,
|
|
)
|
|
line.check_pricing.return_value = None
|
|
line.check_targeted_qt_tolerance.return_value = None
|
|
|
|
with patch.object(sale_module, 'Pool', return_value=pool), \
|
|
patch.object(
|
|
sale_module.SaleLine, '_has_invalid_delivery_period',
|
|
return_value=False), \
|
|
patch.object(
|
|
sale_module.SaleLine,
|
|
'_sync_initial_quantity_from_theorical',
|
|
return_value=False), \
|
|
patch.object(sale_module.SaleLine, 'save'), \
|
|
patch.object(sale_module.Pnl, 'generate_from_sale_line'):
|
|
sale_module.SaleLine.validate([line])
|
|
|
|
self.assertEqual(len(saved_lots), 1)
|
|
self.assertEqual(len(created_fee_lots), 1)
|
|
self.assertEqual(created_fee_lots[0].sale_line, line.id)
|
|
|
|
def test_custom_duplicate_match_pair_lots_links_new_quantities(self):
|
|
'custom duplicate matches the new purchase and sale lot.qt rows'
|
|
saved = []
|
|
deleted = []
|
|
checked_lines = []
|
|
purchase_lqt = SimpleNamespace(
|
|
lot_s=None,
|
|
lot_quantity=Decimal('0'),
|
|
)
|
|
sale_lqt = SimpleNamespace(
|
|
lot_p=None,
|
|
lot_quantity=Decimal('100'),
|
|
)
|
|
|
|
class FakeLotQt:
|
|
@classmethod
|
|
def search(cls, domain):
|
|
if ('lot_p', '=', 101) in domain:
|
|
return [purchase_lqt]
|
|
if ('lot_s', '=', 202) in domain:
|
|
return [sale_lqt]
|
|
return []
|
|
|
|
@classmethod
|
|
def save(cls, records):
|
|
saved.extend(records)
|
|
|
|
@classmethod
|
|
def delete(cls, records):
|
|
deleted.extend(records)
|
|
|
|
class FakeLot:
|
|
@classmethod
|
|
def assert_lines_quantity_consistency(cls, lines):
|
|
checked_lines.extend(lines)
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = lambda name: {
|
|
'lot.qt': FakeLotQt,
|
|
'lot.lot': FakeLot,
|
|
}[name]
|
|
purchase_line = SimpleNamespace(__name__='purchase.line')
|
|
sale_line = SimpleNamespace(__name__='sale.line')
|
|
|
|
with patch.object(duplicate_module, 'Pool', return_value=pool):
|
|
duplicate_module.TradeCustomDuplicate._match_duplicate_pair_lots(
|
|
purchase_line, SimpleNamespace(id=101),
|
|
sale_line, SimpleNamespace(id=202),
|
|
Decimal('100'))
|
|
|
|
self.assertEqual(purchase_lqt.lot_s, 202)
|
|
self.assertEqual(purchase_lqt.lot_quantity, Decimal('100.00000'))
|
|
self.assertEqual(saved, [purchase_lqt])
|
|
self.assertEqual(deleted, [sale_lqt])
|
|
self.assertEqual(checked_lines, [purchase_line, sale_line])
|
|
|
|
def test_lot_matching_rejects_purchase_quantity_above_available(self):
|
|
'apply matching cannot consume more than available purchase quantity'
|
|
class LotQtMock:
|
|
def __call__(self, _id):
|
|
return Mock(lot_quantity=Decimal('100'))
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = (
|
|
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
|
|
purchase_lot = Mock(
|
|
lot_matched_qt=Decimal('101'), lot_r_id=1)
|
|
|
|
with patch.object(lot_module, 'Pool', return_value=pool):
|
|
with self.assertRaises(UserError):
|
|
lot_module.LotQt.match_lots([purchase_lot], [])
|
|
|
|
def test_lot_matching_available_includes_tolerance_remaining(self):
|
|
'matching can use remaining purchase tolerance above open quantity'
|
|
purchase = Mock(tol_max=Decimal('10'))
|
|
line = Mock(
|
|
purchase=purchase, inherit_tol=True,
|
|
quantity_theorical=Decimal('100'), unit=None)
|
|
vlot = Mock(id=10, line=line)
|
|
lqt = Mock(lot_p=vlot, lot_quantity=Decimal('60'))
|
|
matched_lqt = Mock(lot_quantity=Decimal('40'), lot_unit=None)
|
|
|
|
with patch.object(
|
|
lot_module.LotQt, 'search', return_value=[matched_lqt]):
|
|
self.assertEqual(
|
|
lot_module.LotQt._matching_tolerated_available(
|
|
lqt, 'purchase'),
|
|
Decimal('70.00000'))
|
|
|
|
def test_lot_matching_accepts_purchase_quantity_within_tolerance(self):
|
|
'apply matching can consume purchase quantity up to tolerance max'
|
|
purchase = Mock(tol_max=Decimal('10'))
|
|
line = Mock(
|
|
purchase=purchase, inherit_tol=True,
|
|
quantity_theorical=Decimal('100'), unit=None)
|
|
vlot = Mock(id=10, line=line)
|
|
lqt = Mock(lot_p=vlot, lot_s=None, lot_quantity=Decimal('100'))
|
|
|
|
class LotQtMock:
|
|
def __call__(self, _id):
|
|
return lqt
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = (
|
|
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
|
|
purchase_lot = Mock(
|
|
lot_matched_qt=Decimal('105'), lot_r_id=1)
|
|
|
|
with patch.object(lot_module, 'Pool', return_value=pool), \
|
|
patch.object(lot_module.LotQt, 'search', return_value=[]):
|
|
lot_module.LotQt.match_lots([purchase_lot], [])
|
|
|
|
def test_lot_matching_gauge_uses_entered_quantity_to_match(self):
|
|
'go to matching gauge projects tolerance from qt to match'
|
|
matching_lot = lot_module.LotMatchingLot()
|
|
matching_lot.lot_theoretical_qt = Decimal('100')
|
|
matching_lot.lot_already_matched_qt = Decimal('40')
|
|
matching_lot.lot_matched_qt = Decimal('65')
|
|
matching_lot.lot_tolerance_min = Decimal('-5')
|
|
matching_lot.lot_tolerance_max = Decimal('10')
|
|
|
|
self.assertEqual(
|
|
matching_lot.on_change_with_lot_tolerance_used(),
|
|
Decimal('5.00000'))
|
|
|
|
def test_go_matching_defaults_selected_open_lot_qts(self):
|
|
'go to matching only preloads selected unmatched open lot.qt rows'
|
|
purchase = Mock(id=10, party=Mock(id=20))
|
|
sale = Mock(id=30, party=Mock(id=40))
|
|
product = Mock(id=50)
|
|
purchase_lot = Mock(
|
|
id=101,
|
|
line=Mock(
|
|
purchase=purchase, inherit_tol=False,
|
|
quantity_theorical=Decimal('100'),
|
|
targeted_qt=Decimal('96'), tol_min=0, tol_max=0),
|
|
sale_line=None,
|
|
lot_type='virtual', lot_product=product)
|
|
sale_lot = Mock(
|
|
id=102, line=None,
|
|
sale_line=Mock(
|
|
sale=sale, inherit_tol=False,
|
|
quantity_theorical=Decimal('80'),
|
|
targeted_qt=Decimal('84'), tol_min=0, tol_max=0),
|
|
lot_type='virtual', lot_product=product)
|
|
matched_sale_lot = Mock(
|
|
id=103, line=None,
|
|
sale_line=Mock(
|
|
sale=sale, inherit_tol=False,
|
|
quantity_theorical=Decimal('20'), tol_min=0, tol_max=0),
|
|
lot_type='virtual', lot_product=product)
|
|
lot_qts = {
|
|
1: Mock(
|
|
id=1, lot_p=purchase_lot, lot_s=None,
|
|
lot_quantity=Decimal('100'),
|
|
lot_shipment_in=None, lot_shipment_internal=None,
|
|
lot_shipment_out=None),
|
|
2: Mock(
|
|
id=2, lot_p=None, lot_s=sale_lot,
|
|
lot_quantity=Decimal('80'),
|
|
lot_shipment_in=None, lot_shipment_internal=None,
|
|
lot_shipment_out=None),
|
|
3: Mock(
|
|
id=3, lot_p=purchase_lot, lot_s=matched_sale_lot,
|
|
lot_quantity=Decimal('20'),
|
|
lot_shipment_in=None, lot_shipment_internal=None,
|
|
lot_shipment_out=None),
|
|
}
|
|
|
|
class LotQtMock:
|
|
def __call__(self, _id):
|
|
return lot_qts[_id]
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = (
|
|
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
|
|
transaction = Mock()
|
|
transaction.context = {
|
|
'active_ids': [10000001, 10000002, 10000003],
|
|
}
|
|
|
|
with patch.object(lot_module, 'Pool', return_value=pool):
|
|
with patch.object(
|
|
lot_module, 'Transaction', return_value=transaction):
|
|
result = lot_module.LotGoMatching().default_match([])
|
|
|
|
self.assertEqual(result['qt_type'], 'all')
|
|
self.assertEqual(len(result['lot_p']), 1)
|
|
self.assertEqual(len(result['lot_s']), 1)
|
|
self.assertEqual(result['lot_p'][0]['lot_r_id'], 1)
|
|
self.assertEqual(result['lot_p'][0]['lot_quantity'], Decimal('100'))
|
|
self.assertEqual(result['lot_p'][0]['lot_targeted_qt'], Decimal('96'))
|
|
self.assertEqual(result['lot_p'][0]['lot_qt_min'], Decimal('100.00000'))
|
|
self.assertEqual(result['lot_p'][0]['lot_qt_max'], Decimal('100.00000'))
|
|
self.assertEqual(result['lot_p'][0]['lot_cp'], 20)
|
|
self.assertEqual(result['lot_s'][0]['lot_r_id'], 2)
|
|
self.assertEqual(result['lot_s'][0]['lot_quantity'], Decimal('80'))
|
|
self.assertEqual(result['lot_s'][0]['lot_targeted_qt'], Decimal('84'))
|
|
self.assertEqual(result['lot_s'][0]['lot_qt_min'], Decimal('80.00000'))
|
|
self.assertEqual(result['lot_s'][0]['lot_qt_max'], Decimal('80.00000'))
|
|
self.assertEqual(result['lot_s'][0]['lot_cp'], 40)
|
|
|
|
def test_go_matching_defaults_selected_physical_purchase_lot(self):
|
|
'go to matching preloads selected unmatched physical purchase lots'
|
|
purchase = Mock(id=10, party=Mock(id=20))
|
|
sale = Mock(id=30, party=Mock(id=40))
|
|
product = Mock(id=50)
|
|
physical_lot = Mock(
|
|
id=101,
|
|
line=Mock(
|
|
purchase=purchase, inherit_tol=False,
|
|
quantity_theorical=Decimal('100'),
|
|
targeted_qt=Decimal('96'), tol_min=0, tol_max=0),
|
|
sale_line=None,
|
|
lot_type='physic',
|
|
lot_product=product,
|
|
lot_shipment_in=None,
|
|
lot_shipment_internal=None,
|
|
lot_shipment_out=None)
|
|
physical_lot.get_current_quantity_converted.return_value = (
|
|
Decimal('70'))
|
|
sale_lot = Mock(
|
|
id=102, line=None,
|
|
sale_line=Mock(
|
|
sale=sale, inherit_tol=False,
|
|
quantity_theorical=Decimal('80'),
|
|
targeted_qt=Decimal('84'), tol_min=0, tol_max=0),
|
|
lot_type='virtual', lot_product=product)
|
|
sale_lqt = Mock(
|
|
id=2, lot_p=None, lot_s=sale_lot,
|
|
lot_quantity=Decimal('80'),
|
|
lot_shipment_in=None, lot_shipment_internal=None,
|
|
lot_shipment_out=None)
|
|
|
|
class LotQtMock:
|
|
def __call__(self, _id):
|
|
return sale_lqt
|
|
|
|
class LotMock:
|
|
def __call__(self, _id):
|
|
return physical_lot
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = (
|
|
lambda name: LotQtMock() if name == 'lot.qt' else LotMock())
|
|
transaction = Mock()
|
|
transaction.context = {
|
|
'active_ids': [101, 10000002],
|
|
}
|
|
|
|
with patch.object(lot_module, 'Pool', return_value=pool):
|
|
with patch.object(
|
|
lot_module, 'Transaction', return_value=transaction):
|
|
result = lot_module.LotGoMatching().default_match([])
|
|
|
|
self.assertEqual(len(result['lot_p']), 1)
|
|
self.assertEqual(len(result['lot_s']), 1)
|
|
self.assertEqual(result['lot_p'][0]['lot_id'], 101)
|
|
self.assertIsNone(result['lot_p'][0]['lot_r_id'])
|
|
self.assertEqual(result['lot_p'][0]['lot_type'], 'physic')
|
|
self.assertEqual(result['lot_p'][0]['lot_quantity'], Decimal('70'))
|
|
self.assertEqual(result['lot_p'][0]['lot_targeted_qt'], Decimal('96'))
|
|
self.assertEqual(result['lot_p'][0]['lot_cp'], 20)
|
|
self.assertEqual(result['lot_s'][0]['lot_r_id'], 2)
|
|
|
|
def test_go_matching_requires_purchase_and_sale_selection(self):
|
|
'go to matching requires one purchase and one sale row'
|
|
wizard = lot_module.LotGoMatching()
|
|
wizard.match = Mock(lot_p=[], lot_s=[Mock()])
|
|
|
|
with self.assertRaises(UserError):
|
|
wizard.transition_matching()
|
|
|
|
def test_physical_purchase_matching_keeps_empty_sale_lot_qt(self):
|
|
'physical purchase matching does not fail when sale lot_qt is empty'
|
|
physical_lot = Mock(
|
|
id=101,
|
|
lot_type='physic',
|
|
sale_line=None)
|
|
physical_lot.get_current_quantity_converted.return_value = (
|
|
Decimal('10'))
|
|
physical_lot.getVlot_p.return_value = Mock()
|
|
physical_lot.updateVirtualPart.return_value = True
|
|
sale_virtual_lot = Mock(
|
|
sale_line=Mock(),
|
|
lot_quantity=Decimal('9'),
|
|
lot_qt=None)
|
|
sale_virtual_lot.getVlot_s.return_value = sale_virtual_lot
|
|
sale_lqt = Mock(
|
|
lot_p=None,
|
|
lot_s=sale_virtual_lot,
|
|
lot_quantity=Decimal('9'),
|
|
lot_unit=None)
|
|
|
|
class LotQtMock:
|
|
def __call__(self, _id):
|
|
return sale_lqt
|
|
|
|
@staticmethod
|
|
def search(_domain):
|
|
return []
|
|
|
|
@staticmethod
|
|
def save(_records):
|
|
return None
|
|
|
|
class LotMock:
|
|
def __call__(self, _id):
|
|
return physical_lot
|
|
|
|
@staticmethod
|
|
def save(_records):
|
|
return None
|
|
|
|
pool = Mock()
|
|
pool.get.side_effect = (
|
|
lambda name: LotQtMock() if name == 'lot.qt' else LotMock())
|
|
purchase_match = Mock(
|
|
lot_id=101,
|
|
lot_r_id=None,
|
|
lot_matched_qt=Decimal('1'))
|
|
sale_match = Mock(
|
|
lot_r_id=2,
|
|
lot_matched_qt=Decimal('1'))
|
|
|
|
with patch.object(lot_module, 'Pool', return_value=pool), \
|
|
patch.object(lot_module.LotQt, 'search', return_value=[]):
|
|
lot_module.LotQt.match_lots([purchase_match], [sale_match])
|
|
|
|
self.assertIsNone(sale_virtual_lot.lot_qt)
|
|
self.assertEqual(sale_virtual_lot.lot_quantity, Decimal('8'))
|
|
|
|
def test_purchase_tolerance_used_is_weighted_line_average(self):
|
|
'purchase tolerance gauge averages line gauges by theoretical quantity'
|
|
unit = Mock()
|
|
physical = Mock(lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('105')
|
|
physical_line = Mock(
|
|
type='line', unit=unit, quantity_theorical=Decimal('100'),
|
|
lots=[physical])
|
|
virtual = Mock(id=10, lot_type='virtual')
|
|
open_line = Mock(
|
|
type='line', unit=unit, quantity_theorical=Decimal('300'),
|
|
lots=[virtual])
|
|
purchase = purchase_module.Purchase()
|
|
purchase.lines = [physical_line, open_line]
|
|
purchase.tol_min = Decimal('5')
|
|
purchase.tol_max = Decimal('10')
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(lot_quantity=Decimal('270'), lot_unit=unit)]
|
|
pool = Mock()
|
|
pool.get.return_value = lot_qt_model
|
|
|
|
with patch.object(purchase_module, 'Pool', return_value=pool):
|
|
self.assertEqual(
|
|
purchase.get_tolerance_used('tolerance_used'),
|
|
Decimal('-6.25000'))
|
|
self.assertEqual(
|
|
purchase.get_tolerance_min('tolerance_min'),
|
|
Decimal('-5'))
|
|
self.assertEqual(
|
|
purchase.get_tolerance_max('tolerance_max'),
|
|
Decimal('10'))
|
|
|
|
def test_sale_tolerance_used_is_weighted_line_average(self):
|
|
'sale tolerance gauge averages line gauges by theoretical quantity'
|
|
unit = Mock()
|
|
physical = Mock(lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('105')
|
|
physical_line = Mock(
|
|
type='line', unit=unit, quantity_theorical=Decimal('100'),
|
|
lots=[physical])
|
|
virtual = Mock(id=20, lot_type='virtual')
|
|
open_line = Mock(
|
|
type='line', unit=unit, quantity_theorical=Decimal('300'),
|
|
lots=[virtual])
|
|
sale = sale_module.Sale()
|
|
sale.lines = [physical_line, open_line]
|
|
sale.tol_min = Decimal('5')
|
|
sale.tol_max = Decimal('10')
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(lot_quantity=Decimal('-270'), lot_unit=unit)]
|
|
pool = Mock()
|
|
pool.get.return_value = lot_qt_model
|
|
|
|
with patch.object(sale_module, 'Pool', return_value=pool):
|
|
self.assertEqual(
|
|
sale.get_tolerance_used('tolerance_used'),
|
|
Decimal('-6.25000'))
|
|
self.assertEqual(
|
|
sale.get_tolerance_min('tolerance_min'),
|
|
Decimal('-5'))
|
|
self.assertEqual(
|
|
sale.get_tolerance_max('tolerance_max'),
|
|
Decimal('10'))
|
|
|
|
def test_purchase_line_tolerance_uses_physical_lots_when_present(self):
|
|
'purchase line tolerance uses the editable targeted quantity'
|
|
unit = Mock()
|
|
virtual = Mock(id=10, lot_type='virtual')
|
|
physical = Mock(lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('105')
|
|
line = purchase_module.Line()
|
|
line.unit = unit
|
|
line.quantity_theorical = Decimal('100')
|
|
line.quantity = Decimal('100')
|
|
line.targeted_qt = Decimal('105')
|
|
line.lots = [virtual, physical]
|
|
line.inherit_tol = False
|
|
line.tol_min = Decimal('5')
|
|
line.tol_max = Decimal('10')
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(lot_quantity=Decimal('80'), lot_unit=unit)]
|
|
pool = Mock()
|
|
pool.get.return_value = lot_qt_model
|
|
|
|
with patch.object(purchase_module, 'Pool', return_value=pool):
|
|
self.assertEqual(
|
|
line.get_tolerance_used('tolerance_used'),
|
|
Decimal('5.00000'))
|
|
lot_qt_model.search.assert_not_called()
|
|
|
|
def test_sale_line_tolerance_uses_open_lot_qt_without_physical_lots(self):
|
|
'sale line tolerance uses the editable targeted quantity'
|
|
unit = Mock()
|
|
virtual = Mock(id=20, lot_type='virtual')
|
|
line = sale_module.SaleLine()
|
|
line.unit = unit
|
|
line.quantity_theorical = Decimal('100')
|
|
line.quantity = Decimal('100')
|
|
line.targeted_qt = Decimal('95')
|
|
line.lots = [virtual]
|
|
line.inherit_tol = False
|
|
line.tol_min = Decimal('5')
|
|
line.tol_max = Decimal('10')
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(lot_quantity=Decimal('-95'), lot_unit=unit)]
|
|
pool = Mock()
|
|
pool.get.return_value = lot_qt_model
|
|
|
|
with patch.object(sale_module, 'Pool', return_value=pool):
|
|
self.assertEqual(
|
|
line.get_tolerance_used('tolerance_used'),
|
|
Decimal('-5.00000'))
|
|
|
|
def test_purchase_line_targeted_qt_blocks_above_tolerance(self):
|
|
'purchase line targeted quantity must stay inside tolerance bounds'
|
|
physical = Mock(lot_type='physic')
|
|
physical.get_current_quantity_converted.return_value = Decimal('111')
|
|
line = purchase_module.Line()
|
|
line.quantity_theorical = Decimal('100')
|
|
line.quantity = Decimal('100')
|
|
line.targeted_qt = Decimal('111')
|
|
line.lots = [physical]
|
|
line.inherit_tol = False
|
|
line.tol_min = Decimal('5')
|
|
line.tol_max = Decimal('10')
|
|
|
|
with self.assertRaises(UserError):
|
|
line.check_targeted_qt_tolerance()
|
|
|
|
def test_sale_line_targeted_qt_blocks_below_tolerance(self):
|
|
'sale line targeted quantity must not fall below tolerance minimum'
|
|
unit = Mock()
|
|
virtual = Mock(id=20, lot_type='virtual')
|
|
line = sale_module.SaleLine()
|
|
line.unit = unit
|
|
line.quantity_theorical = Decimal('100')
|
|
line.quantity = Decimal('100')
|
|
line.targeted_qt = Decimal('94')
|
|
line.lots = [virtual]
|
|
line.inherit_tol = False
|
|
line.tol_min = Decimal('5')
|
|
line.tol_max = Decimal('10')
|
|
lot_qt_model = Mock()
|
|
lot_qt_model.search.return_value = [
|
|
Mock(lot_quantity=Decimal('-94'), lot_unit=unit)]
|
|
pool = Mock()
|
|
pool.get.return_value = lot_qt_model
|
|
|
|
with patch.object(sale_module, 'Pool', return_value=pool):
|
|
with self.assertRaises(UserError):
|
|
line.check_targeted_qt_tolerance()
|
|
|
|
def test_purchase_line_initial_targeted_qt_defaults_to_contract_quantity(self):
|
|
'purchase line targeted quantity defaults to contractual quantity'
|
|
values = {'quantity_theorical': Decimal('100')}
|
|
|
|
purchase_module.Line._set_initial_quantity_values(values)
|
|
|
|
self.assertEqual(values['targeted_qt'], Decimal('100.00000'))
|
|
|
|
def test_sale_line_initial_targeted_qt_defaults_to_contract_quantity(self):
|
|
'sale line targeted quantity defaults to contractual quantity'
|
|
values = {'quantity_theorical': Decimal('100')}
|
|
|
|
sale_module.SaleLine._set_initial_quantity_values(values)
|
|
|
|
self.assertEqual(values['targeted_qt'], Decimal('100.00000'))
|
|
|
|
def test_lot_context_group_defaults_to_all(self):
|
|
'lots management opens without grouping by physical lot'
|
|
self.assertEqual(lot_module.LotContext.default_group(), 'all')
|
|
|
|
def test_contract_detail_defaults_sale_locations_from_dropship_purchase(self):
|
|
'create contracts copies supplier-to-customer locations from purchase to sale'
|
|
supplier = Mock(id=1, type='supplier')
|
|
customer = Mock(id=2, type='customer')
|
|
purchase = Mock(from_location=supplier, to_location=customer)
|
|
lqt = Mock(
|
|
lot_p=Mock(line=Mock(purchase=purchase)),
|
|
lot_s=None)
|
|
|
|
with patch.object(
|
|
lot_module.ContractDetail, '_get_lqt_from_context',
|
|
return_value=lqt):
|
|
self.assertEqual(lot_module.ContractDetail.default_from_location(), 1)
|
|
self.assertEqual(lot_module.ContractDetail.default_to_location(), 2)
|
|
|
|
def test_contract_detail_defaults_sale_from_purchase_stock_destination(self):
|
|
'create contracts uses purchase stock destination as sale source'
|
|
supplier = Mock(id=1, type='supplier')
|
|
storage = Mock(id=3, type='storage')
|
|
purchase = Mock(from_location=supplier, to_location=storage)
|
|
lqt = Mock(
|
|
lot_p=Mock(line=Mock(purchase=purchase)),
|
|
lot_s=None)
|
|
|
|
with patch.object(
|
|
lot_module.ContractDetail, '_get_lqt_from_context',
|
|
return_value=lqt):
|
|
self.assertEqual(lot_module.ContractDetail.default_from_location(), 3)
|
|
self.assertIsNone(lot_module.ContractDetail.default_to_location())
|
|
|
|
def test_contract_detail_defaults_purchase_locations_from_dropship_sale(self):
|
|
'create contracts copies supplier-to-customer locations from sale to purchase'
|
|
supplier = Mock(id=1, type='supplier')
|
|
customer = Mock(id=2, type='customer')
|
|
sale = Mock(from_location=supplier, to_location=customer)
|
|
lqt = Mock(
|
|
lot_p=None,
|
|
lot_s=Mock(sale_line=Mock(sale=sale)))
|
|
|
|
with patch.object(
|
|
lot_module.ContractDetail, '_get_lqt_from_context',
|
|
return_value=lqt):
|
|
self.assertEqual(lot_module.ContractDetail.default_from_location(), 1)
|
|
self.assertEqual(lot_module.ContractDetail.default_to_location(), 2)
|
|
|
|
def test_contract_detail_defaults_purchase_to_sale_stock_source(self):
|
|
'create contracts uses sale stock source as purchase destination'
|
|
storage = Mock(id=3, type='storage')
|
|
customer = Mock(id=2, type='customer')
|
|
sale = Mock(from_location=storage, to_location=customer)
|
|
lqt = Mock(
|
|
lot_p=None,
|
|
lot_s=Mock(sale_line=Mock(sale=sale)))
|
|
|
|
with patch.object(
|
|
lot_module.ContractDetail, '_get_lqt_from_context',
|
|
return_value=lqt):
|
|
self.assertIsNone(lot_module.ContractDetail.default_from_location())
|
|
self.assertEqual(lot_module.ContractDetail.default_to_location(), 3)
|
|
|
|
def test_contract_detail_hides_melya_company_fields(self):
|
|
'create contracts mirrors Melya company field visibility'
|
|
company = Mock(party=Mock(name='MELYA'))
|
|
company_model = Mock(return_value=company)
|
|
transaction = Mock()
|
|
transaction.context = {'company': 42}
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock, \
|
|
patch('trytond.modules.purchase_trade.lot.Transaction',
|
|
return_value=transaction):
|
|
PoolMock.return_value.get.return_value = company_model
|
|
|
|
self.assertTrue(
|
|
lot_module.ContractDetail.default_company_visible())
|
|
|
|
def test_sale_report_price_lines_basis_displays_premium_only(self):
|
|
'basis report pricing displays only the premium in templates'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
line = Mock()
|
|
line.type = 'line'
|
|
line.price_type = 'basis'
|
|
line.enable_linked_currency = True
|
|
line.linked_currency = Mock(rec_name='USC')
|
|
line.linked_unit = Mock(rec_name='POUND')
|
|
line.unit = Mock(rec_name='MT')
|
|
line.unit_price = Decimal('1598.3495')
|
|
line.linked_price = Decimal('72.5000')
|
|
line.premium = Decimal('8.3000')
|
|
line.get_pricing_text = 'ON ICE Cotton #2 MARCH 2026'
|
|
|
|
sale = Sale()
|
|
sale.currency = Mock(rec_name='USD')
|
|
sale.lines = [line]
|
|
|
|
self.assertEqual(
|
|
sale.report_price_lines,
|
|
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026')
|
|
|
|
def test_sale_report_net_and_gross_sum_all_lines(self):
|
|
'sale report totals aggregate every line instead of the first one only'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
def make_lot(quantity):
|
|
lot = Mock()
|
|
lot.lot_type = 'physic'
|
|
lot.get_current_quantity.return_value = Decimal(quantity)
|
|
lot.get_current_gross_quantity.return_value = Decimal(quantity)
|
|
return lot
|
|
|
|
line_a = Mock(type='line', quantity=Decimal('1000'))
|
|
line_a.lots = [make_lot('1000')]
|
|
line_b = Mock(type='line', quantity=Decimal('1000'))
|
|
line_b.lots = [make_lot('1000')]
|
|
|
|
sale = Sale()
|
|
sale.lines = [line_a, line_b]
|
|
|
|
self.assertEqual(sale.report_net, Decimal('2000'))
|
|
self.assertEqual(sale.report_gross, Decimal('2000'))
|
|
|
|
def test_sale_report_trade_blocks_use_lot_current_quantity(self):
|
|
'sale trade blocks use current lot quantity for quantity display'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
lot = Mock()
|
|
lot.lot_type = 'physic'
|
|
lot.get_current_quantity.return_value = Decimal('950')
|
|
line = Mock()
|
|
line.type = 'line'
|
|
line.lots = [lot]
|
|
line.quantity = Decimal('1000')
|
|
line.unit = Mock(rec_name='MT')
|
|
line.del_period = Mock(description='MARCH 2026')
|
|
line.price_type = 'priced'
|
|
line.linked_currency = Mock(rec_name='USC')
|
|
line.linked_unit = Mock(rec_name='POUND')
|
|
line.linked_price = Decimal('8.3000')
|
|
line.unit_price = Decimal('0')
|
|
line.get_pricing_text = 'ON ICE Cotton #2 MARCH 2026'
|
|
|
|
sale = Sale()
|
|
sale.currency = Mock(rec_name='USD')
|
|
sale.lines = [line]
|
|
|
|
self.assertEqual(
|
|
sale.report_trade_blocks,
|
|
[(
|
|
'950.0 MT (NINE HUNDRED AND FIFTY METRIC TONS) - MARCH 2026',
|
|
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026',
|
|
)])
|
|
|
|
def test_sale_report_uses_single_virtual_lot_hist_when_no_physical(self):
|
|
'sale report uses the unique virtual lot hist when no physical lot exists'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
|
|
virtual.get_hist_quantity.return_value = (
|
|
Decimal('930'),
|
|
Decimal('0'),
|
|
)
|
|
line = Mock(type='line', quantity=Decimal('1000'))
|
|
line.lots = [virtual]
|
|
line.unit = Mock(rec_name='MT')
|
|
line.del_period = Mock(description='MARCH 2026')
|
|
|
|
sale = Sale()
|
|
sale.lines = [line]
|
|
|
|
self.assertEqual(sale.report_net, Decimal('930'))
|
|
self.assertEqual(sale.report_gross, Decimal('930'))
|
|
self.assertEqual(sale.report_total_quantity, '930.0')
|
|
self.assertEqual(sale.report_quantity_unit_upper, 'LBS')
|
|
self.assertEqual(
|
|
sale.report_quantity_lines,
|
|
'930.0 LBS (NINE HUNDRED AND THIRTY POUNDS) - MARCH 2026')
|
|
|
|
def test_sale_report_prefers_physical_lot_hist_over_virtual(self):
|
|
'sale report prioritizes physical lot hist values over virtual ones'
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
|
|
virtual.get_hist_quantity.return_value = (
|
|
Decimal('930'),
|
|
Decimal('940'),
|
|
)
|
|
physical = Mock(lot_type='physic', lot_unit_line=Mock(rec_name='LBS'))
|
|
physical.get_hist_quantity.return_value = (
|
|
Decimal('950'),
|
|
Decimal('980'),
|
|
)
|
|
line = Mock(type='line', quantity=Decimal('1000'))
|
|
line.lots = [virtual, physical]
|
|
line.unit = Mock(rec_name='MT')
|
|
line.del_period = Mock(description='MARCH 2026')
|
|
|
|
sale = Sale()
|
|
sale.lines = [line]
|
|
|
|
self.assertEqual(sale.report_net, Decimal('950'))
|
|
self.assertEqual(sale.report_gross, Decimal('980'))
|
|
self.assertEqual(sale.report_total_quantity, '950.0')
|
|
self.assertEqual(sale.report_quantity_unit_upper, 'LBS')
|
|
self.assertEqual(
|
|
sale.report_quantity_lines,
|
|
'950.0 LBS (NINE HUNDRED AND FIFTY POUNDS) - MARCH 2026')
|
|
|
|
def test_invoice_report_note_title_uses_sale_direction(self):
|
|
'sale final note title is inverted from the raw amount sign'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
debit = Invoice()
|
|
debit.type = 'out'
|
|
debit.total_amount = Decimal('10')
|
|
self.assertEqual(debit.report_note_title, 'Debit Note')
|
|
|
|
credit = Invoice()
|
|
credit.type = 'out'
|
|
credit.total_amount = Decimal('-10')
|
|
self.assertEqual(credit.report_note_title, 'Credit Note')
|
|
|
|
def test_invoice_report_note_title_keeps_inverse_for_purchase(self):
|
|
'purchase final note title keeps the opposite mapping'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
credit = Invoice()
|
|
credit.type = 'in'
|
|
credit.total_amount = Decimal('10')
|
|
self.assertEqual(credit.report_note_title, 'Credit Note')
|
|
|
|
debit = Invoice()
|
|
debit.type = 'in'
|
|
debit.total_amount = Decimal('-10')
|
|
self.assertEqual(debit.report_note_title, 'Debit Note')
|
|
|
|
def test_invoice_commission_note_uses_company_to_broker_direction(self):
|
|
'commission note title follows company to broker correction direction'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
credit = Invoice()
|
|
credit.total_amount = Decimal('-79.37')
|
|
self.assertEqual(credit.report_commission_note_title, 'Credit Note')
|
|
self.assertEqual(credit.report_commission_due_label, 'TOTAL DUE TO US')
|
|
|
|
debit = Invoice()
|
|
debit.total_amount = Decimal('79.37')
|
|
self.assertEqual(debit.report_commission_note_title, 'Debit Note')
|
|
self.assertEqual(debit.report_commission_due_label, 'TOTAL DUE TO YOU')
|
|
|
|
def test_invoice_commission_note_total_is_absolute(self):
|
|
'commission note total is displayed without the accounting sign'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = Invoice()
|
|
invoice.total_amount = Decimal('-79.37')
|
|
|
|
self.assertEqual(invoice.report_commission_note_total_display, '79.37')
|
|
|
|
def test_invoice_commission_note_uses_signed_invoice_adjustment(self):
|
|
'commission note ignores the unsigned full fee amount'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
fee = Mock()
|
|
fee.get_amount.return_value = Decimal('24329.40')
|
|
invoice = Invoice()
|
|
invoice.total_amount = Decimal('-24.60')
|
|
invoice.lines = [Mock(type='line', fee=fee)]
|
|
|
|
self.assertEqual(invoice.report_commission_note_title, 'Credit Note')
|
|
self.assertEqual(invoice.report_commission_due_label, 'TOTAL DUE TO US')
|
|
self.assertEqual(invoice.report_commission_note_total_display, '24.60')
|
|
fee.get_amount.assert_not_called()
|
|
|
|
def test_sale_provisional_invoice_delete_collects_padding_lots(self):
|
|
'deleting a draft sale provisional invoice clears its lot padding'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = SimpleNamespace(
|
|
id=10,
|
|
type='out',
|
|
reference='Provisional',
|
|
number=None,
|
|
lines=[])
|
|
provisional_line = SimpleNamespace(invoice=invoice)
|
|
lot = SimpleNamespace(
|
|
id=20,
|
|
sale_invoice_padding=Decimal('2'),
|
|
sale_invoice_line_prov=provisional_line)
|
|
invoice.lines = [
|
|
SimpleNamespace(
|
|
type='line',
|
|
description='Pro forma',
|
|
lot=lot)]
|
|
|
|
self.assertEqual(
|
|
Invoice._sale_padding_lots_to_clear_on_delete([invoice]),
|
|
[lot])
|
|
|
|
def test_fee_invoice_delete_collects_fee_to_reset(self):
|
|
'deleting a draft fee invoice resets the linked fee to not invoiced'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
fee = SimpleNamespace(id=20, dn_cn=None)
|
|
invoice = SimpleNamespace(
|
|
id=10,
|
|
number=None,
|
|
lines=[SimpleNamespace(fee=fee)])
|
|
|
|
reset_fees, clear_dn_cn_fees = Invoice._fee_updates_on_delete(
|
|
[invoice])
|
|
|
|
self.assertEqual(reset_fees, [fee])
|
|
self.assertEqual(clear_dn_cn_fees, [])
|
|
|
|
def test_fee_dn_cn_delete_collects_fee_to_clear_dn_cn_only(self):
|
|
'deleting a draft fee DN/CN clears only the fee DN/CN link'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = SimpleNamespace(id=10, number=None, lines=[])
|
|
fee = SimpleNamespace(id=20, dn_cn=invoice)
|
|
invoice.lines = [SimpleNamespace(fee=fee)]
|
|
|
|
reset_fees, clear_dn_cn_fees = Invoice._fee_updates_on_delete(
|
|
[invoice])
|
|
|
|
self.assertEqual(reset_fees, [])
|
|
self.assertEqual(clear_dn_cn_fees, [fee])
|
|
|
|
def test_lot_invoice_message_mentions_processed_fees(self):
|
|
'lot invoice success message mentions selected fee invoicing'
|
|
wizard = lot_module.LotInvoice()
|
|
wizard._invoiced_fee_count = 2
|
|
|
|
self.assertEqual(
|
|
wizard.default_message(None)['message'],
|
|
'Invoice created. Fee invoices: 2.')
|
|
|
|
def test_invoice_report_net_sums_signed_invoice_lines(self):
|
|
'invoice report net uses the signed differential from invoice lines'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
line_a = Mock(type='line', quantity=Decimal('1000'))
|
|
line_b = Mock(type='line', quantity=Decimal('-200'))
|
|
invoice = Invoice()
|
|
invoice.lines = [line_a, line_b]
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('800'))
|
|
|
|
def test_invoice_report_weights_use_current_lot_hist_values(self):
|
|
'invoice net and gross weights come from the current lot hist entry'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
unit = Mock(rec_name='LBS', symbol='LBS')
|
|
lot = Mock(lot_unit_line=unit)
|
|
lot.get_hist_quantity.return_value = (
|
|
Decimal('950'),
|
|
Decimal('980'),
|
|
)
|
|
line = Mock(type='line', quantity=Decimal('1000'), lot=lot, unit=Mock(rec_name='MT'))
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('950'))
|
|
self.assertEqual(invoice.report_gross, Decimal('980'))
|
|
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
|
|
self.assertEqual(
|
|
invoice.report_quantity_lines,
|
|
'950.00 LBS (950.00 LBS)')
|
|
|
|
def test_invoice_report_weights_prefer_invoice_line_snapshot(self):
|
|
'invoice report weights use the invoicing snapshot when available'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
unit = Mock(rec_name='LBS', symbol='LBS')
|
|
lot = Mock(lot_unit_line=unit)
|
|
lot.get_hist_quantity.return_value = (
|
|
Decimal('950'),
|
|
Decimal('980'),
|
|
)
|
|
snapshot = Mock(
|
|
net_quantity=Decimal('900'),
|
|
gross_quantity=Decimal('920'),
|
|
unit=unit,
|
|
)
|
|
line = Mock(
|
|
type='line',
|
|
quantity=Decimal('1000'),
|
|
lot=lot,
|
|
unit=Mock(rec_name='MT'),
|
|
lot_weight_snapshots=[snapshot],
|
|
)
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('900'))
|
|
self.assertEqual(invoice.report_gross, Decimal('920'))
|
|
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
|
|
self.assertEqual(
|
|
invoice.report_quantity_lines,
|
|
'900.00 LBS (900.00 LBS)')
|
|
|
|
def test_invoice_report_proforma_snapshot_adds_sale_padding(self):
|
|
'ICT provisional report displays snapshot weights plus sale padding'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
unit = Mock(id=1, rec_name='LBS', symbol='LBS')
|
|
lot = Mock(lot_unit_line=unit, sale_invoice_padding=Decimal('10'))
|
|
snapshot = Mock(
|
|
net_quantity=Decimal('900'),
|
|
gross_quantity=Decimal('920'),
|
|
unit=unit,
|
|
)
|
|
line = Mock(
|
|
type='line',
|
|
description='Pro forma',
|
|
invoice_type='out',
|
|
quantity=Decimal('1010'),
|
|
lot=lot,
|
|
unit=unit,
|
|
lot_weight_snapshots=[snapshot],
|
|
)
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('910'))
|
|
self.assertEqual(invoice.report_gross, Decimal('930'))
|
|
self.assertEqual(
|
|
invoice.report_quantity_lines,
|
|
'910.00 LBS (910.00 LBS)')
|
|
|
|
def test_invoice_validation_creates_weight_snapshot_for_report_lines(self):
|
|
'invoice validation snapshots lot weights for pro forma and final lines'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
lot_ref = Mock(id=10)
|
|
lot = Mock(id=10)
|
|
proforma = Mock(
|
|
type='line',
|
|
description='Pro forma',
|
|
quantity=Decimal('1000'),
|
|
lot=lot_ref,
|
|
)
|
|
final = Mock(
|
|
type='line',
|
|
description='Final',
|
|
quantity=Decimal('1000'),
|
|
lot=lot_ref,
|
|
)
|
|
invoice = Invoice()
|
|
invoice.lines = [proforma, final]
|
|
|
|
snapshot_model = Mock()
|
|
lot_model = Mock(return_value=lot)
|
|
|
|
def get_model(name):
|
|
if name == 'account.invoice.line.lot.weight':
|
|
return snapshot_model
|
|
if name == 'lot.lot':
|
|
return lot_model
|
|
raise AssertionError(name)
|
|
|
|
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
|
|
PoolMock.return_value.get.side_effect = get_model
|
|
|
|
invoice._create_lot_weight_snapshots()
|
|
|
|
self.assertEqual(
|
|
snapshot_model.create_for_invoice_line.call_args_list,
|
|
[call(proforma, lot), call(final, lot)])
|
|
|
|
def test_invoice_report_lbs_converts_kilogram_to_lbs(self):
|
|
'invoice lbs helper converts kilogram quantities with the proper uom ratio'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
kg = Mock(id=1, rec_name='KILOGRAM', symbol='KG')
|
|
lbs = Mock(id=2, rec_name='LBS', symbol='LBS')
|
|
lot = Mock(lot_unit_line=kg)
|
|
lot.get_hist_quantity.return_value = (
|
|
Decimal('999995'),
|
|
Decimal('999995'),
|
|
)
|
|
line = Mock(type='line', quantity=Decimal('999995'), lot=lot, unit=kg)
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
|
|
uom_model = Mock()
|
|
uom_model.search.return_value = [lbs]
|
|
uom_model.compute_qty.side_effect = (
|
|
lambda from_unit, qty, to_unit: qty * 2.20462
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = uom_model
|
|
|
|
self.assertEqual(invoice.report_lbs, Decimal('2204608.98'))
|
|
self.assertEqual(
|
|
invoice.report_quantity_lines,
|
|
'999995.00 KILOGRAM (2204608.98 LBS)')
|
|
self.assertEqual(invoice.report_net_display, '999995.00')
|
|
self.assertEqual(invoice.report_lbs_display, '2204608.98')
|
|
|
|
def test_invoice_report_weights_keep_line_sign_with_lot_hist_values(self):
|
|
'invoice lot hist values keep the invoice line sign for final notes'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
positive_lot = Mock(lot_unit_line=Mock(rec_name='LBS'))
|
|
positive_lot.get_hist_quantity.return_value = (
|
|
Decimal('950'),
|
|
Decimal('980'),
|
|
)
|
|
negative_lot = Mock(lot_unit_line=Mock(rec_name='LBS'))
|
|
negative_lot.get_hist_quantity.return_value = (
|
|
Decimal('150'),
|
|
Decimal('160'),
|
|
)
|
|
positive = Mock(type='line', quantity=Decimal('1000'), lot=positive_lot)
|
|
negative = Mock(type='line', quantity=Decimal('-200'), lot=negative_lot)
|
|
invoice = Invoice()
|
|
invoice.lines = [positive, negative]
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('800'))
|
|
self.assertEqual(invoice.report_gross, Decimal('820'))
|
|
|
|
def test_invoice_report_shows_old_current_and_difference_for_same_lot_note(self):
|
|
'final note weight adjustment shows old, current and difference weights'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
mt = Mock(id=1, rec_name='MT')
|
|
kg = Mock(id=2, rec_name='KILOGRAM')
|
|
current_type = Mock(id=100)
|
|
previous_type = Mock(id=200)
|
|
shared_lot = Mock(id=10, lot_type='physic', lot_unit_line=kg)
|
|
shared_lot.lot_state = current_type
|
|
shared_lot.get_hist_quantity.return_value = (
|
|
Decimal('999995'),
|
|
Decimal('999992'),
|
|
)
|
|
shared_lot.lot_hist = [
|
|
Mock(
|
|
quantity_type=previous_type,
|
|
quantity=Decimal('999995'),
|
|
gross_quantity=Decimal('999998'),
|
|
),
|
|
Mock(
|
|
quantity_type=current_type,
|
|
quantity=Decimal('999990'),
|
|
gross_quantity=Decimal('999992'),
|
|
),
|
|
]
|
|
|
|
negative = Mock(
|
|
type='line',
|
|
quantity=Decimal('-999.995'),
|
|
unit=mt,
|
|
lot=shared_lot,
|
|
)
|
|
positive = Mock(
|
|
type='line',
|
|
quantity=Decimal('999.990'),
|
|
unit=mt,
|
|
lot=shared_lot,
|
|
lot_weight_snapshots=[
|
|
Mock(net_quantity=Decimal('999995'), gross_quantity=Decimal('999992'), unit=kg),
|
|
],
|
|
)
|
|
|
|
invoice = Invoice()
|
|
negative.invoice = invoice
|
|
positive.invoice = invoice
|
|
invoice.lines = [negative, positive]
|
|
|
|
uom_model = Mock()
|
|
uom_model.search.return_value = [Mock(id=3, rec_name='LBS', symbol='LBS')]
|
|
uom_model.compute_qty.side_effect = (
|
|
lambda from_unit, qty, to_unit: (
|
|
qty * 1000
|
|
if getattr(from_unit, 'rec_name', None) == 'MT'
|
|
and getattr(to_unit, 'rec_name', None) == 'KILOGRAM'
|
|
else (
|
|
qty * 2.20462
|
|
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
|
|
and getattr(to_unit, 'rec_name', None) == 'LBS'
|
|
else qty
|
|
)
|
|
)
|
|
)
|
|
|
|
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = uom_model
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('999995.0'))
|
|
self.assertEqual(invoice.report_gross, Decimal('999990'))
|
|
self.assertEqual(invoice.report_weight_difference, Decimal('5.0'))
|
|
self.assertEqual(invoice.report_net_display, '999995.00')
|
|
self.assertEqual(invoice.report_gross_display, '999990.00')
|
|
self.assertEqual(invoice.report_weight_difference_display, '5.00')
|
|
self.assertEqual(
|
|
invoice.report_quantity_lines.splitlines(),
|
|
[
|
|
'-999995.0 KILOGRAM (-2204608.98 LBS)',
|
|
'999990.0 KILOGRAM (2204597.95 LBS)',
|
|
])
|
|
|
|
def test_invoice_report_weights_ignore_origin_virtual_lot(self):
|
|
'invoice report falls back to invoice quantity instead of origin virtual lot'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
virtual = Mock(id=1, lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
|
|
virtual.get_hist_quantity.return_value = (
|
|
Decimal('930'),
|
|
Decimal('0'),
|
|
)
|
|
origin = Mock(lots=[virtual])
|
|
line = Mock(
|
|
type='line',
|
|
quantity=Decimal('1000'),
|
|
lot=None,
|
|
origin=origin,
|
|
unit=Mock(rec_name='MT'),
|
|
)
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
line.invoice = invoice
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('1000'))
|
|
self.assertEqual(invoice.report_gross, Decimal('1000'))
|
|
self.assertEqual(invoice.report_weight_unit_upper, 'MT')
|
|
|
|
def test_invoice_report_weights_use_lot_linked_to_invoice_line(self):
|
|
'invoice uses the physical lot explicitly linked to the invoice line'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
virtual = Mock(id=1, lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
|
|
virtual.get_hist_quantity.return_value = (
|
|
Decimal('930'),
|
|
Decimal('940'),
|
|
)
|
|
physical = Mock(id=2, lot_type='physic', lot_unit_line=Mock(rec_name='LBS'))
|
|
physical.get_hist_quantity.return_value = (
|
|
Decimal('950'),
|
|
Decimal('980'),
|
|
)
|
|
physical.sale_invoice_line = None
|
|
line = Mock(
|
|
id=100,
|
|
type='line',
|
|
quantity=Decimal('1000'),
|
|
lot=virtual,
|
|
unit=Mock(rec_name='MT'),
|
|
)
|
|
physical.sale_invoice_line_prov = line
|
|
physical.invoice_line = None
|
|
physical.invoice_line_prov = None
|
|
origin = Mock(lots=[virtual, physical])
|
|
line.origin = origin
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
invoice.sales = [Mock(lines=[origin])]
|
|
line.invoice = invoice
|
|
|
|
self.assertEqual(invoice.report_net, Decimal('950'))
|
|
self.assertEqual(invoice.report_gross, Decimal('980'))
|
|
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
|
|
|
|
def test_invoice_report_shipment_uses_invoice_line_lot_not_first_trade_line(self):
|
|
'invoice shipment info comes from the lots linked to the invoiced line'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
shipment_a = Mock(
|
|
id=1,
|
|
bl_date='2026-04-01',
|
|
bl_number='BL-A',
|
|
vessel=Mock(vessel_name='VESSEL A'),
|
|
from_location=Mock(rec_name='LOADING A'),
|
|
to_location=Mock(rec_name='DISCHARGE A'),
|
|
controller=Mock(rec_name='CTRL A'),
|
|
number='SI-A',
|
|
)
|
|
shipment_b = Mock(
|
|
id=2,
|
|
bl_date='2026-04-05',
|
|
bl_number='BL-B',
|
|
vessel=Mock(vessel_name='VESSEL B'),
|
|
from_location=Mock(rec_name='LOADING B'),
|
|
to_location=Mock(rec_name='DISCHARGE B'),
|
|
controller=Mock(rec_name='CTRL B'),
|
|
number='SI-B',
|
|
)
|
|
lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a)
|
|
lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b)
|
|
line_a = Mock(lots=[lot_a])
|
|
line_b = Mock(lots=[lot_b])
|
|
purchase = Mock(lines=[line_a, line_b])
|
|
|
|
invoice_line = Mock(type='line', lot=lot_b, origin=line_b)
|
|
invoice = Invoice()
|
|
invoice.purchases = [purchase]
|
|
invoice.lines = [invoice_line]
|
|
|
|
self.assertEqual(invoice.report_bl_nb, 'BL-B')
|
|
self.assertEqual(invoice.report_bl_date, '2026-04-05')
|
|
self.assertEqual(invoice.report_vessel, 'VESSEL B')
|
|
self.assertEqual(invoice.report_loading_port, 'LOADING B')
|
|
self.assertEqual(invoice.report_discharge_port, 'DISCHARGE B')
|
|
self.assertEqual(invoice.report_controller_name, 'CTRL B')
|
|
self.assertEqual(invoice.report_si_number, 'SI-B')
|
|
self.assertEqual(invoice.report_si_reference, 'REF-B')
|
|
|
|
def test_shipment_in_truck_defaults_empty_cargo_mode(self):
|
|
'truck shipment clears vessel data and uses empty cargo mode'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
|
|
shipment = ShipmentIn()
|
|
shipment.transport_type = 'truck'
|
|
shipment.vessel = Mock()
|
|
shipment.cargo_mode = 'bulk'
|
|
|
|
shipment.on_change_transport_type()
|
|
|
|
self.assertIsNone(shipment.vessel)
|
|
self.assertEqual(shipment.cargo_mode, 'none')
|
|
self.assertEqual(
|
|
ShipmentIn._normalize_transport_values({
|
|
'transport_type': 'truck',
|
|
'cargo_mode': 'bulk',
|
|
'vessel': 42,
|
|
}),
|
|
{
|
|
'transport_type': 'truck',
|
|
'cargo_mode': 'none',
|
|
'vessel': None,
|
|
})
|
|
|
|
def test_shipment_in_write_ignores_empty_record_groups(self):
|
|
'shipment write normalization keeps empty internal writes as no-op'
|
|
ShipmentIn = Pool().get('stock.shipment.in')
|
|
|
|
self.assertEqual(
|
|
ShipmentIn._normalize_transport_write_args((
|
|
[],
|
|
{'cost_price': Decimal('12.34')},
|
|
)),
|
|
[])
|
|
|
|
def test_invoice_report_transportation_uses_truck_label(self):
|
|
'invoice_melya Transportation displays By Truck for truck shipments'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = Invoice()
|
|
invoice._get_report_shipment = Mock(return_value=Mock(
|
|
transport_type='truck',
|
|
supplier=Mock(rec_name='SUPPLIER'),
|
|
vessel=Mock(vessel_name='VESSEL A'),
|
|
note='ignored',
|
|
))
|
|
|
|
self.assertEqual(invoice.report_transportation, 'By Truck')
|
|
|
|
def test_invoice_report_transportation_uses_vessel_name_only(self):
|
|
'invoice_melya Transportation displays only vessel name for vessels'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
invoice = Invoice()
|
|
invoice._get_report_shipment = Mock(return_value=Mock(
|
|
transport_type='vessel',
|
|
supplier=Mock(rec_name='SUPPLIER'),
|
|
vessel=Mock(vessel_name='VESSEL A'),
|
|
note='ignored',
|
|
))
|
|
|
|
self.assertEqual(invoice.report_transportation, 'VESSEL A')
|
|
|
|
def test_invoice_report_shipment_is_blank_if_invoice_mixes_shipments(self):
|
|
'invoice shipment fields stay empty when multiple shipments are invoiced together'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
shipment_a = Mock(
|
|
id=1,
|
|
bl_date='2026-04-01',
|
|
bl_number='BL-A',
|
|
vessel=Mock(vessel_name='VESSEL A'),
|
|
from_location=Mock(rec_name='LOADING A'),
|
|
to_location=Mock(rec_name='DISCHARGE A'),
|
|
controller=Mock(rec_name='CTRL A'),
|
|
number='SI-A',
|
|
)
|
|
shipment_b = Mock(
|
|
id=2,
|
|
bl_date='2026-04-05',
|
|
bl_number='BL-B',
|
|
reference='REF-B',
|
|
vessel=Mock(vessel_name='VESSEL B'),
|
|
from_location=Mock(rec_name='LOADING B'),
|
|
to_location=Mock(rec_name='DISCHARGE B'),
|
|
controller=Mock(rec_name='CTRL B'),
|
|
number='SI-B',
|
|
)
|
|
lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a)
|
|
lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b)
|
|
line_a = Mock(type='line', lot=lot_a, origin=Mock(lots=[lot_a]))
|
|
line_b = Mock(type='line', lot=lot_b, origin=Mock(lots=[lot_b]))
|
|
invoice = Invoice()
|
|
invoice.lines = [line_a, line_b]
|
|
|
|
self.assertIsNone(invoice.report_bl_nb)
|
|
self.assertIsNone(invoice.report_bl_date)
|
|
self.assertEqual(invoice.report_vessel, None)
|
|
self.assertEqual(invoice.report_loading_port, '')
|
|
self.assertEqual(invoice.report_discharge_port, '')
|
|
self.assertEqual(invoice.report_controller_name, '')
|
|
self.assertEqual(invoice.report_si_number, '')
|
|
self.assertEqual(invoice.report_si_reference, '')
|
|
|
|
def test_invoice_report_nb_bale_sums_signed_line_lot_quantities(self):
|
|
'invoice reports packaging from the signed sum of line lot_qt values'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale'))
|
|
negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot)
|
|
positive = Mock(type='line', quantity=Decimal('1000'), lot=lot)
|
|
invoice = Invoice()
|
|
invoice.lines = [negative, positive]
|
|
|
|
self.assertEqual(invoice.report_nb_bale, 'NB BALES: 0')
|
|
|
|
def test_invoice_report_cndn_nb_bale_displays_unchanged_for_zero(self):
|
|
'CN/DN bale label displays Unchanged when the signed balance is zero'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale'))
|
|
negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot)
|
|
positive = Mock(type='line', quantity=Decimal('1000'), lot=lot)
|
|
invoice = Invoice()
|
|
invoice.lines = [negative, positive]
|
|
|
|
self.assertEqual(invoice.report_cndn_nb_bale, 'Unchanged')
|
|
|
|
def test_invoice_report_packaging_displays_quantity_before_unit(self):
|
|
'invoice packaging label displays quantity before package unit'
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
lot = Mock(lot_qt=Decimal('990'), lot_unit=Mock(symbol='BAGS25'))
|
|
line = Mock(type='line', quantity=Decimal('1000'), lot=lot)
|
|
invoice = Invoice()
|
|
invoice.lines = [line]
|
|
|
|
self.assertEqual(invoice.report_nb_bale, 'NB BAGS25: 990')
|
|
self.assertEqual(invoice.report_packaging, '990 BAGS25')
|
|
|
|
def test_invoice_report_positive_rate_lines_keep_positive_components(self):
|
|
'invoice final note pricing section keeps only positive component lines'
|
|
Invoice = Pool().get('account.invoice')
|
|
sale = Mock()
|
|
sale.report_price_lines = (
|
|
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026\n'
|
|
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MAY 2026'
|
|
)
|
|
|
|
invoice = Invoice()
|
|
invoice.sales = [sale]
|
|
invoice.lines = []
|
|
|
|
self.assertEqual(
|
|
invoice.report_positive_rate_lines.splitlines(),
|
|
[
|
|
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026',
|
|
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MAY 2026',
|
|
])
|
|
|
|
def test_lot_invoice_sale_uses_sale_invoice_line_reference(self):
|
|
'sale invoicing must resolve the generated invoice from sale invoice links'
|
|
sale_invoice = Mock()
|
|
sale_invoice_line = Mock(invoice=sale_invoice)
|
|
lot = Mock(
|
|
sale_invoice_line=sale_invoice_line,
|
|
sale_invoice_line_prov=None,
|
|
invoice_line=None,
|
|
invoice_line_prov=None,
|
|
)
|
|
|
|
invoice_line = lot.sale_invoice_line or lot.sale_invoice_line_prov
|
|
|
|
self.assertIs(invoice_line.invoice, sale_invoice)
|
|
|
|
def test_lot_invoice_sale_padding_is_split_per_lot(self):
|
|
'sale provisional padding is split equally across selected lots'
|
|
lots = [Mock(id=1), Mock(id=2)]
|
|
|
|
self.assertEqual(
|
|
lot_module.LotInvoice._split_sale_padding(Decimal('1000'), lots),
|
|
{1: Decimal('500'), 2: Decimal('500')})
|
|
|
|
def test_lot_invoice_fee_action_follows_lot_invoice_stage(self):
|
|
'fee action is standard in provisional and DN/CN in final'
|
|
not_invoiced = Mock()
|
|
not_invoiced.get_invoice.return_value = None
|
|
invoiced = Mock()
|
|
invoiced.get_invoice.return_value = Mock()
|
|
|
|
self.assertEqual(
|
|
lot_module._fee_invoice_action(not_invoiced, 'prov'),
|
|
'standard')
|
|
self.assertEqual(
|
|
lot_module._fee_invoice_action(invoiced, 'prov'),
|
|
'already_invoiced')
|
|
self.assertEqual(
|
|
lot_module._fee_invoice_action(not_invoiced, 'final'),
|
|
'standard')
|
|
self.assertEqual(
|
|
lot_module._fee_invoice_action(invoiced, 'final'),
|
|
'dn_cn')
|
|
|
|
def test_lot_invoice_only_invoices_checked_fees_for_selected_side(self):
|
|
'lot invoice invoices only checked fees from purchase or sale side'
|
|
purchase_fee = Mock(id=1)
|
|
purchase_fee.get_invoice.return_value = None
|
|
ignored_purchase_fee = Mock(id=2)
|
|
ignored_purchase_fee.get_invoice.return_value = None
|
|
sale_fee = Mock(id=3)
|
|
sale_fee.get_invoice.return_value = None
|
|
wizard = SimpleNamespace(inv=SimpleNamespace(
|
|
type='purchase', action='prov',
|
|
fee_pur=[
|
|
SimpleNamespace(fee=purchase_fee, to_invoice=True),
|
|
SimpleNamespace(fee=ignored_purchase_fee, to_invoice=False),
|
|
],
|
|
fee_sale=[SimpleNamespace(fee=sale_fee, to_invoice=True)],
|
|
))
|
|
fee_model = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = fee_model
|
|
|
|
lot_module.LotInvoice._invoice_selected_fees(wizard)
|
|
|
|
fee_model.invoice.assert_called_once_with([purchase_fee])
|
|
|
|
def test_lot_invoice_final_invoices_checked_existing_fee_as_dn_cn(self):
|
|
'final lot invoice sends an already invoiced checked fee to Fee.invoice'
|
|
fee = Mock(id=1)
|
|
fee.get_invoice.return_value = Mock()
|
|
wizard = SimpleNamespace(inv=SimpleNamespace(
|
|
type='sale', action='final', fee_pur=[],
|
|
fee_sale=[SimpleNamespace(fee=fee, to_invoice=True)],
|
|
))
|
|
fee_model = Mock()
|
|
|
|
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
|
|
PoolMock.return_value.get.return_value = fee_model
|
|
|
|
lot_module.LotInvoice._invoice_selected_fees(wizard)
|
|
|
|
fee_model.invoice.assert_called_once_with([fee])
|
|
|
|
@with_transaction()
|
|
def test_invoice_line_included_padding_reads_sale_lot_padding(self):
|
|
'invoice line exposes the sale provisional padding stored on the lot'
|
|
InvoiceLine = Pool().get('account.invoice.line')
|
|
line = InvoiceLine()
|
|
line.lot = Mock(sale_invoice_padding=Decimal('500'))
|
|
line.invoice_type = 'out'
|
|
line.description = 'Pro forma'
|
|
line.quantity = Decimal('10500')
|
|
|
|
self.assertEqual(line.get_included_padding(None), Decimal('500'))
|
|
|
|
|
|
del ModuleTestCase
|