Skip to content

Commit cd97263

Browse files
committed
[UPD] sale_forecast: import wizard
1 parent 470a91a commit cd97263

File tree

6 files changed

+220
-4
lines changed

6 files changed

+220
-4
lines changed

sale_forecast/__manifest__.py

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"security/sale_security.xml",
2020
"views/sale_forecast_view.xml",
2121
"wizards/sale_forecast_wizard_view.xml",
22+
"wizards/wizard_sale_forecast_import.xml",
2223
],
2324
"license": "LGPL-3",
2425
"installable": True,

sale_forecast/security/ir.model.access.csv

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ access_sale_forecast_system,sale.forecast.system,model_sale_forecast,sales_team.
44
access_sale_forecast_sheet,access_sale_forecast_sheet,sale_forecast.model_sale_forecast_sheet,sales_team.group_sale_manager,1,1,1,1
55
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
66
access_sale_forecast_wizard,access_sale_forecast_wizard,sale_forecast.model_sale_forecast_wizard,sales_team.group_sale_manager,1,1,1,1
7+
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

sale_forecast/static/description/index.html

+7-4
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88

99
/*
1010
:Author: David Goodger (goodger@python.org)
11-
:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $
11+
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
1212
:Copyright: This stylesheet has been placed in the public domain.
1313
1414
Default cascading style sheet for the HTML output of Docutils.
15+
Despite the name, some widely supported CSS2 features are used.
1516
1617
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
1718
customize this style sheet.
@@ -274,7 +275,7 @@
274275
margin-left: 2em ;
275276
margin-right: 2em }
276277

277-
pre.code .ln { color: grey; } /* line numbers */
278+
pre.code .ln { color: gray; } /* line numbers */
278279
pre.code, code { background-color: #eeeeee }
279280
pre.code .comment, code .comment { color: #5C6576 }
280281
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
@@ -300,7 +301,7 @@
300301
span.pre {
301302
white-space: pre }
302303

303-
span.problematic {
304+
span.problematic, pre.problematic {
304305
color: red }
305306

306307
span.section-subtitle {
@@ -416,7 +417,9 @@ <h2><a class="toc-backref" href="#toc-entry-4">Contributors</a></h2>
416417
<div class="section" id="maintainers">
417418
<h2><a class="toc-backref" href="#toc-entry-5">Maintainers</a></h2>
418419
<p>This module is maintained by the OCA.</p>
419-
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
420+
<a class="reference external image-reference" href="https://odoo-community.org">
421+
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
422+
</a>
420423
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
421424
mission is to support the collaborative development of Odoo features and
422425
promote its widespread use.</p>

sale_forecast/wizards/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from . import sale_forecast_sheet
22
from . import sale_forecast_sheet_line
33
from . import sale_forecast_wizard
4+
from . import wizard_sale_forecast_import
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# pylint: disable=no-member,protected-access,invalid-name,no-self-use
2+
import base64
3+
import logging
4+
from datetime import datetime
5+
6+
from odoo import _, fields, models
7+
from odoo.exceptions import UserError
8+
9+
_logger = logging.getLogger(__name__) # pylint: disable=invalid-name
10+
11+
12+
class WizardSaleForecastImport(models.TransientModel):
13+
"""Import sale forecast"""
14+
15+
_name = "wizard.sale.forecast.import"
16+
_description = "Import sale forecast records"
17+
18+
file_import = fields.Binary("Import Forecast")
19+
file_name = fields.Char("file name")
20+
21+
def action_process_import(self):
22+
"""Actually process the uploaded file to import it."""
23+
self.ensure_one()
24+
if not self.file_import:
25+
raise UserError(_("Please attach a file containing product information."))
26+
(
27+
rows,
28+
date_headers,
29+
default_code_index,
30+
date_index,
31+
key_index,
32+
) = self._import_file()
33+
aggregate_info, locations = self._aggregate_info(
34+
rows, date_headers, default_code_index, date_index, key_index
35+
)
36+
self._process_import(aggregate_info, locations)
37+
38+
def _import_file(self):
39+
def get_field_index(header_row, name):
40+
"""Get index of column in input file."""
41+
try:
42+
index = header_row.index(name)
43+
return index
44+
except ValueError as error:
45+
raise UserError(
46+
_("Row header name %s is not found in file") % name
47+
) from error
48+
49+
self.ensure_one()
50+
lst = self._get_rows()
51+
if not lst or not lst[0]:
52+
raise UserError(_("Import file is empty or unreadable"))
53+
rows = lst[1]
54+
header_row = rows[1]
55+
date_headers = header_row[4:]
56+
product_headers = header_row[:4]
57+
(product_category_index, product_index, default_code_index, key_index,) = (
58+
get_field_index(product_headers, name)
59+
for name in [
60+
"Product Category",
61+
"Product",
62+
"Item Code (SKU)",
63+
"Key",
64+
]
65+
)
66+
date_index = []
67+
date_index += [get_field_index(header_row, name) for name in date_headers]
68+
return rows, date_headers, default_code_index, date_index, key_index
69+
70+
def _get_rows(self):
71+
"""Get rows from data_file."""
72+
self.ensure_one()
73+
import_model = self.env["base_import.import"]
74+
data_file = base64.b64decode(self.file_import)
75+
importer = import_model.create({"file": data_file, "file_name": self.file_name})
76+
return importer._read_file({"quoting": '"', "separator": ","})
77+
78+
def _aggregate_info(
79+
self, rows, date_headers, default_code_index, date_index, key_index
80+
):
81+
aggregate_info = dict()
82+
locations = set()
83+
for row in rows[2:]:
84+
if row[key_index].strip() == "Total":
85+
continue
86+
aggregate_info[row[default_code_index]] = []
87+
for date, index in zip(date_headers, date_index):
88+
aggregate_info[row[default_code_index]].append(
89+
(
90+
row[key_index].strip(),
91+
self._date_to_object(date.strip()),
92+
float(row[index].replace(",", "").replace(".", ""))
93+
if row[index]
94+
else 0,
95+
)
96+
)
97+
locations.add(row[key_index])
98+
return aggregate_info, locations
99+
100+
def _date_to_object(self, date):
101+
return datetime.strptime(date, "%b-%y")
102+
103+
def _process_import(self, rows, locations):
104+
forecast_model = self.env["sale.forecast"]
105+
location_model = self.env["stock.location"]
106+
location_dict = {
107+
"Sales": location_model.browse(25),
108+
"CS consumption": location_model.browse(30),
109+
"AS consumption": location_model.browse(18),
110+
}
111+
for default_code, location_date_quantity in rows.items():
112+
if not location_date_quantity:
113+
continue
114+
for location, date, quantity in location_date_quantity:
115+
if location not in locations:
116+
# TODO: log
117+
continue
118+
location_id = location_dict.get(location)
119+
if not location_id:
120+
_logger.debug(
121+
"No location %s exists.",
122+
location,
123+
)
124+
continue
125+
if not quantity:
126+
continue
127+
product = self.env["product.product"].search(
128+
[("default_code", "=", default_code)]
129+
)
130+
if not product:
131+
_logger.debug(
132+
"No product with default code %s exists.",
133+
default_code,
134+
)
135+
continue
136+
if not date:
137+
continue
138+
date_range_id = self._get_date_range_id(date)
139+
if not date_range_id:
140+
_logger.debug(
141+
"No montly date range exists for %s.",
142+
date.strftime("%b-%y"),
143+
)
144+
continue
145+
vals = {
146+
"product_id": product.id,
147+
"location_id": location_id.id,
148+
"product_uom_qty": quantity,
149+
"date_range_id": date_range_id.id,
150+
}
151+
existing_forecast = forecast_model.search(vals)
152+
if existing_forecast.search(vals):
153+
_logger.debug(
154+
"Forecast for product %s, location %s, date %s exists, updating...",
155+
(product.name, location, date.strftime("%b-%y")),
156+
)
157+
existing_forecast.write(vals)
158+
continue
159+
forecast_model.create(vals)
160+
161+
def _get_date_range_id(self, date):
162+
date_range_domain = [
163+
("date_start", "<=", date),
164+
("date_end", ">", date),
165+
("type_name", "ilike", "Monthly"),
166+
("active", "=", True),
167+
]
168+
return self.env["date.range"].search(date_range_domain)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" ?>
2+
<odoo>
3+
4+
<record id="wizard_import_sale_forecast_form" model="ir.ui.view">
5+
<field name="name">Import sale forecast</field>
6+
<field name="model">wizard.sale.forecast.import</field>
7+
<field name="arch" type="xml">
8+
<form string="Import">
9+
<field name="file_name" invisible="1" />
10+
<group>
11+
<field name="file_import" class="oe_inline" filename="file_name" />
12+
</group>
13+
<footer>
14+
<button
15+
name="action_process_import"
16+
string="Process Import"
17+
type="object"
18+
class="oe_highlight"
19+
attrs="{'invisible': [('file_import', '=', False)]}"
20+
/>
21+
<button string="Cancel" special="cancel" class="btn-secondary" />
22+
</footer>
23+
</form>
24+
</field>
25+
</record>
26+
27+
<record id="sale_forecast_import_action" model="ir.actions.act_window">
28+
<field name="name">Sale Forecast Import</field>
29+
<field name="type">ir.actions.act_window</field>
30+
<field name="res_model">wizard.sale.forecast.import</field>
31+
<field name="view_mode">form</field>
32+
<field name="target">new</field>
33+
</record>
34+
35+
<menuitem
36+
id="sale_forecast_import_menu"
37+
parent="sale_forecast_planning_menu"
38+
action="sale_forecast_import_action"
39+
sequence="50"
40+
/>
41+
42+
</odoo>

0 commit comments

Comments
 (0)