Merge branch 'main' of https://gitea.open-squared.tech/admin/tradon
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user