New file import format (ARGUS +ICIS)

This commit is contained in:
AzureAD\SylvainDUVERNAY
2026-07-13 21:35:01 +02:00
parent febe29c6b4
commit 808e75daa6
3 changed files with 328 additions and 48 deletions

View File

@@ -85,6 +85,7 @@ class ImportPricesStart(ModelView):
file_structure = fields.Selection([
('historical', "Historical Prices"),
('historical_all_sources', "Historical Prices (All Sources)"),
('forward', "Forward Prices"),
], "Excel file structure", required=True)
file_ = fields.Binary('Excel file', required=True, filename='filename')
@@ -132,6 +133,11 @@ class ImportPrices(Wizard):
'openprice': 'open_price',
'pricevalue': 'price_value',
}
PRICE_INDEX_MAPPING_COLUMNS = (
'excel_tab', 'original_price_index', 'source', 'column_destination',
'standard_price_index', 'index_description')
ALL_SOURCES_VALUE_COLUMNS = {
'price_value', 'open_price', 'low_price', 'mid_price', 'high_price'}
def transition_import_(self):
rows = self._read_xlsx(
@@ -188,7 +194,7 @@ class ImportPrices(Wizard):
price = prices[0]
elif create_missing_price_index:
price, = Price.create([
cls._price_index_values(price_index)])
cls._price_index_values(price_index, row)])
stats['created_indexes'].append(
cls._result_line(
row_number, price_index, None,
@@ -234,10 +240,11 @@ class ImportPrices(Wizard):
return stats
@classmethod
def _price_index_values(cls, price_index):
def _price_index_values(cls, price_index, row=None):
row = row or {}
values = {
'price_index': price_index,
'price_desc': price_index,
'price_desc': row.get('_price_desc') or price_index,
'price_curve_type': 'future',
}
references = [
@@ -246,6 +253,10 @@ class ImportPrices(Wizard):
('price_calendar', 'price.calendar', [('name', '=', 'Argus EU')]),
('price_unit', 'product.uom', [('name', '=', 'Mt')]),
]
source = row.get('_source')
if source:
references.append(
('price_area', 'price.area', [('name', '=', source)]))
for field, model_name, domain in references:
record = cls._first_record(model_name, domain)
if record:
@@ -372,6 +383,9 @@ class ImportPrices(Wizard):
try:
with zipfile.ZipFile(BytesIO(data)) as workbook:
shared_strings = cls._read_shared_strings(workbook)
if file_structure == 'historical_all_sources':
return cls._read_historical_all_sources_price_rows(
workbook, shared_strings)
sheet_name = cls._first_sheet_name(workbook)
sheet = ElementTree.fromstring(workbook.read(sheet_name))
except (KeyError, zipfile.BadZipFile, ElementTree.ParseError):
@@ -386,6 +400,163 @@ class ImportPrices(Wizard):
return cls._read_forward_price_rows(rows, shared_strings, ns)
return cls._read_historical_price_rows(rows, shared_strings, ns)
@classmethod
def _read_historical_all_sources_price_rows(cls, workbook, shared_strings):
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
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)
@classmethod
def _read_historical_all_sources_rows(
cls, sheet_rows, mapping, shared_strings, ns):
result_by_key = {}
mapping_by_tab = {}
for mapping_row in mapping:
tab = mapping_row.get('excel_tab')
destination = mapping_row.get('column_destination')
if not tab or destination not in cls.ALL_SOURCES_VALUE_COLUMNS:
continue
mapping_by_tab.setdefault(tab, []).append(mapping_row)
for tab, tab_mapping in mapping_by_tab.items():
sheet_name = cls._matching_sheet_name(sheet_rows, tab)
if not sheet_name:
continue
rows = sheet_rows[sheet_name]
header_row_number, mapped_columns = cls._mapped_columns(
rows, tab_mapping, shared_strings, ns)
if not mapped_columns:
continue
date_column = cls._date_column(
rows[header_row_number - 1], mapped_columns, shared_strings,
ns)
for excel_row_number, sheet_row in enumerate(
rows[header_row_number:], start=header_row_number + 1):
row_values = cls._row_values(sheet_row, shared_strings, ns)
price_date = row_values.get(date_column)
if price_date in (None, ''):
continue
for column, mapping_row in mapped_columns.items():
price_value = row_values.get(column)
if price_value in (None, ''):
continue
price_index = (
mapping_row.get('standard_price_index') or '').strip()
if not price_index:
continue
key = (price_index, price_date, excel_row_number)
result = result_by_key.setdefault(key, {
'_row_number': excel_row_number,
'_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
return list(result_by_key.values())
@classmethod
def _price_index_mapping(cls):
cursor = Transaction().connection.cursor()
columns = ', '.join(cls.PRICE_INDEX_MAPPING_COLUMNS)
cursor.execute(
'SELECT %s FROM public."ext_H2SO4_PriceIndexMapping"'
% columns)
return [
dict(zip(cls.PRICE_INDEX_MAPPING_COLUMNS, row))
for row in cursor.fetchall()]
@classmethod
def _mapped_columns(cls, rows, mapping, shared_strings, ns):
mapping_by_header = {}
for mapping_row in mapping:
header = cls._normalize_mapping_value(
mapping_row.get('original_price_index'))
mapping_by_header[header] = mapping_row
hits_by_row = {}
columns_by_row = {}
for row_number, sheet_row in enumerate(rows, start=1):
for cell in sheet_row.findall('s:c', ns):
header = cls._normalize_mapping_value(
cls._cell_value(cell, shared_strings))
mapping_row = mapping_by_header.get(header)
if not mapping_row:
continue
column = cls._cell_column_index(cell.get('r'))
hits_by_row[row_number] = hits_by_row.get(row_number, 0) + 1
columns_by_row.setdefault(row_number, {})[column] = mapping_row
if not hits_by_row:
return None, {}
header_row_number = max(hits_by_row, key=hits_by_row.get)
return header_row_number, columns_by_row[header_row_number]
@classmethod
def _date_column(cls, header_row, mapped_columns, shared_strings, ns):
for cell in header_row.findall('s:c', ns):
if cls._normalize_header(
cls._cell_value(cell, shared_strings)) == 'date':
return cls._cell_column_index(cell.get('r'))
return min(mapped_columns) - 1
@classmethod
def _row_values(cls, sheet_row, shared_strings, ns):
values = {}
for cell in sheet_row.findall('s:c', ns):
values[cls._cell_column_index(cell.get('r'))] = cls._cell_value(
cell, shared_strings)
return values
@classmethod
def _workbook_sheet_rows(cls, workbook, ns):
sheet_rows = {}
for sheet_name, sheet_path in cls._worksheet_paths(workbook):
sheet = ElementTree.fromstring(workbook.read(sheet_path))
sheet_rows[sheet_name] = sheet.findall('.//s:sheetData/s:row', ns)
return sheet_rows
@classmethod
def _worksheet_paths(cls, workbook):
workbook_xml = ElementTree.fromstring(workbook.read('xl/workbook.xml'))
rels_xml = ElementTree.fromstring(
workbook.read('xl/_rels/workbook.xml.rels'))
wb_ns = {
's': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
'r': (
'http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships'),
}
rel_ns = {
'r': (
'http://schemas.openxmlformats.org/package/2006/'
'relationships'),
}
rels = {
rel.get('Id'): rel.get('Target')
for rel in rels_xml.findall('r:Relationship', rel_ns)}
for sheet in workbook_xml.findall('.//s:sheet', wb_ns):
rel_id = sheet.get(
'{http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships}id')
target = rels[rel_id]
if not target.startswith('/'):
target = 'xl/' + target
yield sheet.get('name'), target.lstrip('/')
@classmethod
def _matching_sheet_name(cls, sheet_rows, mapping_tab):
normalized_tab = cls._normalize_mapping_value(mapping_tab)
for sheet_name in sheet_rows:
if cls._normalize_mapping_value(sheet_name) == normalized_tab:
return sheet_name
return None
@classmethod
def _read_historical_price_rows(cls, rows, shared_strings, ns):
headers = {}
@@ -540,6 +711,10 @@ class ImportPrices(Wizard):
def _normalize_header(value):
return re.sub(r'[^a-z0-9]', '', (value or '').strip().lower())
@staticmethod
def _normalize_mapping_value(value):
return re.sub(r'\s+', ' ', (value or '').strip()).lower()
@staticmethod
def _as_float(value):
if value in (None, ''):

View File

@@ -5190,6 +5190,111 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(result[0]['mid_price'], '75.25')
self.assertEqual(result[0]['high_price'], '75.25')
@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>480</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]['high_price'], '480')
self.assertEqual(result[0]['low_price'], '460')
self.assertEqual(result[0]['_source'], 'ARGUS')
self.assertEqual(result[0]['_price_desc'], 'Brazil CFR')
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_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')