From 82893b457e5af8a8eee1644c7aeef994de9d5011 Mon Sep 17 00:00:00 2001 From: ntsirintanis Date: Fri, 19 Jul 2024 12:45:22 +0200 Subject: [PATCH] [UPD] sale_forecast: import wizard --- sale_forecast/__manifest__.py | 1 + sale_forecast/security/ir.model.access.csv | 1 + sale_forecast/static/description/index.html | 11 +- sale_forecast/wizards/__init__.py | 1 + .../wizards/wizard_sale_forecast_import.py | 189 ++++++++++++++++++ .../wizards/wizard_sale_forecast_import.xml | 42 ++++ 6 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 sale_forecast/wizards/wizard_sale_forecast_import.py create mode 100644 sale_forecast/wizards/wizard_sale_forecast_import.xml diff --git a/sale_forecast/__manifest__.py b/sale_forecast/__manifest__.py index 636d65bc1e2..668d187c4d5 100644 --- a/sale_forecast/__manifest__.py +++ b/sale_forecast/__manifest__.py @@ -19,6 +19,7 @@ "security/sale_security.xml", "views/sale_forecast_view.xml", "wizards/sale_forecast_wizard_view.xml", + "wizards/wizard_sale_forecast_import.xml", ], "license": "LGPL-3", "installable": True, diff --git a/sale_forecast/security/ir.model.access.csv b/sale_forecast/security/ir.model.access.csv index 06fb264be1e..84308539f8b 100644 --- a/sale_forecast/security/ir.model.access.csv +++ b/sale_forecast/security/ir.model.access.csv @@ -4,3 +4,4 @@ access_sale_forecast_system,sale.forecast.system,model_sale_forecast,sales_team. access_sale_forecast_sheet,access_sale_forecast_sheet,sale_forecast.model_sale_forecast_sheet,sales_team.group_sale_manager,1,1,1,1 access_sale_forecast_sheet_line,access_sale_forecast_sheet_line,sale_forecast.model_sale_forecast_sheet_line,sales_team.group_sale_manager,1,1,1,1 access_sale_forecast_wizard,access_sale_forecast_wizard,sale_forecast.model_sale_forecast_wizard,sales_team.group_sale_manager,1,1,1,1 +sale_forecast.access_wizard_sale_forecast_import,access_wizard_sale_forecast_import,sale_forecast.model_wizard_sale_forecast_import,base.group_user,1,1,1,1 diff --git a/sale_forecast/static/description/index.html b/sale_forecast/static/description/index.html index 68b3f5168ac..3d1b566f35f 100644 --- a/sale_forecast/static/description/index.html +++ b/sale_forecast/static/description/index.html @@ -8,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -274,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -300,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -416,7 +417,9 @@

Contributors

Maintainers

This module is maintained by the OCA.

-Odoo Community Association + +Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

diff --git a/sale_forecast/wizards/__init__.py b/sale_forecast/wizards/__init__.py index 4021f430340..a8f6960ea39 100644 --- a/sale_forecast/wizards/__init__.py +++ b/sale_forecast/wizards/__init__.py @@ -1,3 +1,4 @@ from . import sale_forecast_sheet from . import sale_forecast_sheet_line from . import sale_forecast_wizard +from . import wizard_sale_forecast_import diff --git a/sale_forecast/wizards/wizard_sale_forecast_import.py b/sale_forecast/wizards/wizard_sale_forecast_import.py new file mode 100644 index 00000000000..a4164186435 --- /dev/null +++ b/sale_forecast/wizards/wizard_sale_forecast_import.py @@ -0,0 +1,189 @@ +# pylint: disable=no-member,protected-access,invalid-name,no-self-use +import base64 +import logging +from datetime import datetime + +from odoo import _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class WizardSaleForecastImport(models.TransientModel): + """Import sale forecast""" + + _name = "wizard.sale.forecast.import" + _description = "Import sale forecast records" + + file_import = fields.Binary("Import Forecast") + file_name = fields.Char("file name") + + def action_process_import(self): + """Actually process the uploaded file to import it.""" + self.ensure_one() + if not self.file_import: + raise UserError(_("Please attach a file containing product information.")) + ( + rows, + date_headers, + default_code_index, + date_index, + key_index, + ) = self._import_file() + aggregate_info = self._aggregate_info( + rows, date_headers, default_code_index, date_index, key_index + ) + self._process_import(aggregate_info) + + def _import_file(self): + def get_field_index(header_row, name): + """Get index of column in input file.""" + try: + index = header_row.index(name) + return index + except ValueError as error: + raise UserError( + _("Row header name %s is not found in file") % name + ) from error + + self.ensure_one() + lst = self._get_rows() + if not lst or not lst[0]: + raise UserError(_("Import file is empty or unreadable")) + rows = lst[1] + header_row = rows[1] + date_headers = header_row[4:] + product_headers = header_row[:4] + (product_category_index, product_index, default_code_index, key_index,) = ( + get_field_index(product_headers, name) + for name in [ + "Product Category", + "Product", + "Item Code (SKU)", + "Key", + ] + ) + date_index = [] + date_index += [get_field_index(header_row, name) for name in date_headers] + return rows, date_headers, default_code_index, date_index, key_index + + def _get_rows(self): + """Get rows from data_file.""" + self.ensure_one() + import_model = self.env["base_import.import"] + data_file = base64.b64decode(self.file_import) + importer = import_model.create({"file": data_file, "file_name": self.file_name}) + return importer._read_file({"quoting": '"', "separator": ","}) + + def _aggregate_info( + self, rows, date_headers, default_code_index, date_index, key_index + ): + aggregate_info = dict() + for row in rows[2:]: + location = row[key_index].strip() + if not location: + continue + if location == "Total": + continue + default_code = row[default_code_index].strip() + aggregate_info[default_code] = aggregate_info.get(default_code, []) + for date, index in zip(date_headers, date_index): + quantity = ( + float(row[index].replace(",", "").replace(".", "")) + if row[index] + else 0 + ) + if quantity <= 0: + continue + if not quantity: + continue + date_forecast = self._date_to_object(date.strip()) + if not date_forecast: + continue + aggregate_info[default_code].append( + ( + location, + date_forecast, + quantity, + ) + ) + return aggregate_info + + def _date_to_object(self, date): + """No expired dates""" + date_object = datetime.strptime(date, "%b-%y") + if date_object.date() < fields.Date.today(): + return False + return date_object + + def _process_import(self, rows): + forecast_model = self.env["sale.forecast"] + location_model = self.env["stock.location"] + location_dict = { + "Sales": location_model.browse(25), + "CS consumption": location_model.browse(30), + "AS consumption": location_model.browse(18), + } + for default_code, location_date_quantity in rows.items(): + product = self.env["product.product"].search( + [("default_code", "=", default_code)] + ) + if not product: + _logger.warning( + "No product with default code %s exists.", + default_code, + ) + continue + if not location_date_quantity: + continue + for location, date, quantity in location_date_quantity: + if location not in location_dict.keys(): + _logger.warning( + "No location %s exists.", + location, + ) + continue + location_id = location_dict.get(location) + if not location_id: + _logger.warning( + "No location %s exists.", + location, + ) + continue + date_range_id = self._get_date_range_id(date) + if not date_range_id: + _logger.warning( + "No monthly date range exists for %s.", + date.strftime("%b-%y"), + ) + continue + vals = { + "product_id": product.id, + "location_id": location_id.id, + "product_uom_qty": quantity, + "date_range_id": date_range_id.id, + } + existing_forecast = forecast_model.search( + [ + ("product_id", "=", vals["product_id"]), + ("date_range_id", "=", vals["date_range_id"]), + ("location_id", "=", vals["location_id"]), + ] + ) + if existing_forecast: + _logger.warning( + "Forecast for product %s, location %s, date %s exists, updating...", + (product.name, location, date.strftime("%b-%y")), + ) + existing_forecast.write(vals) + continue + forecast_model.create(vals) + + def _get_date_range_id(self, date): + date_range_domain = [ + ("date_start", "<=", date), + ("date_end", ">", date), + ("type_name", "ilike", "Monthly"), + ("active", "=", True), + ] + return self.env["date.range"].search(date_range_domain) diff --git a/sale_forecast/wizards/wizard_sale_forecast_import.xml b/sale_forecast/wizards/wizard_sale_forecast_import.xml new file mode 100644 index 00000000000..711f0427754 --- /dev/null +++ b/sale_forecast/wizards/wizard_sale_forecast_import.xml @@ -0,0 +1,42 @@ + + + + + Import sale forecast + wizard.sale.forecast.import + +
+ + + + +
+
+ +
+
+ + + Sale Forecast Import + ir.actions.act_window + wizard.sale.forecast.import + form + new + + + + +