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(''' + + + + price_index + price_date + 2026-06 + 2026-07 + 2026-08 + + + CFR Chile + 2026-05-01 + 400 + 410 + 420 + + + FOB Peru + 2026-05-01 + 390 + 400 + 410 + + + + ''') + rows = sheet.findall('.//s:sheetData/s:row', ns) + + result = ImportPrices._read_forward_price_rows(rows, [], ns) + + self.assertEqual(len(result), 6) + self.assertEqual(result[0]['price_index'], 'CFR Chile 2026-06') + self.assertEqual(result[0]['price_date'], '2026-05-01') + self.assertEqual(result[0]['price_value'], '400') + self.assertEqual(result[-1]['price_index'], 'FOB Peru 2026-08') + self.assertEqual(result[-1]['_row_number'], 3) + def test_price_value_unique_rejects_duplicate_create(self): 'price value uniqueness rejects duplicate model creation' price_value = SimpleNamespace( diff --git a/modules/purchase_trade/view/import_prices_start_form.xml b/modules/purchase_trade/view/import_prices_start_form.xml index 399f7f3..17a97e8 100644 --- a/modules/purchase_trade/view/import_prices_start_form.xml +++ b/modules/purchase_trade/view/import_prices_start_form.xml @@ -1,4 +1,7 @@
+