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 c80c34a..8bf2beb 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 @@ -5190,6 +5192,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' @@ -5211,7 +5264,7 @@ description 46191 470 - 480 + 482 460 @@ -5261,10 +5314,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' @@ -5295,6 +5413,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')