Compare commits

...

38 Commits

Author SHA1 Message Date
Benoit
650db876dd fix migraiton of mail_usability 2021-02-18 10:22:35 +01:00
Chafique
cd02a07433 [FIX] remove notify_email option in README.rst 2020-10-26 11:36:04 +01:00
Chafique
b097fb2666 [12.0][MIG] mail_usability 2020-10-26 11:30:23 +01:00
Alexis de Lattre
fc0ef1a827 New module link_tracker_usability 2020-10-20 10:38:39 +02:00
Sébastien BEAU
97712ce234 [FIX] fix helper 2020-10-20 10:38:39 +02:00
Sébastien BEAU
3ac0d60d17 [IMP] by default do not send an email when user_id is fill on object 2020-10-20 10:38:39 +02:00
Sébastien BEAU
5ec76d12d0 [REF] refactor the code in order to split it in several file 2020-10-20 10:38:39 +02:00
Sébastien BEAU
f13068d356 [IMP] add record_id on mail.message to be able to access to the record 2020-10-20 10:38:39 +02:00
Sébastien BEAU
39e257a3a5 [IMP] add some extra style css support and add a debugger mode. Update readme 2020-10-20 10:38:39 +02:00
Sébastien BEAU
10a3366096 [IMP] remove the fucking auto_delete!!! 2020-10-20 10:38:39 +02:00
Sébastien BEAU
e0d77b3a47 [REF] refactor the code to make it simplifier and avoid hacking the _notify method 2020-10-20 10:38:39 +02:00
Sébastien BEAU
4fe833135e [IMP] add readme, remove auto following when sending an email, use light version of email notification to avoid injecting useless link in the mail sent 2020-10-20 10:38:39 +02:00
Sébastien BEAU
1632342a99 [IMP] improve the wizard for testing email, allow to search on object and to send email for real check 2020-10-20 10:38:39 +02:00
David Beal
73c8044c47 UPD Branding 2020-10-20 10:38:39 +02:00
Alexis de Lattre
8ed3ed02f9 mail_usability: add intermediary level to notify_email parameter of res.partner 2020-10-20 10:38:39 +02:00
Alexis de Lattre
d3cb8aeb79 Port mail_usability to v10 2020-10-20 10:38:39 +02:00
David Beal
a7a68e8dee IMP add icons 2020-10-20 10:38:39 +02:00
Alexis de Lattre
ce1dac1e11 Mass rename from __openerp__.py to __manifest__.py 2020-10-20 10:38:39 +02:00
Alexis de Lattre
8eae0f32d8 Set all modules as uninstallable 2020-10-20 10:38:39 +02:00
Benoit
3b32dc25af [ADD] mail_usability module for improvements on mails 2020-10-20 10:38:39 +02:00
Alexis de Lattre
67d31f9658 Display advanced fields in stock move form views 2020-10-13 17:22:34 +02:00
Alexis de Lattre
1b931d066b stock_usability: add tracking on is_locked field of pickings 2020-10-13 10:07:36 +02:00
David Beal
f605b56a5e FIX mrp_usability: define sold out in bottom page 2020-10-06 16:28:43 +02:00
David Beal
58f01d9673 FIX mrp_usability: round rupture value 2020-10-02 18:23:46 +02:00
David Beal
8878ab5bd1 IMP mrp_usability: define stock move in rupture 2020-10-02 18:13:59 +02:00
Alexis de Lattre
80f5341da0 [FIX] stock_valuation_xlsx: fix report when categ_subtotal is false 2020-09-25 23:57:56 +02:00
Alexis de Lattre
a4ca584e90 stock_valuation_xlsx: Add ability to force cost price to current
Improve headers in XLSX
Improve code
2020-09-25 22:51:39 +02:00
Alexis de Lattre
4d81dee7b4 stock_valuation_xlsx: Replace the right menu 2020-09-25 16:28:24 +02:00
Alexis de Lattre
140217da6e Port module stock_valuation_xlsx from v10 to v12 2020-09-25 16:16:14 +02:00
Sébastien BEAU
a9a0a2a999 [IMP] add module for hiding unwanted feature 2020-09-17 01:03:53 +02:00
Alexis de Lattre
d7f3a70d48 mrp_average_cost: improve code perf 2020-09-11 15:34:29 +02:00
Alexis de Lattre
1074fcba21 Show property_cost_method on product form view 2020-09-11 14:33:05 +02:00
beau sebastien
0ae76be885 Merge pull request #126 from akretion/12.0-MIG-product_no_translation
12.0 mig product no translation
2020-08-20 22:47:33 +02:00
Sébastien BEAU
59cdcbd173 [MIG] migrate product_no_tranlation 2020-08-20 22:44:21 +02:00
david.beal@akretion.com
7cf224cf23 [IMP] icon translation 2020-08-20 22:30:11 +02:00
Alexis de Lattre
d0d65ebbca product_no_translation : translate=False now on all fields declared in addons/product/product.py 2020-08-20 22:30:11 +02:00
Alexis de Lattre
ca10381be8 Add module product_no_translation 2020-08-20 22:30:11 +02:00
clementmbr
44b19dfe60 Merge pull request #124 from akretion/12.0-imp-button-prod-to-bom
[IMP] mrp_usability: improve smart button from products to BoMs
2020-08-05 16:04:24 -03:00
43 changed files with 1331 additions and 70 deletions

19
mail_usability/README.rst Normal file
View File

@@ -0,0 +1,19 @@
# Mail Usability
Take back the control on your email
## Feature
- do not follow automatically a object when sending an email
- better email preview, allow to select between the whole database object and not only the last 10
- use a light template version for notification without link (link should be explicit)
- add some additional style in the white list when santizing html field (see tools.py)
- make the email template by default not 'auto_delete'
## TIPS
Never, never tick the 'auto_delete' on mail template because it fucking hard to debug
and understand what have been sent (we should create a module with a crontask, that drop them latter)
If the template of mail do not look like the same when saving it in odoo, maybe the sanitize style have drop some balise
please run odoo with "LOG_STYLE_SANITIZE=True odoo" to understand what have been drop, magic warning logger will tell you everthing

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@@ -0,0 +1,33 @@
# Copyright 2020 Akretion France (http://www.akretion.com)
# @author Benoît Guillot <benoit.guillot@akretion.com>
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'name': 'Mail Usability',
'version': '12.0.1.0.0',
'category': 'Base',
'license': 'AGPL-3',
'summary': 'Usability improvements on mails',
'description': """
Mail Usability
==============
Small usability improvements on mails:
* remove link in mail footer
* remove 'sent by' in notification footer
* add a new entry *All Messages Except Notifications* to the field *Receive Inbox Notifications by Email* of partners (becomes the default value)
""",
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['mail'],
'data': [
'views/mail_view.xml',
'data/mail_data.xml',
'wizard/email_template_preview_view.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!--Default Notification Email template -->
<record id="mail_template_notification" model="mail.template">
<field name="name">Notification Email</field>
<field name="subject">${object.subject}</field>
<field name="model_id" ref="mail.model_mail_message"/>
<field name="auto_delete" eval="True"/>
<field name="body_html">${object.body | safe}</field>
</record>
<template id="message_notification_email_usability">
<div t-raw="message.body"/>
</template>
</odoo>

View File

@@ -0,0 +1,5 @@
from . import mail
from . import tools
from . import mail_template
from . import mail_message
from . import res_partner

View File

@@ -0,0 +1,36 @@
# Copyright 2016-2017 Akretion France (http://www.akretion.com)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, api
import logging
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
def _active_message_auto_subscribe_notify(self):
_logger.debug('Skip automatic subscribe notification')
return False
def _message_auto_subscribe_notify(self, partner_ids, template):
if self._active_message_auto_subscribe_notify():
return super(MailThread, self)._message_auto_subscribe_notify(
partner_ids, template)
else:
return True
@api.multi
@api.returns('self', lambda value: value.id)
def message_post(self, body='', subject=None, message_type='notification',
subtype=None, parent_id=False, attachments=None,
content_subtype='html', **kwargs):
if not 'mail_create_nosubscribe' in self._context:
# Do not implicitly follow an object by just sending a message
self = self.with_context(mail_create_nosubscribe=True)
return super(MailThread, self).message_post(
body=body, subject=subject, message_type=message_type,
subtype=subtype, parent_id=parent_id, attachments=attachments,
content_subtype=content_subtype, **kwargs)

View File

@@ -0,0 +1,19 @@
# Copyright 2019 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class MailMessage(models.Model):
_inherit = 'mail.message'
@property
def record_id(self):
# we do not use a reference field here as mail message
# are used everywhere and many model are not yet loaded
# so odoo raise exception
if self:
self.ensure_one()
return self.env[self.model].browse(self.res_id)
return None

View File

@@ -0,0 +1,11 @@
# Copyright 2018 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp import fields, models
class MailTemplate(models.Model):
_inherit = 'mail.template'
auto_delete = fields.Boolean(default=False)

View File

@@ -0,0 +1,27 @@
# Copyright 2016-2019 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api
class ResPartner(models.Model):
_inherit = 'res.partner'
opt_out = fields.Boolean(track_visibility='onchange')
@api.model
def _notify(self, message, rdata, record, force_send=False,
send_after_commit=True, model_description=False,
mail_auto_delete=True):
# use an empty layout for notification by default
if not message.layout:
message.layout = 'mail_usability.message_notification_email_usability'
# Never auto delete notification email
# fucking to hard to debug when message have been delete
mail_auto_delete = False
return super(ResPartner, self)._notify(
message=message, rdata=rdata, record=record,
force_send=force_send, send_after_commit=send_after_commit,
model_description=model_description, mail_auto_delete=mail_auto_delete)

View File

@@ -0,0 +1,43 @@
# Copyright 2018 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tools.mail import _Cleaner
import os
import logging
_logger = logging.getLogger(__name__)
_Cleaner._style_whitelist += [
'word-wrap',
'display'
'border-top',
'border-bottom',
'border-left',
'border-right',
'text-transform',
]
if os.getenv('LOG_STYLE_SANITIZE'):
# Monkey patch the parse style method to debug
# the missing style
def parse_style(self, el):
attributes = el.attrib
styling = attributes.get('style')
if styling:
valid_styles = {}
styles = self._style_re.findall(styling)
for style in styles:
if style[0].lower() in self._style_whitelist:
valid_styles[style[0].lower()] = style[1]
# START HACK
else:
_logger.warning('Remove style %s %s', *style)
# END HACK
if valid_styles:
el.attrib['style'] = '; '.join(
'%s:%s' % (key, val)
for (key, val) in valid_styles.iteritems())
else:
del el.attrib['style']
_Cleaner.parse_style = parse_style

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mail_tree" model="ir.ui.view">
<field name="model">mail.mail</field>
<field name="inherit_id" ref="mail.view_mail_tree"/>
<field name="arch" type="xml">
<field name="email_from" position="replace"/>
<field name="date" position="after">
<field name="email_from"/>
<field name="email_to"/>
</field>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import email_template_preview

View File

@@ -0,0 +1,43 @@
# Copyright 2019 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp import api, fields, models
class TemplatePreview(models.TransientModel):
_inherit = "email_template.preview"
res_id = fields.Integer(compute='_compute_res_id')
object_id = fields.Reference(selection='_reference_models')
@api.model
def default_get(self, fields):
result = super(TemplatePreview, self).default_get(fields)
if result.get('model_id'):
model = self.env['ir.model'].browse(result['model_id'])
result['object_id'] = model.model
return result
def _reference_models(self):
result = self.default_get(['model_id'])
if result.get('model_id'):
model = self.env['ir.model'].browse(result['model_id'])
return [(model.model, model.name)]
else:
ir_models = self.env['ir.model'].search([('state', '!=', 'manual')])
return [(ir_model.model, ir_model.name)
for ir_model in ir_models
if not ir_model.model.startswith('ir.')]
@api.depends('object_id')
def _compute_res_id(self):
for record in self:
if self.object_id:
record.res_id = self.object_id.id
def send(self):
template = self.env['mail.template'].browse(
self._context['template_id'])
template.send_mail(
self.res_id, force_send=True, raise_exception=True)

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="email_template_preview_form" model="ir.ui.view">
<field name="model">email_template.preview</field>
<field name="inherit_id" ref="mail.email_template_preview_form"/>
<field name="arch" type="xml">
<field name="res_id" position="attributes">
<attribute name="invisible">True</attribute>
</field>
<field name="res_id" position="after">
<field name="object_id"/>
</field>
<footer position="inside">
<button
string="Send"
name="send"
class="btn-primary"
type='object'/>
</footer>
</field>
</record>
</odoo>

View File

@@ -252,9 +252,9 @@ class MrpProduction(models.Model):
for order in self:
if order.product_id.cost_method == 'average':
unit_cost = order.compute_order_unit_cost()
order.unit_cost = unit_cost
order.write({'unit_cost': unit_cost})
logger.info('MO %s: unit_cost=%s', order.name, unit_cost)
for finished_move in order.move_finished_ids.filtered(
lambda x: x.product_id == order.product_id):
finished_move.price_unit = unit_cost
order.move_finished_ids.filtered(
lambda x: x.product_id == order.product_id).write({
'price_unit': unit_cost})
return super(MrpProduction, self).post_inventory()

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-07-16 13:56+0000\n"
"PO-Revision-Date: 2019-07-16 16:01+0200\n"
"POT-Creation-Date: 2020-10-06 13:37+0000\n"
"PO-Revision-Date: 2020-10-06 15:38+0200\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -15,41 +15,52 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"Language: fr\n"
"X-Generator: Poedit 2.0.6\n"
"X-Generator: Poedit 2.3\n"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.mrp_production_form_view
msgid "Are you sure you want to cancel this manufacturing order?"
msgstr "Etes vous sur de vouloir annuler cet ordre de production"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.product_template_form_view_bom_button
msgid "Bill of Materials"
msgstr "Nomenclature"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid "Lot"
msgstr ""
#. module: mrp_usability
#: model:ir.model,name:mrp_usability.model_product_product
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid "Product"
msgstr "Article"
#. module: mrp_usability
#: model:ir.model,name:mrp_usability.model_product_template
msgid "Product Template"
msgstr "Modèle d'article"
#. module: mrp_usability
#: model:ir.model,name:mrp_usability.model_mrp_production
msgid "Production Order"
msgstr "Ordre de production"
msgstr ""
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid "Quantity"
msgstr "Quantité"
msgid "Sold Out Quantity"
msgstr "Quantité en Rupture"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid ""
"These products were unavailable (or partially) while edition of this Manufacturing Order.\n"
" Here is complete quantities for these."
" Here is missing quantities."
msgstr ""
"Les produits ci-dessous étaient indisponibles (complètement ou partiellement) lors de l'édition de l'OF.<br/>\n"
"Voici les quantités totales de ceux-ci."
"Voici les quantités manquantes."
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.view_mrp_bom_filter

View File

@@ -3,9 +3,18 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
from odoo import api, models
class MrpProduction(models.Model):
_inherit = 'mrp.production'
_order = 'id desc'
@api.model
def get_stock_move_sold_out_report(self, move):
lines = move.active_move_line_ids
qty_in_lots = sum([x.product_uom_qty for x in lines])
diff = round(move.product_qty - qty_in_lots, 3)
if diff == 0.0:
return ""
return diff

View File

@@ -16,27 +16,26 @@
t-value="any(o.move_raw_ids.filtered(lambda x: x.product_uom_qty &gt; x.reserved_availability))"/>
<h4 if="has_product_unavailable">
These products were unavailable (or partially) while edition of this Manufacturing Order.
Here is complete quantities for these.
Here is missing quantities.
</h4>
<table class="table table-sm" t-if="o.move_raw_ids and has_product_unavailable">
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Sold Out Quantity</th>
</tr>
</thead>
<tbody>
<t t-set="lines"
<t t-set="moves"
t-value="o.move_raw_ids.filtered(lambda x: x.product_uom_qty &gt; x.reserved_availability)"/>
<t t-foreach="lines" t-as="ml">
<t t-foreach="moves" t-as="m">
<tr>
<td>
<span t-field="ml.product_id"/>
<span t-field="m.product_id"/>
</td>
<td>
<span t-esc="ml.product_uom_qty" t-if="ml.state !='done'"/>
<span t-esc="ml.qty_done" t-if="ml.state =='done'"/>
<span t-field="ml.product_uom" groups="uom.group_uom"/>
<span t-esc="o.get_stock_move_sold_out_report(m)" t-if="m.state !='done'"/>
<span t-field="m.product_uom" groups="uom.group_uom"/>
</td>
</tr>
</t>

View File

@@ -0,0 +1,23 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# Product No Translation module for Odoo
# Copyright (C) 2014 Akretion (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
from . import product

View File

@@ -0,0 +1,40 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# Product No Translation module for Odoo
# Copyright (C) 2014 Akretion (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
{
'name': 'Product no Translation',
'version': '12.0.0.0.1',
'category': 'Sales Management',
'license': 'AGPL-3',
'summary': 'For companies that work with only one language',
'description': """
This module sets the translatable fields of the product object (name,
descriptions) to non-translatable fields.
This change is usefull for companies that work with only one language.
And it reduces the start time of the Point of Sale !
""",
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['product'],
}

View File

@@ -0,0 +1,62 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# Product No Translation module for Odoo
# Copyright (C) 2014 Akretion (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
from odoo import models, fields
class ProductTemplate(models.Model):
_inherit = "product.template"
description_sale = fields.Text(translate=False)
description_purchase = fields.Text(translate=False)
description = fields.Text(translate=False)
name = fields.Char(translate=False)
class ProductCategory(models.Model):
_inherit = "product.category"
name = fields.Char(translate=False)
class ProductAttribute(models.Model):
_inherit = "product.attribute"
name = fields.Char(translate=False)
class ProductAttributeValue(models.Model):
_inherit = "product.attribute.value"
name = fields.Char(translate=False)
class UomCategory(models.Model):
_inherit = 'uom.category'
name = fields.Char(translate=False)
class UomUom(models.Model):
_inherit = 'uom.uom'
name = fields.Char(translate=False)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

View File

@@ -0,0 +1,31 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale no configurator button",
"summary": "Hide 'configure a product' from sale line",
"version": "12.0.1.0.0",
"category": "Usabability",
"website": "www.akretion.com",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": [],
"bin": [],
},
"depends": [
"sale",
],
"data": [
"views/sale_view.xml",
],
"demo": [
],
"qweb": [
]
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_order_view_form" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//control/create[@groups='product.group_product_variant']" position="replace"/>
</field>
</record>
</odoo>

View File

View File

@@ -0,0 +1,30 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale no optional product",
"summary": "Hide optional product",
"version": "12.0.1.0.0",
"category": "Usability",
"website": "www.akretion.com",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": [],
"bin": [],
},
"depends": [
"sale_management",
],
"data": [
"views/product_template_view.xml",
"views/sale_order_view.xml",
],
"demo": [
],
"qweb": [
]
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="product_template_view_form" model="ir.ui.view">
<field name="model">product.template</field>
<field name="inherit_id" ref="sale.product_template_form_view"/>
<field name="arch" type="xml">
<group name="options" position="attributes">
<attribute name="invisible"/>
</group>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_order_form_quote" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_management.sale_order_form_quote"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='sale_order_option_ids']/.." position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
</field>
</record>
</odoo>

View File

View File

@@ -0,0 +1,31 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale no preview button",
"summary": "Hide 'preview' from sale",
"version": "12.0.1.0.0",
"category": "Usabability",
"website": "www.akretion.com",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": [],
"bin": [],
},
"depends": [
"sale",
],
"data": [
"views/sale_view.xml",
],
"demo": [
],
"qweb": [
]
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_order_form" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<button name="preview_sale_order" position="attributes">
<attribute name="invisible">1</attribute>
</button>
</field>
</record>
</odoo>

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2019 Akretion (http://www.akretion.com)
# Copyright 2019-2020 Akretion France (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
@@ -18,6 +18,8 @@ The usability enhancements include:
* activate the refund option by default in return wizard on pickings
* show field *property_cost_method* on product form view
* add ability to select a stock location on the inventory valuation report
@@ -26,6 +28,9 @@ This module has been written by Alexis de Lattre from Akretion <alexis.delattre@
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['stock_account'],
'data': ['wizard/stock_quantity_history_view.xml'],
'data': [
'product_view.xml',
'wizard/stock_quantity_history_view.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2020 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="view_template_property_form" model="ir.ui.view">
<field name="name">stock_account.product.template.form</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="stock_account.view_template_property_form"/>
<field name="arch" type="xml">
<field name="property_cost_method" position="attributes">
<attribute name="invisible">0</attribute>
</field>
</field>
</record>
</odoo>

View File

@@ -19,6 +19,7 @@ class StockPicking(models.Model):
move_type = fields.Selection(track_visibility='onchange')
# Can be used in view to hide some fields depending of pick type
picking_type_code = fields.Selection(related='picking_type_id.code')
is_locked = fields.Boolean(track_visibility='onchange')
@api.multi
def do_unreserve(self):

View File

@@ -139,43 +139,35 @@
</field>
</record>
<!-- Display route in stock moves -->
<!--
<!-- Display advanced fields in stock moves form view -->
<record id="view_move_form" model="ir.ui.view">
<field name="name">stock.usability.stock.move.form</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock.view_move_form" />
<field name="arch" type="xml">
<!--
<field name="state" position="before">
<button type="object" name="button_do_unreserve" string="Unreserve"
groups="stock.group_stock_user"
attrs="{'invisible': [('reserved_quant_ids', '=', [])]}"/>
</field>
<field name="picking_id" position="after">
</field> -->
<field name="origin" position="after">
<field name="picking_id" readonly="1" string="Picking"/>
<field name="inventory_id" readonly="1"/>
</field>
<group name="moved_quants_grp" position="after">
<notebook colspan="2">
<page string="Notes" name="notes">
<field name="note" nolabel="1"/>
</page>
<page string="Advanced Parameters" name="advanced-params" groups="stock.group_stock_manager">
<group name="advanced">
<field name="procurement_id"/>
<field name="route_ids" widget="many2many_tags"/>
<field name="rule_id" readonly="1"/>
<field name="push_rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit"
attrs="{'readonly': [('state', '=', 'done')]}"/>
<field name="reserved_quant_ids" readonly="1"/>
</group>
</page>
</notebook>
<group name="origin_grp" position="after">
<group name="advanced" string="Advanced" groups="stock.group_stock_manager">
<field name="warehouse_id" readonly="1"/>
<field name="route_ids" widget="many2many_tags" readonly="1"/>
<field name="rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="restrict_partner_id" readonly="1"/>
</group>
</group>
</field>
</record>
-->
<record id="view_move_picking_form" model="ir.ui.view">
<field name="name">stock.usability.stock.move.picking.form</field>
@@ -187,32 +179,20 @@
groups="stock.group_stock_user"
states="partially_available,assigned"/>
</field>
<!--
<field name="group_id" position="replace"/>
<group name="moved_quants_grp" position="after">
<notebook colspan="2">
<page string="Notes" name="notes">
<field name="note" nolabel="1"/>
</page>
<page string="Advanced Parameters" name="advanced-params" groups="stock.group_stock_manager">
<group name="advanced">
<field name="partner_id"/>
<field name="procurement_id"/>
<field name="group_id"/>
<field name="route_ids" widget="many2many_tags"/>
<field name="rule_id" readonly="1"/>
<field name="push_rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit" readonly="1"/>
<field name="reserved_quant_ids" readonly="1"/>
</group>
</page>
</notebook>
<group name="quants_grp" position="after">
<group string="Advanced" name="advanced" groups="stock.group_stock_manager">
<field name="origin" readonly="1"/>
<field name="warehouse_id" readonly="1"/>
<field name="group_id" readonly="1"/>
<field name="route_ids" widget="many2many_tags" readonly="1"/>
<field name="rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="restrict_partner_id" readonly="1"/>
</group>
</group>
-->
<field name="move_dest_ids" position="before">
<field name="rule_id" readonly="1"/>
</field>
</field>
</record>

View File

@@ -0,0 +1 @@
from . import wizard

View File

@@ -0,0 +1,44 @@
# Copyright 2020 Akretion France (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'name': 'Stock Valuation XLSX',
'version': '12.0.1.0.0',
'category': 'Tools',
'license': 'AGPL-3',
'summary': 'Generate XLSX reports for past or present stock levels',
'description': """
Stock Valuation XLSX
====================
This module generate nice XLSX stock valuation reports either:
* from a physical inventory,
* from present stock levels (i.e. from quants),
* from past stock levels.
It has several options:
* filter per product category,
* split by lots,
* split by stock location,
* display subtotals per category.
You can access this XLSX stock valuation report either:
* from the menu *Inventory > Reports > Stock Valuation XLSX* (it replaces the native menu *Inventory at Date*)
* from the form view of *validated* inventories (menu *Inventory > Inventory Control > Inventory Adjustments*) via the button *XLSX Valuation Report*.
This module has been written by Alexis de Lattre from Akretion <alexis.delattre@akretion.com>.
""",
'author': "Akretion",
'website': 'http://www.akretion.com',
'depends': ['stock_account'],
'data': [
'wizard/stock_valuation_xlsx_view.xml',
'views/stock_inventory.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2020 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="view_inventory_form" model="ir.ui.view">
<field name="name">xlsx.stock.inventory.form</field>
<field name="model">stock.inventory</field>
<field name="inherit_id" ref="stock.view_inventory_form"/>
<field name="arch" type="xml">
<button name="action_validate" position="after">
<button name="%(stock_valuation_xlsx_action)d" type="action"
states="done" string="XLSX Valuation Report"
context="{'default_source': 'inventory', 'default_inventory_id': active_id, 'default_location_id': location_id}"/>
</button>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import stock_valuation_xlsx

View File

@@ -0,0 +1,534 @@
# Copyright 2020 Akretion France (http://www.akretion.com/)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from odoo.tools import float_is_zero, float_round
from io import BytesIO
from datetime import datetime
import xlsxwriter
import logging
import base64
logger = logging.getLogger(__name__)
class StockValuationXlsx(models.TransientModel):
_name = 'stock.valuation.xlsx'
_description = 'Generate XLSX report for stock valuation'
export_file = fields.Binary(string='XLSX Report', readonly=True)
export_filename = fields.Char(readonly=True)
# I don't use ir.actions.url on v12, because it renders
# the wizard unusable after the first report generation, which creates
# a lot of confusion for users
state = fields.Selection([
('setup', 'Setup'),
('done', 'Done'),
], string='State', default='setup', readonly=True)
warehouse_id = fields.Many2one(
'stock.warehouse', string='Warehouse',
states={'done': [('readonly', True)]})
location_id = fields.Many2one(
'stock.location', string='Root Stock Location', required=True,
domain=[('usage', 'in', ('view', 'internal'))],
default=lambda self: self._default_location(),
states={'done': [('readonly', True)]},
help="The childen locations of the selected locations will "
u"be taken in the valuation.")
categ_ids = fields.Many2many(
'product.category', string='Product Categories',
states={'done': [('readonly', True)]})
source = fields.Selection([
('inventory', 'Physical Inventory'),
('stock', 'Stock Levels'),
], string='Source data', default='stock', required=True,
states={'done': [('readonly', True)]})
inventory_id = fields.Many2one(
'stock.inventory', string='Inventory', domain=[('state', '=', 'done')],
states={'done': [('readonly', True)]})
stock_date_type = fields.Selection([
('present', 'Present'),
('past', 'Past'),
], string='Present or Past', default='present',
states={'done': [('readonly', True)]})
past_date = fields.Datetime(
string='Past Date', states={'done': [('readonly', True)]},
default=fields.Datetime.now)
categ_subtotal = fields.Boolean(
string='Subtotals per Categories', default=True,
states={'done': [('readonly', True)]},
help="Show a subtotal per product category")
standard_price_date = fields.Selection([
('past', 'Past Date or Inventory Date'),
('present', 'Current'),
], default='past', string='Cost Price Date',
states={'done': [('readonly', True)]})
split_by_lot = fields.Boolean(
string='Display Lots', states={'done': [('readonly', True)]})
split_by_location = fields.Boolean(
string='Display Stock Locations', states={'done': [('readonly', True)]})
@api.model
def _default_location(self):
wh = self.env.ref('stock.warehouse0')
return wh.lot_stock_id
@api.onchange('warehouse_id')
def warehouse_id_change(self):
if self.warehouse_id:
self.location_id = self.warehouse_id.view_location_id.id
def _check_config(self, company_id):
self.ensure_one()
if (
self.source == 'stock' and
self.stock_date_type == 'past' and
self.past_date > fields.Datetime.now()):
raise UserError(_("The 'Past Date' must be in the past !"))
if self.source == 'inventory':
if not self.inventory_id:
raise UserError(_("You must select an inventory."))
elif self.inventory_id.state != 'done':
raise UserError(_(
"The selected inventory (%s) is not in done state.")
% self.inventory_id.display_name)
cost_method_real_count = self.env['ir.property'].search([
('company_id', '=', company_id),
('name', '=', 'property_cost_method'),
('value_text', '=', 'real'),
('type', '=', 'selection'),
], count=True)
if cost_method_real_count:
raise UserError(_(
"There are %d properties that have "
"'Costing Method' = 'Real Price'. This costing "
"method is not supported by this module.")
% cost_method_real_count)
def _prepare_product_domain(self):
self.ensure_one()
domain = [('type', '=', 'product')]
if self.categ_ids:
domain += [('categ_id', 'child_of', self.categ_ids.ids)]
return domain
def get_product_ids(self):
self.ensure_one()
domain = self._prepare_product_domain()
products = self.env['product.product'].search(domain)
return products.ids
def _prepare_product_fields(self):
return ['uom_id', 'name', 'default_code', 'categ_id']
def compute_product_data(
self, company_id, in_stock_product_ids, standard_price_past_date=False):
self.ensure_one()
logger.debug('Start compute_product_data')
ppo = self.env['product.product']
ppho = self.env['product.price.history']
fields_list = self._prepare_product_fields()
if not standard_price_past_date:
fields_list.append('standard_price')
products = ppo.search_read([('id', 'in', in_stock_product_ids)], fields_list)
product_id2data = {}
for p in products:
logger.debug('p=%d', p['id'])
# I don't call the native method get_history_price()
# because it requires a browse record and it is too slow
if standard_price_past_date:
history = ppho.search_read([
('company_id', '=', company_id),
('product_id', '=', p['id']),
('datetime', '<=', standard_price_past_date)],
['cost'], order='datetime desc, id desc', limit=1)
standard_price = history and history[0]['cost'] or 0.0
else:
standard_price = p['standard_price']
product_id2data[p['id']] = {'standard_price': standard_price}
for pfield in fields_list:
if pfield.endswith('_id'):
product_id2data[p['id']][pfield] = p[pfield][0]
else:
product_id2data[p['id']][pfield] = p[pfield]
logger.debug('End compute_product_data')
return product_id2data
def id2name(self, product_ids):
logger.debug('Start id2name')
pco = self.env['product.category']
splo = self.env['stock.production.lot']
slo = self.env['stock.location'].with_context(active_test=False)
puo = self.env['uom.uom'].with_context(active_test=False)
categ_id2name = {}
categ_domain = []
if self.categ_ids:
categ_domain = [('id', 'child_of', self.categ_ids.ids)]
for categ in pco.search_read(categ_domain, ['display_name']):
categ_id2name[categ['id']] = categ['display_name']
uom_id2name = {}
uoms = puo.search_read([], ['name'])
for uom in uoms:
uom_id2name[uom['id']] = uom['name']
lot_id2data = {}
lot_fields = ['name']
if hasattr(splo, 'expiry_date'):
lot_fields.append('expiry_date')
lots = splo.search_read(
[('product_id', 'in', product_ids)], lot_fields)
for lot in lots:
lot_id2data[lot['id']] = lot
loc_id2name = {}
locs = slo.search_read(
[('id', 'child_of', self.location_id.id)], ['display_name'])
for loc in locs:
loc_id2name[loc['id']] = loc['display_name']
logger.debug('End id2name')
return categ_id2name, uom_id2name, lot_id2data, loc_id2name
def compute_data_from_inventory(self, product_ids, prec_qty):
self.ensure_one()
logger.debug('Start compute_data_from_inventory')
# Can he modify UoM ?
inv_lines = self.env['stock.inventory.line'].search_read([
('inventory_id', '=', self.inventory_id.id),
('location_id', 'child_of', self.location_id.id),
('product_id', 'in', product_ids),
('product_qty', '>', 0),
], ['product_id', 'location_id', 'prod_lot_id', 'product_qty'])
res = []
in_stock_products = {}
for l in inv_lines:
if not float_is_zero(l['product_qty'], precision_digits=prec_qty):
res.append({
'product_id': l['product_id'][0],
'lot_id': l['prod_lot_id'] and l['prod_lot_id'][0] or False,
'qty': l['product_qty'],
'location_id': l['location_id'][0],
})
in_stock_products[l['product_id'][0]] = True
logger.debug('End compute_data_from_inventory')
return res, in_stock_products
def compute_data_from_present_stock(self, company_id, product_ids, prec_qty):
self.ensure_one()
logger.debug('Start compute_data_from_present_stock')
quants = self.env['stock.quant'].search_read([
('product_id', 'in', product_ids),
('location_id', 'child_of', self.location_id.id),
('company_id', '=', company_id),
], ['product_id', 'lot_id', 'location_id', 'quantity'])
res = []
in_stock_products = {}
for quant in quants:
if not float_is_zero(quant['quantity'], precision_digits=prec_qty):
res.append({
'product_id': quant['product_id'][0],
'lot_id': quant['lot_id'] and quant['lot_id'][0] or False,
'location_id': quant['location_id'][0],
'qty': quant['quantity'],
})
in_stock_products[quant['product_id'][0]] = True
logger.debug('End compute_data_from_present_stock')
return res, in_stock_products
def compute_data_from_past_stock(self, product_ids, prec_qty, past_date):
self.ensure_one()
logger.debug('Start compute_data_from_past_stock past_date=%s', past_date)
ppo = self.env['product.product']
products = ppo.with_context(to_date=past_date, location_id=self.location_id.id).browse(product_ids)
res = []
in_stock_products = {}
for product in products:
qty = product.qty_available
if not float_is_zero(qty, precision_digits=prec_qty):
res.append({
'product_id': product.id,
'qty': qty,
'lot_id': False,
'location_id': False,
})
in_stock_products[product.id] = True
logger.debug('End compute_data_from_past_stock')
return res, in_stock_products
def group_result(self, data, split_by_lot, split_by_location):
logger.debug(
'Start group_result split_by_lot=%s, split_by_location=%s',
split_by_lot, split_by_location)
wdict = {}
for l in data:
key_list = [l['product_id']]
if split_by_lot:
key_list.append(l['lot_id'])
if split_by_location:
key_list.append(l['location_id'])
key = tuple(key_list)
wdict.setdefault(key, dict(product_id=l['product_id'], lot_id=l['lot_id'], location_id=l['location_id'], qty=0.0))
wdict[key]['qty'] += l['qty']
logger.debug('End group_result')
return wdict.values()
def stringify_and_sort_result(
self, product_ids, product_id2data, data,
prec_qty, prec_price, prec_cur_rounding, categ_id2name,
uom_id2name, lot_id2data, loc_id2name):
logger.debug('Start stringify_and_sort_result')
res = []
for l in data:
product_id = l['product_id']
qty = float_round(l['qty'], precision_digits=prec_qty)
standard_price = float_round(
product_id2data[product_id]['standard_price'],
precision_digits=prec_price)
subtotal = float_round(
standard_price * qty, precision_rounding=prec_cur_rounding)
res.append(dict(
product_id2data[product_id],
product_name=product_id2data[product_id]['name'],
loc_name=l['location_id'] and loc_id2name[l['location_id']] or '',
lot_name=l['lot_id'] and lot_id2data[l['lot_id']]['name'] or '',
expiry_date=l['lot_id'] and lot_id2data[l['lot_id']].get('expiry_date'),
qty=qty,
uom_name=uom_id2name[product_id2data[product_id]['uom_id']],
standard_price=standard_price,
subtotal=subtotal,
categ_name=categ_id2name[product_id2data[product_id]['categ_id']],
))
sort_res = sorted(res, key=lambda x: x['product_name'])
logger.debug('End stringify_and_sort_result')
return sort_res
def generate(self):
self.ensure_one()
logger.debug('Start generate XLSX stock valuation report')
splo = self.env['stock.production.lot'].with_context(active_test=False)
prec_qty = self.env['decimal.precision'].precision_get('Product Unit of Measure')
prec_price = self.env['decimal.precision'].precision_get('Product Price')
company = self.env.user.company_id
company_id = company.id
prec_cur_rounding = company.currency_id.rounding
self._check_config(company_id)
product_ids = self.get_product_ids()
if not product_ids:
raise UserError(_("There are no products to analyse."))
split_by_lot = self.split_by_lot
split_by_location = self.split_by_location
if self.source == 'stock':
if self.stock_date_type == 'present':
past_date = False
data, in_stock_products = self.compute_data_from_present_stock(
company_id, product_ids, prec_qty)
elif self.stock_date_type == 'past':
split_by_lot = False
split_by_location = False
past_date = self.past_date
data, in_stock_products = self.compute_data_from_past_stock(
product_ids, prec_qty, past_date)
elif self.source == 'inventory':
past_date = self.inventory_id.date
data, in_stock_products = self.compute_data_from_inventory(product_ids, prec_qty)
standard_price_past_date = past_date
if not (self.source == 'stock' and self.stock_date_type == 'present') and self.standard_price_date == 'present':
standard_price_past_date = False
in_stock_product_ids = list(in_stock_products.keys())
product_id2data = self.compute_product_data(
company_id, in_stock_product_ids,
standard_price_past_date=standard_price_past_date)
data_res = self.group_result(data, split_by_lot, split_by_location)
categ_id2name, uom_id2name, lot_id2data, loc_id2name = self.id2name(product_ids)
res = self.stringify_and_sort_result(
product_ids, product_id2data, data_res, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name, lot_id2data, loc_id2name)
logger.debug('Start create XLSX workbook')
file_data = BytesIO()
workbook = xlsxwriter.Workbook(file_data)
sheet = workbook.add_worksheet('Stock')
styles = self._prepare_styles(workbook, company, prec_price)
cols = self._prepare_cols()
categ_subtotal = self.categ_subtotal
# remove cols that we won't use
if not split_by_lot:
cols.pop('lot_name', None)
cols.pop('expiry_date', None)
if not hasattr(splo, 'expiry_date'):
cols.pop('expiry_date', None)
if not split_by_location:
cols.pop('loc_name', None)
if not categ_subtotal:
cols.pop('categ_subtotal', None)
j = 0
for col, col_vals in sorted(cols.items(), key=lambda x: x[1]['sequence']):
cols[col]['pos'] = j
cols[col]['pos_letter'] = chr(j + 97).upper()
sheet.set_column(j, j, cols[col]['width'])
j += 1
# HEADER
now_dt = fields.Datetime.context_timestamp(self, datetime.now())
now_str = fields.Datetime.to_string(now_dt)
if past_date:
stock_time_utc_dt = past_date
stock_time_dt = fields.Datetime.context_timestamp(self, stock_time_utc_dt)
stock_time_str = fields.Datetime.to_string(stock_time_dt)
else:
stock_time_str = now_str
if standard_price_past_date:
standard_price_date_str = stock_time_str
else:
standard_price_date_str = now_str
i = 0
sheet.write(i, 0, 'Odoo - Stock Valuation', styles['doc_title'])
sheet.set_row(0, 26)
i += 1
sheet.write(i, 0, 'Inventory Date: %s' % stock_time_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Cost Price Date: %s' % standard_price_date_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Stock location (children included): %s' % self.location_id.complete_name, styles['doc_subtitle'])
if self.categ_ids:
i += 1
sheet.write(i, 0, 'Product Categories: %s' % ', '.join([categ.display_name for categ in self.categ_ids]), styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Generated on %s by %s' % (now_str, self.env.user.name), styles['regular_small'])
# TITLE of COLS
i += 2
for col in cols.values():
sheet.write(i, col['pos'], col['title'], styles['col_title'])
i += 1
sheet.write(i, cols['subtotal']['pos'] - 1, _("TOTAL:"), styles['total_title'])
total_row = i
# LINES
if categ_subtotal:
categ_ids = categ_id2name.keys()
else:
categ_ids = [0]
total = 0.0
letter_qty = cols['qty']['pos_letter']
letter_price = cols['standard_price']['pos_letter']
letter_subtotal = cols['subtotal']['pos_letter']
crow = 0
lines = res
for categ_id in categ_ids:
ctotal = 0.0
categ_has_line = False
if categ_subtotal:
# skip a line and save it's position as crow
i += 1
crow = i
lines = filter(lambda x: x['categ_id'] == categ_id, res)
for l in lines:
i += 1
total += l['subtotal']
ctotal += l['subtotal']
categ_has_line = True
subtotal_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1)
sheet.write_formula(i, cols['subtotal']['pos'], subtotal_formula, styles['regular_currency'], l['subtotal'])
for col_name, col in cols.items():
if not col.get('formula'):
if col.get('type') == 'date' and l[col_name]:
l[col_name] = fields.Date.from_string(l[col_name])
sheet.write(i, col['pos'], l[col_name], styles[col['style']])
if categ_subtotal:
if categ_has_line:
sheet.write(crow, 0, categ_id2name[categ_id], styles['categ_title'])
for x in range(cols['categ_subtotal']['pos'] - 1):
sheet.write(crow, x + 1, '', styles['categ_title'])
cformula = '=SUM(%s%d:%s%d)' % (letter_subtotal, crow + 2, letter_subtotal, i + 1)
sheet.write_formula(crow, cols['categ_subtotal']['pos'], cformula, styles['categ_currency'], float_round(ctotal, precision_rounding=prec_cur_rounding))
else:
i -= 1 # go back to skipped line
# Write total
total_formula = '=SUM(%s%d:%s%d)' % (letter_subtotal, total_row + 2, letter_subtotal, i + 1)
sheet.write_formula(total_row, cols['subtotal']['pos'], total_formula, styles['total_currency'], float_round(total, precision_rounding=prec_cur_rounding))
workbook.close()
logger.debug('End create XLSX workbook')
file_data.seek(0)
filename = 'Odoo_stock_%s.xlsx' % stock_time_str.replace(' ', '-').replace(':', '_')
export_file_b64 = base64.b64encode(file_data.read())
self.write({
'state': 'done',
'export_filename': filename,
'export_file': export_file_b64,
})
# action = {
# 'name': _('Stock Valuation XLSX'),
# 'type': 'ir.actions.act_url',
# 'url': "web/content/?model=%s&id=%d&filename_field=export_filename&"
# "field=export_file&download=true&filename=%s" % (
# self._name, self.id, self.export_filename),
# 'target': 'self',
# }
action = self.env['ir.actions.act_window'].for_xml_id(
'stock_valuation_xlsx', 'stock_valuation_xlsx_action')
action['res_id'] = self.id
return action
def _prepare_styles(self, workbook, company, prec_price):
total_bg_color = '#faa03a'
categ_bg_color = '#e1daf5'
col_title_bg_color = '#fff9b4'
regular_font_size = 10
currency_num_format = u'# ### ##0.00 %s' % company.currency_id.symbol
price_currency_num_format = u'# ### ##0.%s %s' % ('0' * prec_price, company.currency_id.symbol)
styles = {
'doc_title': workbook.add_format({
'bold': True, 'font_size': regular_font_size + 10,
'font_color': '#003b6f'}),
'doc_subtitle': workbook.add_format({
'bold': True, 'font_size': regular_font_size}),
'col_title': workbook.add_format({
'bold': True, 'bg_color': col_title_bg_color,
'text_wrap': True, 'font_size': regular_font_size,
'align': 'center',
}),
'total_title': workbook.add_format({
'bold': True, 'text_wrap': True, 'font_size': regular_font_size + 2,
'align': 'right', 'bg_color': total_bg_color}),
'total_currency': workbook.add_format({
'num_format': currency_num_format, 'bg_color': total_bg_color}),
'regular_date': workbook.add_format({'num_format': 'dd/mm/yyyy'}),
'regular_currency': workbook.add_format({'num_format': currency_num_format}),
'regular_price_currency': workbook.add_format({'num_format': price_currency_num_format}),
'regular': workbook.add_format({}),
'regular_small': workbook.add_format({'font_size': regular_font_size - 2}),
'categ_title': workbook.add_format({
'bold': True, 'bg_color': categ_bg_color,
'font_size': regular_font_size}),
'categ_currency': workbook.add_format({
'num_format': currency_num_format, 'bg_color': categ_bg_color}),
'date_title': workbook.add_format({
'bold': True, 'font_size': regular_font_size, 'align': 'right'}),
'date_title_val': workbook.add_format({
'bold': True, 'font_size': regular_font_size}),
}
return styles
def _prepare_cols(self):
cols = {
'default_code': {'width': 18, 'style': 'regular', 'sequence': 10, 'title': _('Product Code')},
'product_name': {'width': 40, 'style': 'regular', 'sequence': 20, 'title': _('Product Name')},
'loc_name': {'width': 25, 'style': 'regular_small', 'sequence': 30, 'title': _('Location Name')},
'lot_name': {'width': 18, 'style': 'regular', 'sequence': 40, 'title': _('Lot')},
'expiry_date': {'width': 11, 'style': 'regular_date', 'sequence': 50, 'title': _('Expiry Date'), 'type': 'date'},
'qty': {'width': 8, 'style': 'regular', 'sequence': 60, 'title': _('Qty')},
'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 70, 'title': _('UoM')},
'standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 80, 'title': _('Cost Price')},
'subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 90, 'title': _('Sub-total'), 'formula': True},
'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 100, 'title': _('Categ Sub-total'), 'formula': True},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 110, 'title': _('Product Category')},
}
return cols

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2020 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="stock_valuation_xlsx_form" model="ir.ui.view">
<field name="name">stock.valuation.xlsx.form</field>
<field name="model">stock.valuation.xlsx</field>
<field name="arch" type="xml">
<form string="Stock valuation XLSX">
<div name="help">
<p>The generated XLSX report has the valuation of stockable products located on the selected stock locations (and their childrens).</p>
</div>
<group name="setup">
<field name="state" invisible="1"/>
<field name="categ_ids" widget="many2many_tags"/>
<field name="warehouse_id"/>
<field name="location_id"/>
<field name="source" widget="radio"/>
<field name="inventory_id" attrs="{'invisible': [('source', '!=', 'inventory')], 'required': [('source', '=', 'inventory')]}"/>
<field name="stock_date_type" attrs="{'invisible': [('source', '!=', 'stock')], 'required': [('source', '=', 'stock')]}" widget="radio"/>
<field name="past_date" attrs="{'invisible': ['|', ('source', '!=', 'stock'), ('stock_date_type', '!=', 'past')], 'required': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
<field name="standard_price_date" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'present')]}" widget="radio"/>
<field name="categ_subtotal" />
<field name="split_by_lot" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}" groups="stock.group_production_lot"/>
<field name="split_by_location" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
</group>
<group name="done" states="done" string="Result">
<field name="export_file" filename="export_filename"/>
<field name="export_filename" invisible="1"/>
</group>
<footer>
<button name="generate" type="object" states="setup"
class="btn-primary" string="Generate"/>
<button special="cancel" string="Cancel" class="btn-default" states="setup"/>
<button special="cancel" string="Close" class="btn-default" states="done"/>
</footer>
</form>
</field>
</record>
<record id="stock_valuation_xlsx_action" model="ir.actions.act_window">
<field name="name">Stock Valuation XLSX</field>
<field name="res_model">stock.valuation.xlsx</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Replace native menu, to avoid user confusion -->
<record id="stock_account.menu_valuation" model="ir.ui.menu">
<field name="action" ref="stock_valuation_xlsx.stock_valuation_xlsx_action"/>
<field name="name">Stock Valuation XLSX</field>
</record>
</odoo>