This commit is contained in:
2026-07-14 16:51:18 +02:00
2 changed files with 178 additions and 29 deletions

View File

@@ -133,6 +133,9 @@ class ImportPrices(Wizard):
'openprice': 'open_price', 'openprice': 'open_price',
'pricevalue': 'price_value', 'pricevalue': 'price_value',
} }
HISTORICAL_REQUIRED_COLUMNS = {
'price_index', 'price_date', 'high_price', 'low_price',
'open_price', 'price_value'}
PRICE_INDEX_MAPPING_COLUMNS = ( PRICE_INDEX_MAPPING_COLUMNS = (
'excel_tab', 'original_price_index', 'source', 'column_destination', 'excel_tab', 'original_price_index', 'source', 'column_destination',
'standard_price_index', 'index_description') 'standard_price_index', 'index_description')
@@ -308,17 +311,28 @@ class ImportPrices(Wizard):
records = Pool().get(model_name).search(domain, limit=1) records = Pool().get(model_name).search(domain, limit=1)
return records[0] if records else None return records[0] if records else None
@classmethod @classmethod
def _price_value_values(cls, price, row, price_date): def _price_value_values(cls, price, row, price_date):
return { high_price = cls._as_float(row.get('high_price'))
'price': price.id, low_price = cls._as_float(row.get('low_price'))
'price_date': price_date, mid_price = cls._as_float(row.get('mid_price'))
'high_price': cls._as_float(row.get('high_price')), if not row.get('_preserve_mid_price'):
'low_price': cls._as_float(row.get('low_price')), mid_price = cls._mid_price(low_price, high_price, mid_price)
'mid_price': cls._as_float(row.get('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')), 'open_price': cls._as_float(row.get('open_price')),
'price_value': cls._as_float(row.get('price_value')), '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 @staticmethod
def _result_line(row_number, price_index, price_date, detail): def _result_line(row_number, price_index, price_date, detail):
@@ -378,7 +392,7 @@ class ImportPrices(Wizard):
parts.append('%s=%s' % (name, value)) parts.append('%s=%s' % (name, value))
return ', '.join(parts) or 'price imported' return ', '.join(parts) or 'price imported'
@classmethod @classmethod
def _read_xlsx(cls, data, file_structure='historical'): def _read_xlsx(cls, data, file_structure='historical'):
try: try:
with zipfile.ZipFile(BytesIO(data)) as workbook: with zipfile.ZipFile(BytesIO(data)) as workbook:
@@ -389,21 +403,25 @@ class ImportPrices(Wizard):
sheet_name = cls._first_sheet_name(workbook) sheet_name = cls._first_sheet_name(workbook)
sheet = ElementTree.fromstring(workbook.read(sheet_name)) sheet = ElementTree.fromstring(workbook.read(sheet_name))
except (KeyError, zipfile.BadZipFile, ElementTree.ParseError): except (KeyError, zipfile.BadZipFile, ElementTree.ParseError):
raise UserError("The selected file is not a valid Excel .xlsx file.") raise UserError("The selected file is not a valid Excel .xlsx file.")
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
rows = sheet.findall('.//s:sheetData/s:row', ns) rows = sheet.findall('.//s:sheetData/s:row', ns)
if not rows: if not rows:
return [] return []
if file_structure == 'forward': if file_structure == 'forward':
return cls._read_forward_price_rows(rows, shared_strings, ns) 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 @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'} 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() mapping = cls._price_index_mapping()
return cls._read_historical_all_sources_rows( return cls._read_historical_all_sources_rows(
sheet_rows, mapping, shared_strings, ns) sheet_rows, mapping, shared_strings, ns)
@@ -450,15 +468,18 @@ class ImportPrices(Wizard):
key = (price_index, price_date, excel_row_number) key = (price_index, price_date, excel_row_number)
result = result_by_key.setdefault(key, { result = result_by_key.setdefault(key, {
'_row_number': excel_row_number, '_row_number': excel_row_number,
'_preserve_mid_price': True,
'_source': mapping_row.get('source'), '_source': mapping_row.get('source'),
'_price_desc': mapping_row.get('index_description'), '_price_desc': mapping_row.get('index_description'),
'price_index': price_index, 'price_index': price_index,
'price_date': price_date, 'price_date': price_date,
}) })
result[mapping_row['column_destination']] = price_value result[mapping_row['column_destination']] = price_value
if (mapping_row['column_destination'] == 'mid_price' if mapping_row['column_destination'] == 'mid_price':
and not result.get('price_value')): if not result.get('price_value'):
result['price_value'] = price_value result['price_value'] = price_value
if not result.get('open_price'):
result['open_price'] = price_value
return list(result_by_key.values()) return list(result_by_key.values())
@classmethod @classmethod
@@ -514,7 +535,9 @@ class ImportPrices(Wizard):
return values return values
@classmethod @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 = {} sheet_rows = {}
for sheet_name, sheet_path in cls._worksheet_paths(workbook): for sheet_name, sheet_path in cls._worksheet_paths(workbook):
sheet = ElementTree.fromstring(workbook.read(sheet_path)) sheet = ElementTree.fromstring(workbook.read(sheet_path))
@@ -567,7 +590,7 @@ class ImportPrices(Wizard):
if normalized in cls.REQUIRED_COLUMNS: if normalized in cls.REQUIRED_COLUMNS:
headers[index] = cls.REQUIRED_COLUMNS[normalized] 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: if missing:
raise UserError( raise UserError(
"Missing columns in Excel file: %s" "Missing columns in Excel file: %s"
@@ -732,10 +755,10 @@ class ImportPrices(Wizard):
return ( return (
datetime.date(1899, 12, 30) datetime.date(1899, 12, 30)
+ datetime.timedelta(days=int(float(text)))) + datetime.timedelta(days=int(float(text))))
for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y'): for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y', '%d.%m.%Y'):
try: try:
return datetime.datetime.strptime(text, fmt).date() return datetime.datetime.strptime(text, fmt).date()
except ValueError: except ValueError:
pass pass
raise UserError("Invalid price_date: %s" % text) raise UserError("Invalid price_date: %s" % text)

View File

@@ -2,9 +2,11 @@
# this repository contains the full copyright notices and license terms. # this repository contains the full copyright notices and license terms.
import datetime import datetime
import zipfile
from pathlib import Path from pathlib import Path
from contextlib import nullcontext from contextlib import nullcontext
from decimal import Decimal from decimal import Decimal
from io import BytesIO
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import ANY, Mock, call, patch from unittest.mock import ANY, Mock, call, patch
from xml.etree import ElementTree from xml.etree import ElementTree
@@ -5232,6 +5234,57 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(result[0]['mid_price'], '75.25') self.assertEqual(result[0]['mid_price'], '75.25')
self.assertEqual(result[0]['high_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() @with_transaction()
def test_import_historical_all_sources_uses_mapping_table(self): def test_import_historical_all_sources_uses_mapping_table(self):
'all sources import maps source columns to standard price values' 'all sources import maps source columns to standard price values'
@@ -5253,7 +5306,7 @@ description</t></is></c>
<row r="3"> <row r="3">
<c r="A3"><v>46191</v></c> <c r="A3"><v>46191</v></c>
<c r="B3"><v>470</v></c> <c r="B3"><v>470</v></c>
<c r="C3"><v>480</v></c> <c r="C3"><v>482</v></c>
<c r="D3"><v>460</v></c> <c r="D3"><v>460</v></c>
</row> </row>
</sheetData> </sheetData>
@@ -5303,10 +5356,75 @@ description</t></is></c>
self.assertEqual(result[0]['price_date'], '46191') self.assertEqual(result[0]['price_date'], '46191')
self.assertEqual(result[0]['mid_price'], '470') self.assertEqual(result[0]['mid_price'], '470')
self.assertEqual(result[0]['price_value'], '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]['low_price'], '460')
self.assertEqual(result[0]['_source'], 'ARGUS') self.assertEqual(result[0]['_source'], 'ARGUS')
self.assertEqual(result[0]['_price_desc'], 'Brazil CFR') 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): def test_import_historical_all_sources_requires_exact_tab_match(self):
'all sources import ignores similarly named source sheets' 'all sources import ignores similarly named source sheets'
@@ -5337,6 +5455,14 @@ description</t></is></c>
self.assertEqual(values['price_desc'], 'Brazil CFR') self.assertEqual(values['price_desc'], 'Brazil CFR')
self.assertEqual(values['price_area'], 8) 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): def test_pricing_component_matrix_returns_generic_line_price(self):
'matrix pricing can use an unconditional matrix line as a component price' 'matrix pricing can use an unconditional matrix line as a component price'
Component = Pool().get('pricing.component') Component = Pool().get('pricing.component')