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 @@
+