diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py
index e39a780..8674498 100755
--- a/modules/purchase_trade/pricing.py
+++ b/modules/purchase_trade/pricing.py
@@ -133,6 +133,9 @@ class ImportPrices(Wizard):
'openprice': 'open_price',
'pricevalue': 'price_value',
}
+ HISTORICAL_REQUIRED_COLUMNS = {
+ 'price_index', 'price_date', 'high_price', 'low_price',
+ 'open_price', 'price_value'}
PRICE_INDEX_MAPPING_COLUMNS = (
'excel_tab', 'original_price_index', 'source', 'column_destination',
'standard_price_index', 'index_description')
@@ -308,17 +311,28 @@ class ImportPrices(Wizard):
records = Pool().get(model_name).search(domain, limit=1)
return records[0] if records else None
- @classmethod
- def _price_value_values(cls, price, row, price_date):
- return {
- 'price': price.id,
- 'price_date': price_date,
- 'high_price': cls._as_float(row.get('high_price')),
- 'low_price': cls._as_float(row.get('low_price')),
- 'mid_price': cls._as_float(row.get('mid_price')),
+ @classmethod
+ def _price_value_values(cls, price, row, price_date):
+ high_price = cls._as_float(row.get('high_price'))
+ low_price = cls._as_float(row.get('low_price'))
+ mid_price = cls._as_float(row.get('mid_price'))
+ if not row.get('_preserve_mid_price'):
+ mid_price = cls._mid_price(low_price, high_price, mid_price)
+ return {
+ 'price': price.id,
+ 'price_date': price_date,
+ 'high_price': high_price,
+ 'low_price': low_price,
+ 'mid_price': mid_price,
'open_price': cls._as_float(row.get('open_price')),
'price_value': cls._as_float(row.get('price_value')),
- }
+ }
+
+ @staticmethod
+ def _mid_price(low_price, high_price, fallback=None):
+ if low_price is not None and high_price is not None:
+ return (low_price + high_price) / 2
+ return fallback
@staticmethod
def _result_line(row_number, price_index, price_date, detail):
@@ -378,7 +392,7 @@ class ImportPrices(Wizard):
parts.append('%s=%s' % (name, value))
return ', '.join(parts) or 'price imported'
- @classmethod
+ @classmethod
def _read_xlsx(cls, data, file_structure='historical'):
try:
with zipfile.ZipFile(BytesIO(data)) as workbook:
@@ -389,21 +403,25 @@ class ImportPrices(Wizard):
sheet_name = cls._first_sheet_name(workbook)
sheet = ElementTree.fromstring(workbook.read(sheet_name))
except (KeyError, zipfile.BadZipFile, ElementTree.ParseError):
- raise UserError("The selected file is not a valid Excel .xlsx file.")
-
- ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
- rows = sheet.findall('.//s:sheetData/s:row', ns)
+ raise UserError("The selected file is not a valid Excel .xlsx file.")
+
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
+ rows = sheet.findall('.//s:sheetData/s:row', ns)
if not rows:
return []
-
+
if file_structure == 'forward':
return cls._read_forward_price_rows(rows, shared_strings, ns)
- return cls._read_historical_price_rows(rows, shared_strings, ns)
+ if file_structure == 'historical':
+ return cls._read_historical_price_rows(rows, shared_strings, ns)
+ raise UserError("Unknown Excel file structure: %s" % file_structure)
@classmethod
- def _read_historical_all_sources_price_rows(cls, workbook, shared_strings):
+ def _read_historical_all_sources_price_rows(
+ cls, workbook, shared_strings, sheet_rows=None):
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
- sheet_rows = cls._workbook_sheet_rows(workbook, ns)
+ if sheet_rows is None:
+ sheet_rows = cls._workbook_sheet_rows(workbook, ns)
mapping = cls._price_index_mapping()
return cls._read_historical_all_sources_rows(
sheet_rows, mapping, shared_strings, ns)
@@ -450,15 +468,18 @@ class ImportPrices(Wizard):
key = (price_index, price_date, excel_row_number)
result = result_by_key.setdefault(key, {
'_row_number': excel_row_number,
+ '_preserve_mid_price': True,
'_source': mapping_row.get('source'),
'_price_desc': mapping_row.get('index_description'),
'price_index': price_index,
'price_date': price_date,
})
result[mapping_row['column_destination']] = price_value
- if (mapping_row['column_destination'] == 'mid_price'
- and not result.get('price_value')):
- result['price_value'] = price_value
+ if mapping_row['column_destination'] == 'mid_price':
+ if not result.get('price_value'):
+ result['price_value'] = price_value
+ if not result.get('open_price'):
+ result['open_price'] = price_value
return list(result_by_key.values())
@classmethod
@@ -514,7 +535,9 @@ class ImportPrices(Wizard):
return values
@classmethod
- def _workbook_sheet_rows(cls, workbook, ns):
+ def _workbook_sheet_rows(cls, workbook, ns=None):
+ ns = ns or {
+ 's': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheet_rows = {}
for sheet_name, sheet_path in cls._worksheet_paths(workbook):
sheet = ElementTree.fromstring(workbook.read(sheet_path))
@@ -567,7 +590,7 @@ class ImportPrices(Wizard):
if normalized in cls.REQUIRED_COLUMNS:
headers[index] = cls.REQUIRED_COLUMNS[normalized]
- missing = set(cls.REQUIRED_COLUMNS.values()) - set(headers.values())
+ missing = cls.HISTORICAL_REQUIRED_COLUMNS - set(headers.values())
if missing:
raise UserError(
"Missing columns in Excel file: %s"
@@ -732,10 +755,10 @@ class ImportPrices(Wizard):
return (
datetime.date(1899, 12, 30)
+ datetime.timedelta(days=int(float(text))))
- for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y'):
- try:
- return datetime.datetime.strptime(text, fmt).date()
- except ValueError:
+ for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y', '%d.%m.%Y'):
+ try:
+ return datetime.datetime.strptime(text, fmt).date()
+ except ValueError:
pass
raise UserError("Invalid price_date: %s" % text)
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index d070683..38f8685 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -2,9 +2,11 @@
# 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
@@ -5232,6 +5234,57 @@ class PurchaseTradeTestCase(ModuleTestCase):
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('''
+
+
+
+ Price Index
+ Price Date
+ High Price
+ Low Price
+ Open Price
+ Price Value
+
+
+ ARGUS-CFR Brasil
+ 46191
+ 482
+ 460
+ 470
+ 470
+
+
+
+ ''')
+ 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'
@@ -5253,7 +5306,7 @@ description
46191
470
- 480
+ 482
460
@@ -5303,10 +5356,75 @@ description
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]['high_price'], '480')
+ 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', '''
+
+
+
+
+
+ ''')
+ archive.writestr('xl/_rels/workbook.xml.rels', '''
+
+
+
+ ''')
+ archive.writestr('xl/worksheets/sheet1.xml', '''
+
+
+
+ Price Index
+ Price Date
+
+
+ ARGUS-CFR Brasil
+ 46191
+
+
+
+ ''')
+ 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'
@@ -5337,6 +5455,14 @@ description
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')