diff --git a/modules/__pycache__/__init__.cpython-314.pyc b/modules/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..9735663
Binary files /dev/null and b/modules/__pycache__/__init__.cpython-314.pyc differ
diff --git a/modules/purchase_trade/__pycache__/pricing.cpython-314.pyc b/modules/purchase_trade/__pycache__/pricing.cpython-314.pyc
new file mode 100644
index 0000000..89008ea
Binary files /dev/null and b/modules/purchase_trade/__pycache__/pricing.cpython-314.pyc differ
diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py
index f397d3f..ee4cdfa 100755
--- a/modules/purchase_trade/pricing.py
+++ b/modules/purchase_trade/pricing.py
@@ -69,12 +69,20 @@ class ImportPricesStart(ModelView):
"Import Prices"
__name__ = 'purchase_trade.import_prices.start'
+ file_structure = fields.Selection([
+ ('historical', "Historical Prices"),
+ ('forward', "Forward Prices"),
+ ], "Excel file structure", required=True)
file_ = fields.Binary('Excel file', required=True, filename='filename')
filename = fields.Char('Filename')
create_missing_price_index = fields.Boolean(
"Create price index if missing")
overwrite_existing_price = fields.Boolean("Overwrite existing price")
+ @staticmethod
+ def default_file_structure():
+ return 'historical'
+
class ImportPricesResult(ModelView):
"Import Prices Result"
@@ -112,7 +120,8 @@ class ImportPrices(Wizard):
}
def transition_import_(self):
- rows = self._read_xlsx(self.start.file_)
+ rows = self._read_xlsx(
+ self.start.file_, file_structure=self.start.file_structure)
stats = self._import_rows(
rows,
create_missing_price_index=(
@@ -142,7 +151,8 @@ class ImportPrices(Wizard):
'errors': [],
}
- for row_number, row in enumerate(rows, start=2):
+ for index, row in enumerate(rows, start=2):
+ row_number = row.get('_row_number', index)
price_index = (row.get('price_index') or '').strip()
try:
price_date = cls._as_date(row.get('price_date'))
@@ -341,7 +351,7 @@ class ImportPrices(Wizard):
return ', '.join(parts) or 'price imported'
@classmethod
- def _read_xlsx(cls, data):
+ def _read_xlsx(cls, data, file_structure='historical'):
try:
with zipfile.ZipFile(BytesIO(data)) as workbook:
shared_strings = cls._read_shared_strings(workbook)
@@ -355,6 +365,12 @@ class ImportPrices(Wizard):
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)
+
+ @classmethod
+ def _read_historical_price_rows(cls, rows, shared_strings, ns):
headers = {}
for cell in rows[0].findall('s:c', ns):
index = cls._cell_column_index(cell.get('r'))
@@ -381,6 +397,59 @@ class ImportPrices(Wizard):
result.append(values)
return result
+ @classmethod
+ def _read_forward_price_rows(cls, rows, shared_strings, ns):
+ headers = {}
+ month_terms = {}
+ for cell in rows[0].findall('s:c', ns):
+ index = cls._cell_column_index(cell.get('r'))
+ header = cls._cell_value(cell, shared_strings)
+ normalized = cls._normalize_header(header)
+ if index == 1 and normalized == 'priceindex':
+ headers[index] = 'price_index'
+ elif index == 2 and normalized == 'pricedate':
+ headers[index] = 'price_date'
+ elif index and index >= 3 and header not in (None, ''):
+ month_terms[index] = cls._as_month_term(header)
+
+ missing = []
+ if headers.get(1) != 'price_index':
+ missing.append('price_index')
+ if headers.get(2) != 'price_date':
+ missing.append('price_date')
+ if missing:
+ raise UserError(
+ "Missing columns in Excel file: %s" % ', '.join(missing))
+ if not month_terms:
+ raise UserError(
+ "Missing month term columns in Excel file.")
+
+ result = []
+ for excel_row_number, sheet_row in enumerate(rows[1:], start=2):
+ row_values = {}
+ for cell in sheet_row.findall('s:c', ns):
+ index = cls._cell_column_index(cell.get('r'))
+ row_values[index] = cls._cell_value(cell, shared_strings)
+
+ price_index = (row_values.get(1) or '').strip()
+ price_date = row_values.get(2)
+ if not price_index and price_date in (None, ''):
+ continue
+
+ for index, month_term in sorted(month_terms.items()):
+ price_value = row_values.get(index)
+ if price_value in (None, ''):
+ continue
+ result.append({
+ '_row_number': excel_row_number,
+ 'price_index': (
+ '%s %s' % (price_index, month_term)
+ if price_index else ''),
+ 'price_date': price_date,
+ 'price_value': price_value,
+ })
+ return result
+
@staticmethod
def _read_shared_strings(workbook):
try:
@@ -473,8 +542,20 @@ class ImportPrices(Wizard):
except ValueError:
pass
raise UserError("Invalid price_date: %s" % text)
-
-class MtmScenario(ModelSQL, ModelView):
+
+ @classmethod
+ def _as_month_term(cls, value):
+ if isinstance(value, datetime.date):
+ return value.strftime('%Y-%m')
+ text = str(value).strip()
+ match = re.match(r'^(20\d{2})[-_/\. ](0[1-9]|1[0-2])$', text)
+ if match:
+ return '%s-%s' % (match.group(1), match.group(2))
+ if re.match(r'^\d+(\.\d+)?$', text):
+ return cls._as_date(text).strftime('%Y-%m')
+ raise UserError("Invalid month term: %s" % text)
+
+class MtmScenario(ModelSQL, ModelView):
"MtM Scenario"
__name__ = 'mtm.scenario'
diff --git a/modules/purchase_trade/tests/__pycache__/test_module.cpython-314.pyc b/modules/purchase_trade/tests/__pycache__/test_module.cpython-314.pyc
new file mode 100644
index 0000000..6eb3aa3
Binary files /dev/null and b/modules/purchase_trade/tests/__pycache__/test_module.cpython-314.pyc differ
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index 7b3d946..d1ef93e 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -5,6 +5,7 @@ from decimal import Decimal
from datetime import date
from types import SimpleNamespace
from unittest.mock import Mock, patch
+from xml.etree import ElementTree
from trytond.model.exceptions import ValidationError
from trytond.pool import Pool
@@ -308,6 +309,47 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertIn('Errors:', message)
self.assertIn('Row 2 - LME Copper / 2026-03-27', message)
+ def test_import_prices_reads_forward_price_matrix(self):
+ 'import prices expands forward month columns into price rows'
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
+ sheet = ElementTree.fromstring('''
+