From f56f394ec339799b11fd974862764a15ab92cbee Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 21 May 2026 19:22:39 +0200 Subject: [PATCH] Import price --- modules/purchase_trade/pricing.py | 1027 ++++++++++++----- modules/purchase_trade/pricing.xml | 22 + .../view/import_prices_result_form.xml | 3 + .../view/import_prices_start_form.xml | 10 + 4 files changed, 755 insertions(+), 307 deletions(-) create mode 100644 modules/purchase_trade/view/import_prices_result_form.xml create mode 100644 modules/purchase_trade/view/import_prices_start_form.xml diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py index 7393827..adf1fa5 100755 --- a/modules/purchase_trade/pricing.py +++ b/modules/purchase_trade/pricing.py @@ -2,9 +2,9 @@ # this repository contains the full copyright notices and license terms. from trytond.model import fields from trytond.pool import Pool, PoolMeta -from trytond.pyson import Bool, Eval, Id -from trytond.model import (ModelSQL, ModelView) -from trytond.exceptions import UserError +from trytond.pyson import Bool, Eval, Id +from trytond.model import (ModelSQL, ModelView) +from trytond.exceptions import UserError from trytond.tools import is_full_text, lstrip_wildcard from trytond.transaction import Transaction, inactive_records from decimal import getcontext, Decimal, ROUND_HALF_UP @@ -15,8 +15,13 @@ from sql.functions import CurrentTimestamp, DateTrunc from trytond.wizard import Button, StateTransition, StateView, Wizard from itertools import chain, groupby from operator import itemgetter +import calendar import datetime import logging +import re +import zipfile +from io import BytesIO +from xml.etree import ElementTree from trytond.modules.purchase_trade.purchase import (TRIGGERS) logger = logging.getLogger(__name__) @@ -59,6 +64,414 @@ class Estimated(ModelSQL, ModelView): estimated_date = fields.Date("Estimated date") fin_int_delta = fields.Integer("Financing interests delta") +class ImportPricesStart(ModelView): + "Import Prices" + __name__ = 'purchase_trade.import_prices.start' + + 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") + + +class ImportPricesResult(ModelView): + "Import Prices Result" + __name__ = 'purchase_trade.import_prices.result' + + message = fields.Text("Results", readonly=True) + + +class ImportPrices(Wizard): + "Import Prices" + __name__ = 'purchase_trade.import_prices' + + start = StateView( + 'purchase_trade.import_prices.start', + 'purchase_trade.import_prices_start_view_form', + [ + Button('Cancel', 'end', 'tryton-cancel'), + Button('Import', 'import_', 'tryton-ok', default=True), + ]) + import_ = StateTransition() + result = StateView( + 'purchase_trade.import_prices.result', + 'purchase_trade.import_prices_result_view_form', + [ + Button('OK', 'end', 'tryton-ok', default=True), + ]) + + REQUIRED_COLUMNS = { + 'priceindex': 'price_index', + 'pricedate': 'price_date', + 'highprice': 'high_price', + 'lowprice': 'low_price', + 'openprice': 'open_price', + 'pricevalue': 'price_value', + } + + def transition_import_(self): + rows = self._read_xlsx(self.start.file_) + stats = self._import_rows( + rows, + create_missing_price_index=( + self.start.create_missing_price_index), + overwrite_existing_price=self.start.overwrite_existing_price) + self._result_message = self._format_result(stats) + return 'result' + + def default_result(self, fields): + return { + 'message': getattr( + self, '_result_message', + 'No import result was produced.'), + } + + @classmethod + def _import_rows( + cls, rows, create_missing_price_index=False, + overwrite_existing_price=False): + Price = Pool().get('price.price') + PriceValue = Pool().get('price.price_value') + stats = { + 'created_indexes': [], + 'imported': [], + 'updated': [], + 'skipped': [], + 'errors': [], + } + + for row_number, row in enumerate(rows, start=2): + price_index = (row.get('price_index') or '').strip() + try: + price_date = cls._as_date(row.get('price_date')) + if not price_index: + stats['skipped'].append( + cls._result_line( + row_number, '', None, 'missing price_index')) + continue + if not price_date: + stats['skipped'].append( + cls._result_line( + row_number, price_index, None, + 'missing price_date')) + continue + + prices = Price.search( + [('price_index', '=', price_index)], limit=1) + if prices: + price = prices[0] + elif create_missing_price_index: + price, = Price.create([ + cls._price_index_values(price_index)]) + stats['created_indexes'].append( + cls._result_line( + row_number, price_index, None, + 'price index created')) + else: + stats['skipped'].append( + cls._result_line( + row_number, price_index, None, + 'price_index missing')) + continue + + values = cls._price_value_values(price, row, price_date) + existing = PriceValue.search([ + ('price', '=', price.id), + ('price_date', '=', price_date), + ], limit=1) + + if existing: + if overwrite_existing_price: + PriceValue.write(existing, values) + stats['updated'].append( + cls._result_line( + row_number, price_index, price_date, + cls._price_summary(values))) + else: + stats['skipped'].append(cls._result_line( + row_number, price_index, price_date, + 'price_date already exists')) + continue + + PriceValue.create([values]) + stats['imported'].append( + cls._result_line( + row_number, price_index, price_date, + cls._price_summary(values))) + except Exception as exception: + stats['errors'].append( + cls._result_line( + row_number, price_index, row.get('price_date'), + str(exception))) + continue + + return stats + + @classmethod + def _price_index_values(cls, price_index): + values = { + 'price_index': price_index, + 'price_desc': price_index, + 'price_curve_type': 'future', + } + references = [ + ('price_type', 'price.fixtype', [('name', '=', 'Market price')]), + ('price_currency', 'currency.currency', [('code', '=', 'USD')]), + ('price_calendar', 'price.calendar', [('name', '=', 'Argus EU')]), + ('price_unit', 'product.uom', [('name', '=', 'Mt')]), + ] + for field, model_name, domain in references: + record = cls._first_record(model_name, domain) + if record: + values[field] = record.id + + period = cls._period_from_price_index(price_index) + if period: + values['price_period'] = period.id + return values + + @classmethod + def _period_from_price_index(cls, price_index): + match = re.search(r'(?' + price_date = item['price_date'] + if isinstance(price_date, datetime.date): + price_date = price_date.isoformat() + if price_date: + label = '%s / %s' % (label, price_date) + return 'Row %(row)s - ' % item + '%s: %s' % (label, item['detail']) + + @staticmethod + def _price_summary(values): + parts = [] + for name in ('price_value', 'open_price', 'low_price', 'high_price'): + value = values.get(name) + if value is not None: + parts.append('%s=%s' % (name, value)) + return ', '.join(parts) or 'price imported' + + @classmethod + def _read_xlsx(cls, data): + try: + with zipfile.ZipFile(BytesIO(data)) as workbook: + shared_strings = cls._read_shared_strings(workbook) + 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) + if not rows: + return [] + + headers = {} + 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 normalized in cls.REQUIRED_COLUMNS: + headers[index] = cls.REQUIRED_COLUMNS[normalized] + + missing = set(cls.REQUIRED_COLUMNS.values()) - set(headers.values()) + if missing: + raise UserError( + "Missing columns in Excel file: %s" + % ', '.join(sorted(missing))) + + result = [] + for sheet_row in rows[1:]: + values = {} + for cell in sheet_row.findall('s:c', ns): + index = cls._cell_column_index(cell.get('r')) + field = headers.get(index) + if field: + values[field] = cls._cell_value(cell, shared_strings) + if any(v not in (None, '') for v in values.values()): + result.append(values) + return result + + @staticmethod + def _read_shared_strings(workbook): + try: + content = workbook.read('xl/sharedStrings.xml') + except KeyError: + return [] + root = ElementTree.fromstring(content) + ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} + strings = [] + for item in root.findall('s:si', ns): + strings.append(''.join( + text.text or '' for text in item.findall('.//s:t', ns))) + return strings + + @staticmethod + def _first_sheet_name(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'), + } + sheet = workbook_xml.find('.//s:sheet', wb_ns) + rel_id = sheet.get( + '{http://schemas.openxmlformats.org/officeDocument/2006/' + 'relationships}id') + for rel in rels_xml.findall('r:Relationship', rel_ns): + if rel.get('Id') == rel_id: + target = rel.get('Target') + if not target.startswith('/'): + target = 'xl/' + target + return target.lstrip('/') + raise KeyError('No worksheet found') + + @staticmethod + def _cell_column_index(reference): + match = re.match(r'([A-Z]+)', reference or '') + if not match: + return None + index = 0 + for char in match.group(1): + index = index * 26 + ord(char) - ord('A') + 1 + return index + + @classmethod + def _cell_value(cls, cell, shared_strings): + ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} + if cell.get('t') == 'inlineStr': + text = cell.find('.//s:t', ns) + return text.text if text is not None else '' + value = cell.find('s:v', ns) + if value is None: + return '' + if cell.get('t') == 's': + return shared_strings[int(value.text)] + return value.text + + @staticmethod + def _normalize_header(value): + return re.sub(r'[^a-z0-9]', '', (value or '').strip().lower()) + + @staticmethod + def _as_float(value): + if value in (None, ''): + return None + return float(str(value).replace(',', '.')) + + @staticmethod + def _as_date(value): + if isinstance(value, datetime.date): + return value + if value in (None, ''): + return None + text = str(value).strip() + if re.match(r'^\d+(\.\d+)?$', text): + 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: + pass + raise UserError("Invalid price_date: %s" % text) class MtmScenario(ModelSQL, ModelView): "MtM Scenario" __name__ = 'mtm.scenario' @@ -114,11 +527,11 @@ class MtmStrategy(ModelSQL, ModelView): ) ) - elif comp.price_source_type == 'matrix' and comp.price_matrix: - value = self._get_matrix_price(comp, line, dt) - - if comp.ratio: - value *= Decimal(comp.ratio) / Decimal(100) + elif comp.price_source_type == 'matrix' and comp.price_matrix: + value = self._get_matrix_price(comp, line, dt) + + if comp.ratio: + value *= Decimal(comp.ratio) / Decimal(100) total += value * qty @@ -326,26 +739,26 @@ class Component(ModelSQL, ModelView): super(Component, cls).delete(components) -class Pricing(ModelSQL,ModelView): - "Pricing" - __name__ = 'pricing.pricing' - - pricing_date = fields.Date("Date") - price_component = fields.Many2One( - 'pricing.component', "Component", - domain=[('id', 'in', Eval('available_components', []))], - depends=['available_components'])#, ondelete='CASCADE') - available_components = fields.Function( - fields.One2Many('pricing.component', '', "Available Components"), - 'on_change_with_available_components') - quantity = fields.Numeric("Qt",digits='unit') - settl_price = fields.Numeric("Settl. price",digits='unit') - fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True) - fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit', readonly=True) - unfixed_qt = fields.Numeric("Unfixed qt",digits='unit', readonly=True) - unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit', readonly=True) - eod_price = fields.Numeric("EOD price",digits='unit',readonly=True) - last = fields.Boolean("Last") +class Pricing(ModelSQL,ModelView): + "Pricing" + __name__ = 'pricing.pricing' + + pricing_date = fields.Date("Date") + price_component = fields.Many2One( + 'pricing.component', "Component", + domain=[('id', 'in', Eval('available_components', []))], + depends=['available_components'])#, ondelete='CASCADE') + available_components = fields.Function( + fields.One2Many('pricing.component', '', "Available Components"), + 'on_change_with_available_components') + quantity = fields.Numeric("Qt",digits='unit') + settl_price = fields.Numeric("Settl. price",digits='unit') + fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True) + fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit', readonly=True) + unfixed_qt = fields.Numeric("Unfixed qt",digits='unit', readonly=True) + unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit', readonly=True) + eod_price = fields.Numeric("EOD price",digits='unit',readonly=True) + last = fields.Boolean("Last") @classmethod def default_fixed_qt(cls): @@ -367,285 +780,285 @@ class Pricing(ModelSQL,ModelView): def default_quantity(cls): return Decimal(0) - @classmethod - def default_settl_price(cls): - return Decimal(0) - - @classmethod - def default_eod_price(cls): - return Decimal(0) - - @fields.depends('line', 'sale_line') - def on_change_with_available_components(self, name=None): - Component = Pool().get('pricing.component') - line = getattr(self, 'line', None) - if line: - return Component.search([('line', '=', line)]) - sale_line = getattr(self, 'sale_line', None) - if sale_line: - return Component.search([('sale_line', '=', sale_line)]) - return [] - - @staticmethod - def _weighted_average_price(fixed_qt, fixed_price, unfixed_qt, unfixed_price): - fixed_qt = Decimal(str(fixed_qt or 0)) - fixed_price = Decimal(str(fixed_price or 0)) - unfixed_qt = Decimal(str(unfixed_qt or 0)) - unfixed_price = Decimal(str(unfixed_price or 0)) - total_qty = fixed_qt + unfixed_qt - if total_qty == 0: - return Decimal(0) - return round( - ((fixed_qt * fixed_price) + (unfixed_qt * unfixed_price)) / total_qty, - 4, - ) - - def compute_eod_price(self): - if getattr(self, 'sale_line', None) and hasattr(self, 'get_eod_price_sale'): - return self.get_eod_price_sale() - if getattr(self, 'line', None) and hasattr(self, 'get_eod_price_purchase'): - return self.get_eod_price_purchase() - return self._weighted_average_price( - self.fixed_qt, - self.fixed_qt_price, - self.unfixed_qt, - self.unfixed_qt_price, - ) - - @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') - def on_change_fixed_qt(self): - self.eod_price = self.compute_eod_price() - - @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') - def on_change_fixed_qt_price(self): - self.eod_price = self.compute_eod_price() - - @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') - def on_change_unfixed_qt(self): - self.eod_price = self.compute_eod_price() - - @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') - def on_change_unfixed_qt_price(self): - self.eod_price = self.compute_eod_price() - - @classmethod - def create(cls, vlist): - records = super(Pricing, cls).create(vlist) - cls._sync_manual_values(records) - cls._sync_manual_last(records) - cls._sync_eod_price(records) - return records - - @classmethod - def write(cls, *args): - super(Pricing, cls).write(*args) - if (Transaction().context.get('skip_pricing_eod_sync') - or Transaction().context.get('skip_pricing_last_sync')): - return - records = [] - actions = iter(args) - for record_set, values in zip(actions, actions): - if values: - records.extend(record_set) - cls._sync_manual_values(records) - cls._sync_manual_last(records) - cls._sync_eod_price(records) - - @staticmethod - def _component_matches_owner(record): - def record_id(value): - return getattr(value, 'id', value) - - component = getattr(record, 'price_component', None) - if not component: - return True - line = getattr(record, 'line', None) - if line: - return record_id(getattr(component, 'line', None)) == record_id(line) - sale_line = getattr(record, 'sale_line', None) - if sale_line: - return ( - record_id(getattr(component, 'sale_line', None)) - == record_id(sale_line)) - return True - - @classmethod - def validate(cls, records): - super(Pricing, cls).validate(records) - for record in records: - if not cls._component_matches_owner(record): - raise UserError( - "Pricing component must belong to the related line.") - - @classmethod - def _sync_eod_price(cls, records): - if not records: - return - with Transaction().set_context(skip_pricing_eod_sync=True): - for record in records: - eod_price = record.compute_eod_price() - if Decimal(str(record.eod_price or 0)) == Decimal(str(eod_price or 0)): - continue - super(Pricing, cls).write([record], { - 'eod_price': eod_price, - }) - - @classmethod - def _is_manual_pricing_record(cls, record): - component = getattr(record, 'price_component', None) - if component is None: - return True - return not bool(getattr(component, 'auto', False)) - - @classmethod - def _get_pricing_group_domain(cls, record): - component = getattr(record, 'price_component', None) - if getattr(record, 'sale_line', None): - return [ - ('sale_line', '=', record.sale_line.id), - ('price_component', '=', - component.id if getattr(component, 'id', None) else None), - ] - if getattr(record, 'line', None): - return [ - ('line', '=', record.line.id), - ('price_component', '=', - component.id if getattr(component, 'id', None) else None), - ] - return None - - @classmethod - def _get_base_quantity(cls, record): - owner = getattr(record, 'sale_line', None) or getattr(record, 'line', None) - if not owner: - return Decimal(0) - if hasattr(owner, '_get_pricing_base_quantity'): - return Decimal(str(owner._get_pricing_base_quantity() or 0)) - quantity = getattr(owner, 'quantity_theorical', None) - if quantity is None: - quantity = getattr(owner, 'quantity', None) - return Decimal(str(quantity or 0)) - - @classmethod - def _sync_manual_values(cls, records): - if (not records - or Transaction().context.get('skip_pricing_manual_sync')): - return - domains = [] - seen = set() - for record in records: - if not cls._is_manual_pricing_record(record): - continue - domain = cls._get_pricing_group_domain(record) - if not domain: - continue - key = tuple(domain) - if key in seen: - continue - seen.add(key) - domains.append(domain) - if not domains: - return - with Transaction().set_context( - skip_pricing_manual_sync=True, - skip_pricing_last_sync=True, - skip_pricing_eod_sync=True): - for domain in domains: - pricings = cls.search( - domain, - order=[('pricing_date', 'ASC'), ('id', 'ASC')]) - if not pricings: - continue - base_quantity = cls._get_base_quantity(pricings[0]) - cumul_qt = Decimal(0) - cumul_qt_price = Decimal(0) - total = len(pricings) - for index, pricing in enumerate(pricings): - quantity = Decimal(str(pricing.quantity or 0)) - settl_price = Decimal(str(pricing.settl_price or 0)) - cumul_qt += quantity - cumul_qt_price += quantity * settl_price - fixed_qt = cumul_qt - if fixed_qt > 0: - fixed_qt_price = round(cumul_qt_price / fixed_qt, 4) - else: - fixed_qt_price = Decimal(0) - unfixed_qt = base_quantity - fixed_qt - if unfixed_qt < Decimal('0.001'): - unfixed_qt = Decimal(0) - fixed_qt = base_quantity - values = { - 'fixed_qt': fixed_qt, - 'fixed_qt_price': fixed_qt_price, - 'unfixed_qt': unfixed_qt, - 'unfixed_qt_price': settl_price, - 'last': index == (total - 1), - } - eod_price = cls._weighted_average_price( - values['fixed_qt'], - values['fixed_qt_price'], - values['unfixed_qt'], - values['unfixed_qt_price'], - ) - values['eod_price'] = eod_price - super(Pricing, cls).write([pricing], values) - - @classmethod - def _get_manual_last_group_domain(cls, record): - return cls._get_pricing_group_domain(record) - - @classmethod - def _sync_manual_last(cls, records): - if not records: - return - domains = [] - seen = set() - for record in records: - domain = cls._get_manual_last_group_domain(record) - if not domain: - continue - key = tuple(domain) - if key in seen: - continue - seen.add(key) - domains.append(domain) - if not domains: - return - with Transaction().set_context( - skip_pricing_last_sync=True, - skip_pricing_eod_sync=True): - for domain in domains: - pricings = cls.search( - domain, - order=[('pricing_date', 'ASC'), ('id', 'ASC')]) - if not pricings: - continue - last_pricing = pricings[-1] - for pricing in pricings[:-1]: - if pricing.last: - super(Pricing, cls).write([pricing], {'last': False}) - if not last_pricing.last: - super(Pricing, cls).write([last_pricing], {'last': True}) - - def get_fixed_price(self): - price = Decimal(0) - Pricing = Pool().get('pricing.pricing') - domain = self._get_pricing_group_domain(self) - if not domain: - return price - pricings = Pricing.search(domain, order=[('pricing_date', 'ASC'), ('id', 'ASC')]) - if pricings: - cumul_qt = Decimal(0) - cumul_qt_price = Decimal(0) - for pr in pricings: - quantity = Decimal(str(pr.quantity or 0)) - settl_price = Decimal(str(pr.settl_price or 0)) - cumul_qt += quantity - cumul_qt_price += quantity * settl_price - if pr.id == self.id: - break - if cumul_qt > 0: - price = cumul_qt_price / cumul_qt + @classmethod + def default_settl_price(cls): + return Decimal(0) + + @classmethod + def default_eod_price(cls): + return Decimal(0) + + @fields.depends('line', 'sale_line') + def on_change_with_available_components(self, name=None): + Component = Pool().get('pricing.component') + line = getattr(self, 'line', None) + if line: + return Component.search([('line', '=', line)]) + sale_line = getattr(self, 'sale_line', None) + if sale_line: + return Component.search([('sale_line', '=', sale_line)]) + return [] + + @staticmethod + def _weighted_average_price(fixed_qt, fixed_price, unfixed_qt, unfixed_price): + fixed_qt = Decimal(str(fixed_qt or 0)) + fixed_price = Decimal(str(fixed_price or 0)) + unfixed_qt = Decimal(str(unfixed_qt or 0)) + unfixed_price = Decimal(str(unfixed_price or 0)) + total_qty = fixed_qt + unfixed_qt + if total_qty == 0: + return Decimal(0) + return round( + ((fixed_qt * fixed_price) + (unfixed_qt * unfixed_price)) / total_qty, + 4, + ) + + def compute_eod_price(self): + if getattr(self, 'sale_line', None) and hasattr(self, 'get_eod_price_sale'): + return self.get_eod_price_sale() + if getattr(self, 'line', None) and hasattr(self, 'get_eod_price_purchase'): + return self.get_eod_price_purchase() + return self._weighted_average_price( + self.fixed_qt, + self.fixed_qt_price, + self.unfixed_qt, + self.unfixed_qt_price, + ) + + @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') + def on_change_fixed_qt(self): + self.eod_price = self.compute_eod_price() + + @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') + def on_change_fixed_qt_price(self): + self.eod_price = self.compute_eod_price() + + @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') + def on_change_unfixed_qt(self): + self.eod_price = self.compute_eod_price() + + @fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price') + def on_change_unfixed_qt_price(self): + self.eod_price = self.compute_eod_price() + + @classmethod + def create(cls, vlist): + records = super(Pricing, cls).create(vlist) + cls._sync_manual_values(records) + cls._sync_manual_last(records) + cls._sync_eod_price(records) + return records + + @classmethod + def write(cls, *args): + super(Pricing, cls).write(*args) + if (Transaction().context.get('skip_pricing_eod_sync') + or Transaction().context.get('skip_pricing_last_sync')): + return + records = [] + actions = iter(args) + for record_set, values in zip(actions, actions): + if values: + records.extend(record_set) + cls._sync_manual_values(records) + cls._sync_manual_last(records) + cls._sync_eod_price(records) + + @staticmethod + def _component_matches_owner(record): + def record_id(value): + return getattr(value, 'id', value) + + component = getattr(record, 'price_component', None) + if not component: + return True + line = getattr(record, 'line', None) + if line: + return record_id(getattr(component, 'line', None)) == record_id(line) + sale_line = getattr(record, 'sale_line', None) + if sale_line: + return ( + record_id(getattr(component, 'sale_line', None)) + == record_id(sale_line)) + return True + + @classmethod + def validate(cls, records): + super(Pricing, cls).validate(records) + for record in records: + if not cls._component_matches_owner(record): + raise UserError( + "Pricing component must belong to the related line.") + + @classmethod + def _sync_eod_price(cls, records): + if not records: + return + with Transaction().set_context(skip_pricing_eod_sync=True): + for record in records: + eod_price = record.compute_eod_price() + if Decimal(str(record.eod_price or 0)) == Decimal(str(eod_price or 0)): + continue + super(Pricing, cls).write([record], { + 'eod_price': eod_price, + }) + + @classmethod + def _is_manual_pricing_record(cls, record): + component = getattr(record, 'price_component', None) + if component is None: + return True + return not bool(getattr(component, 'auto', False)) + + @classmethod + def _get_pricing_group_domain(cls, record): + component = getattr(record, 'price_component', None) + if getattr(record, 'sale_line', None): + return [ + ('sale_line', '=', record.sale_line.id), + ('price_component', '=', + component.id if getattr(component, 'id', None) else None), + ] + if getattr(record, 'line', None): + return [ + ('line', '=', record.line.id), + ('price_component', '=', + component.id if getattr(component, 'id', None) else None), + ] + return None + + @classmethod + def _get_base_quantity(cls, record): + owner = getattr(record, 'sale_line', None) or getattr(record, 'line', None) + if not owner: + return Decimal(0) + if hasattr(owner, '_get_pricing_base_quantity'): + return Decimal(str(owner._get_pricing_base_quantity() or 0)) + quantity = getattr(owner, 'quantity_theorical', None) + if quantity is None: + quantity = getattr(owner, 'quantity', None) + return Decimal(str(quantity or 0)) + + @classmethod + def _sync_manual_values(cls, records): + if (not records + or Transaction().context.get('skip_pricing_manual_sync')): + return + domains = [] + seen = set() + for record in records: + if not cls._is_manual_pricing_record(record): + continue + domain = cls._get_pricing_group_domain(record) + if not domain: + continue + key = tuple(domain) + if key in seen: + continue + seen.add(key) + domains.append(domain) + if not domains: + return + with Transaction().set_context( + skip_pricing_manual_sync=True, + skip_pricing_last_sync=True, + skip_pricing_eod_sync=True): + for domain in domains: + pricings = cls.search( + domain, + order=[('pricing_date', 'ASC'), ('id', 'ASC')]) + if not pricings: + continue + base_quantity = cls._get_base_quantity(pricings[0]) + cumul_qt = Decimal(0) + cumul_qt_price = Decimal(0) + total = len(pricings) + for index, pricing in enumerate(pricings): + quantity = Decimal(str(pricing.quantity or 0)) + settl_price = Decimal(str(pricing.settl_price or 0)) + cumul_qt += quantity + cumul_qt_price += quantity * settl_price + fixed_qt = cumul_qt + if fixed_qt > 0: + fixed_qt_price = round(cumul_qt_price / fixed_qt, 4) + else: + fixed_qt_price = Decimal(0) + unfixed_qt = base_quantity - fixed_qt + if unfixed_qt < Decimal('0.001'): + unfixed_qt = Decimal(0) + fixed_qt = base_quantity + values = { + 'fixed_qt': fixed_qt, + 'fixed_qt_price': fixed_qt_price, + 'unfixed_qt': unfixed_qt, + 'unfixed_qt_price': settl_price, + 'last': index == (total - 1), + } + eod_price = cls._weighted_average_price( + values['fixed_qt'], + values['fixed_qt_price'], + values['unfixed_qt'], + values['unfixed_qt_price'], + ) + values['eod_price'] = eod_price + super(Pricing, cls).write([pricing], values) + + @classmethod + def _get_manual_last_group_domain(cls, record): + return cls._get_pricing_group_domain(record) + + @classmethod + def _sync_manual_last(cls, records): + if not records: + return + domains = [] + seen = set() + for record in records: + domain = cls._get_manual_last_group_domain(record) + if not domain: + continue + key = tuple(domain) + if key in seen: + continue + seen.add(key) + domains.append(domain) + if not domains: + return + with Transaction().set_context( + skip_pricing_last_sync=True, + skip_pricing_eod_sync=True): + for domain in domains: + pricings = cls.search( + domain, + order=[('pricing_date', 'ASC'), ('id', 'ASC')]) + if not pricings: + continue + last_pricing = pricings[-1] + for pricing in pricings[:-1]: + if pricing.last: + super(Pricing, cls).write([pricing], {'last': False}) + if not last_pricing.last: + super(Pricing, cls).write([last_pricing], {'last': True}) + + def get_fixed_price(self): + price = Decimal(0) + Pricing = Pool().get('pricing.pricing') + domain = self._get_pricing_group_domain(self) + if not domain: + return price + pricings = Pricing.search(domain, order=[('pricing_date', 'ASC'), ('id', 'ASC')]) + if pricings: + cumul_qt = Decimal(0) + cumul_qt_price = Decimal(0) + for pr in pricings: + quantity = Decimal(str(pr.quantity or 0)) + settl_price = Decimal(str(pr.settl_price or 0)) + cumul_qt += quantity + cumul_qt_price += quantity * settl_price + if pr.id == self.id: + break + if cumul_qt > 0: + price = cumul_qt_price / cumul_qt return round(price,4) diff --git a/modules/purchase_trade/pricing.xml b/modules/purchase_trade/pricing.xml index 5332ab0..e058a12 100755 --- a/modules/purchase_trade/pricing.xml +++ b/modules/purchase_trade/pricing.xml @@ -27,6 +27,23 @@ this repository contains the full copyright notices and license terms. --> estimated_tree + + purchase_trade.import_prices.start + form + import_prices_start_form + + + purchase_trade.import_prices.result + form + import_prices_result_form + + + + Import Prices + purchase_trade.import_prices + + + pricing.component tree @@ -187,6 +204,11 @@ this repository contains the full copyright notices and license terms. --> parent="menu_mtm" sequence="10" id="menu_strategy" /> + \ No newline at end of file diff --git a/modules/purchase_trade/view/import_prices_result_form.xml b/modules/purchase_trade/view/import_prices_result_form.xml new file mode 100644 index 0000000..bcf34bf --- /dev/null +++ b/modules/purchase_trade/view/import_prices_result_form.xml @@ -0,0 +1,3 @@ +
+ + diff --git a/modules/purchase_trade/view/import_prices_start_form.xml b/modules/purchase_trade/view/import_prices_start_form.xml new file mode 100644 index 0000000..399f7f3 --- /dev/null +++ b/modules/purchase_trade/view/import_prices_start_form.xml @@ -0,0 +1,10 @@ +
+