Import Prices (All Sources)

This commit is contained in:
AzureAD\SylvainDUVERNAY
2026-07-14 16:13:21 +02:00
parent 808e75daa6
commit fab46dde2f
2 changed files with 178 additions and 29 deletions

View File

@@ -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)

View File

@@ -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('''
<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'
@@ -5211,7 +5264,7 @@ description</t></is></c>
<row r="3">
<c r="A3"><v>46191</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>
</row>
</sheetData>
@@ -5261,10 +5314,75 @@ description</t></is></c>
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', '''
<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'
@@ -5295,6 +5413,14 @@ description</t></is></c>
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')