Compare commits

..

65 Commits

Author SHA1 Message Date
Alexis de Lattre
b973dd2b07 mrp_average_cost : fix bug in multi-uom and when bom qty != 1 2024-03-24 14:54:04 +01:00
Alexis de Lattre
bdb7e04929 sale_stock_usability: only show picking_status on confirmed SO 2023-09-07 18:25:57 +02:00
Alexis de Lattre
04d5b4c5ab sale_down_payment: auto-reconcile when validating invoice 2023-04-25 19:11:58 +02:00
Alexis de Lattre
b2e9f0ecc3 stock_usability: improve stock.move.line form when accessed from menu or product form shortcut 2023-01-10 19:51:44 +01:00
Alexis de Lattre
61c3aaa532 stock_usability: backport improvements from v14 (show reservations on quants tree view)
Also add button to access stock.move.line from lots
2022-12-02 15:37:56 +01:00
Alexis de Lattre
f861685ae8 sale_usability: add client_order_ref in tree view 2022-10-18 15:02:38 +02:00
Alexis de Lattre
0cd6ecd958 purchase_usability: warning when price and/or delay is auto-updated following a qty change
It is similar to the feature present in sale_usability when the qty is
updated and the price unit changes
2022-06-16 18:07:53 +02:00
Alexis de Lattre
0d689b1e95 stock_valuation_xlsx: improve multi-company support
Other small minor improvements/fixes
2022-05-13 17:39:32 +02:00
Alexis de Lattre
8fe2cac892 sale_report: add button to send order acknowledgement 2022-05-06 14:56:12 +02:00
Alexis de Lattre
3b2efaab37 [ADD] module base_dynamic_list 2022-05-06 11:13:03 +02:00
Alexis de Lattre
38a6b1221e account_usability: add name_search on account.incoterms
base_usability: Small improvement in script on res.users
2022-03-04 17:01:38 +01:00
Alexis de Lattre
af806f7bcc stock_usability: usability improvements on inventory and picking form view 2022-01-21 11:24:59 +01:00
Alexis de Lattre
b72d8c44d4 stock_valuation_xlsx: fix crash with using depreciation rules 2021-11-15 16:20:59 +01:00
Alexis de Lattre
49226e814c stock_valuation_xlsx: add depreciation ratios 2021-09-26 23:39:07 +02:00
Benoit
d05b75dbe4 fix migraiton of mail_usability 2021-09-13 21:56:42 +02:00
Chafique
8449c3de80 [FIX] remove notify_email option in README.rst 2021-09-13 21:56:42 +02:00
Chafique
f752fbc9f3 [12.0][MIG] mail_usability 2021-09-13 21:56:42 +02:00
Alexis de Lattre
70d7cbdfda New module link_tracker_usability 2021-09-13 21:56:42 +02:00
Sébastien BEAU
ea4c6df7d6 [FIX] fix helper 2021-09-13 21:56:42 +02:00
Sébastien BEAU
b62f3a3570 [IMP] by default do not send an email when user_id is fill on object 2021-09-13 21:56:42 +02:00
Sébastien BEAU
39855d7b12 [REF] refactor the code in order to split it in several file 2021-09-13 21:56:42 +02:00
Sébastien BEAU
e644b27b7a [IMP] add record_id on mail.message to be able to access to the record 2021-09-13 21:56:42 +02:00
Sébastien BEAU
39bee6e5f7 [IMP] add some extra style css support and add a debugger mode. Update readme 2021-09-13 21:56:42 +02:00
Sébastien BEAU
aec5bb9c91 [IMP] remove the fucking auto_delete!!! 2021-09-13 21:56:42 +02:00
Sébastien BEAU
0f35f8e92b [REF] refactor the code to make it simplifier and avoid hacking the _notify method 2021-09-13 21:56:42 +02:00
Sébastien BEAU
7f64d3c33e [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 2021-09-13 21:56:42 +02:00
Sébastien BEAU
b26b3de7ae [IMP] improve the wizard for testing email, allow to search on object and to send email for real check 2021-09-13 21:56:42 +02:00
David Beal
c3f4775a58 UPD Branding 2021-09-13 21:56:42 +02:00
Alexis de Lattre
d34f273fdb mail_usability: add intermediary level to notify_email parameter of res.partner 2021-09-13 21:56:42 +02:00
Alexis de Lattre
1a8588ea24 Port mail_usability to v10 2021-09-13 21:56:42 +02:00
David Beal
453b6ba626 IMP add icons 2021-09-13 21:56:42 +02:00
Alexis de Lattre
6dbbb98774 Mass rename from __openerp__.py to __manifest__.py 2021-09-13 21:56:42 +02:00
Alexis de Lattre
81b78b83ce Set all modules as uninstallable 2021-09-13 21:56:42 +02:00
Benoit
bb4e5d377c [ADD] mail_usability module for improvements on mails 2021-09-13 21:56:42 +02:00
Alexis de Lattre
8da01d0665 Add script to fix old rounding data bug 2021-09-13 21:50:21 +02:00
Alexis de Lattre
9a67836c50 Improve stock inventory tree view 2021-07-20 13:07:36 +02:00
Alexis de Lattre
8e01d32fe6 Add tracking on some fields 2021-07-20 12:23:41 +02:00
Alexis de Lattre
25a177eb76 Add module sale_mrp_usability (backport of a feature of v14) 2021-05-27 23:58:03 +02:00
Alexis de Lattre
dbbd14f58a sale_down_payment: add a hook that can be used for notifications 2021-05-27 23:08:17 +02:00
Raphaël Valyi
592a82a417 fix message_post v12 API in unreserve (#145)
* fix message_post v12 API in unreserve

* import missing UserError
2021-05-12 20:16:19 +02:00
Pierrick Brun (Akretion)
e56654058e Merge pull request #116 from akretion/12.0-fix_account_type_liquidity_for_stripe
[12.0][FIX] account_usability - Fix stripe installation issue due to wrong account_type on transfert …
2021-04-26 16:15:02 +02:00
Alexis de Lattre
9b89661545 Fix crash in sale_down_payment 2021-03-22 12:10:34 +01:00
Alexis de Lattre
97fe8b463d mrp_usability: add tracking on date fields 2021-03-13 00:24:04 +01:00
David Beal
93272fb571 Update menu_view.xml 2021-03-09 10:50:54 +01:00
Alexis de Lattre
872801fd29 stock_valuation_xlsx: add stock.variation.xlsx report (Up-port from v10)
Up-port from v10 other fixes and improvements up to 2021-02-26
2021-02-26 11:09:51 +01:00
Alexis de Lattre
09b73d1994 mrp_usability: remove readonly on location field 2021-01-29 21:55:26 +01:00
Alexis de Lattre
2fe0ad51c7 mrp_usability: add source and dest loc on stock.move.line form view of finished product 2021-01-29 21:54:23 +01:00
Alexis de Lattre
aa804e2dfd Add track_visibility='onchange' on all importants fields of mrp.bom 2021-01-13 15:19:29 +01:00
Alexis de Lattre
05ac567c7a Show location_id in tree view of stock.move.line in raw materials tab 2021-01-11 19:01:24 +01:00
Alexis de Lattre
4076946f48 mrp_usability: add location_dest_id in the tree view of finished product on MO 2020-12-04 18:29:54 +01:00
Alexis de Lattre
2a3ca2d35c sale_usability: up-port warning on price update when qty is changed 2020-12-04 10:49:35 +01:00
Alexis de Lattre
614c1a7ac2 Remove dead file 2020-12-02 20:59:41 +01:00
Alexis de Lattre
03a57798b6 Add drill-through on purchase.report 2020-11-27 10:05:36 +01:00
Florian da Costa
22e9234d23 [FIX] Import failure if element is not part of the domain 2020-11-25 15:44:13 +01:00
Alexis de Lattre
ac89271d5b mrp_average_cost: Remove ACL that depend on sales_team (this module doesn't
depend on sales_team)
2020-11-15 14:36:10 +01:00
Alexis de Lattre
261442d903 Work it work with mrp_subcontracting (without depending on it) 2020-11-12 18:21:04 +01:00
Alexis de Lattre
d63c4b2433 [FIX] stock_valuation_xlsx: bad context key for past stock level 2020-11-03 17:24:36 +01:00
Florian
4dadc8047e Merge pull request #132 from akretion/12-account-fiscal-pos-pay-receivable
12 account fiscal pos pay receivable
2020-11-02 11:54:23 +01:00
Florian da Costa
34ec0dfa27 Black on account_fiscal_position_payable_receivable 2020-11-02 11:33:07 +01:00
Florian da Costa
7a6600431c Migrate account_fiscal_position_payable_receivable to v12 2020-11-02 11:32:38 +01:00
Alexis de Lattre
341717b75d Port account_fiscal_position_payable_receivable to v10 2020-11-02 11:25:52 +01:00
Raphaël Valyi
1061111f9b Merge pull request #130 from akretion/12.0-remove_oe_title_width
[FIX] Remove hard-coded width on product's title
2020-10-31 21:58:41 -03:00
Alexis de Lattre
d28a40e035 Update comment 2020-10-31 17:50:54 +01:00
clementmbr
3f06231c22 [FIX] Remove hard-coded width on product's title 2020-10-20 21:16:37 -03:00
hparfr
d2b63236e3 Fix stripe installation issue due to wrong account_type on transfert account
template
2020-04-29 23:00:08 +02:00
53 changed files with 1820 additions and 198 deletions

View File

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

View File

@@ -0,0 +1,23 @@
# © 2016-2017 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Account Fiscal Position Payable Receivable",
"version": "12.0.1.0.0",
"category": "Accounting & Finance",
"license": "AGPL-3",
"summary": "Configure payable/receivable accounts on fiscal positions",
"description": """
Account Fiscal Position Payable Receivable
==========================================
This module allows to configure a special *Partner Receivable Account* and a special *Partner Payable Account* on fiscal positions. This is used in the onchange of the fiscal position of partners.
This module has been written by Alexis de Lattre from Akretion <alexis.delattre@akretion.com>.
""",
"author": "Akretion",
"website": "http://www.akretion.com",
"depends": ["account"],
"data": ["views/account_fiscal_position_view.xml"],
"installable": True,
}

View File

@@ -0,0 +1,2 @@
from . import account_fiscal_position
from . import res_partner

View File

@@ -0,0 +1,21 @@
# © 2016-2017 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields
class AccountFiscalPosition(models.Model):
_inherit = "account.fiscal.position"
receivable_account_id = fields.Many2one(
"account.account",
string="Partner Receivable Account",
company_dependent=True,
domain=[("internal_type", "=", "receivable")],
)
payable_account_id = fields.Many2one(
"account.account",
string="Partner Payable Account",
company_dependent=True,
domain=[("internal_type", "=", "payable")],
)

View File

@@ -0,0 +1,25 @@
# © 2016-2017 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, api
class ResPartner(models.Model):
_inherit = "res.partner"
@api.onchange("property_account_position_id")
def fiscal_position_receivable_payable_change(self):
fp = self.property_account_position_id
ipo = self.env["ir.property"]
if fp.receivable_account_id:
self.property_account_receivable_id = fp.receivable_account_id
else:
self.property_account_receivable_id = ipo.get(
"property_account_receivable_id", "res.partner"
)
if fp.payable_account_id:
self.property_account_payable_id = fp.payable_account_id
else:
self.property_account_payable_id = ipo.get(
"property_account_payable_id", "res.partner"
)

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
© 2016 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="view_account_position_form" model="ir.ui.view">
<field name="name">receivable_payable.fiscal_position_form</field>
<field name="model">account.fiscal.position</field>
<field name="inherit_id" ref="account.view_account_position_form" />
<field name="arch" type="xml">
<field name="company_id" position="after">
<field name="receivable_account_id"/>
<field name="payable_account_id"/>
</field>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.tools import float_compare, float_is_zero from odoo.tools import float_compare, float_is_zero, float_round
from odoo.tools.misc import formatLang from odoo.tools.misc import formatLang
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression from odoo.osv import expression
@@ -318,7 +318,6 @@ class AccountAccount(models.Model):
logger.info("END of the script 'fix bank and cash account types'") logger.info("END of the script 'fix bank and cash account types'")
return True return True
# TODO mig to v12
@api.model @api.model
def create_account_groups(self, level=2, name_prefix=u'Comptes '): def create_account_groups(self, level=2, name_prefix=u'Comptes '):
'''Should be launched by a script. Make sure the account_group module is installed '''Should be launched by a script. Make sure the account_group module is installed
@@ -408,6 +407,58 @@ class AccountMove(models.Model):
move.default_credit = default_credit move.default_credit = default_credit
move.default_debit = default_debit move.default_debit = default_debit
@api.model
def _fix_debit_credit_round_bug(self):
logger.info('START script _fix_debit_credit_round_bug')
moves = self.sudo().search([]) # sudo to search in all companies
bug_move_ids = []
for move in moves:
buggy = False
for l in move.line_ids:
if not float_is_zero(l.debit, precision_digits=2):
debit_rounded = float_round(l.debit, precision_digits=2)
if float_compare(l.debit, debit_rounded, precision_digits=6):
logger.info('Bad move to fix ID %d company_id %d name %s ref %s date %s journal %s (line ID %d debit=%s)', move.id, move.company_id.id, move.name, move.ref, move.date, move.journal_id.code, l.id, l.debit)
buggy = True
break
else:
credit_rounded = float_round(l.credit, precision_digits=2)
if float_compare(l.credit, credit_rounded, precision_digits=6):
logger.info('Bad move to fix ID %d company_id %d name %s ref %s date %s journal %s (line ID %d credit=%s)', move.id, move.company_id.id, move.name, move.ref, move.date, move.journal_id.code, l.id, l.credit)
buggy = True
break
if buggy:
bug_move_ids.append(move.id)
bal = 0.0
max_credit = (False, 0)
for l in move.line_ids:
if not float_is_zero(l.debit, precision_digits=2):
new_debit = float_round(l.debit, precision_digits=2)
self._cr.execute(
'UPDATE account_move_line set debit=%s, balance=%s where id=%s',
(new_debit, new_debit, l.id))
bal -= new_debit
elif not float_is_zero(l.credit, precision_digits=2):
new_credit = float_round(l.credit, precision_digits=2)
self._cr.execute(
'UPDATE account_move_line set credit=%s, balance=%s where id=%s',
(new_credit, new_credit * -1, l.id))
bal += new_credit
if new_credit > max_credit[1]:
max_credit = (l, new_credit)
if not float_is_zero(bal, precision_digits=2):
assert abs(bal) < 0.05
l = max_credit[0]
new_credit = max_credit[1]
new_new_credit = float_round(new_credit - bal, precision_digits=2)
assert new_new_credit > 0
self._cr.execute(
'UPDATE account_move_line set credit=%s, balance=%s where id=%s',
(new_new_credit, new_new_credit * -1, l.id))
logger.info('Move ID %d fixed', move.id)
logger.info('%d buggy moves fixed (IDs: %s)', len(bug_move_ids), bug_move_ids)
logger.info('END _fix_debit_credit_round_bug')
class AccountMoveLine(models.Model): class AccountMoveLine(models.Model):
_inherit = 'account.move.line' _inherit = 'account.move.line'
@@ -673,6 +724,16 @@ class AccountIncoterms(models.Model):
res.append((rec.id, '[%s] %s' % (rec.code, rec.name))) res.append((rec.id, '[%s] %s' % (rec.code, rec.name)))
return res return res
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=80):
if args is None:
args = []
if name and operator == 'ilike':
recs = self.search([('code', '=', name)] + args, limit=limit)
if recs:
return recs.name_get()
return super().name_search(name=name, args=args, operator=operator, limit=limit)
class AccountReconciliation(models.AbstractModel): class AccountReconciliation(models.AbstractModel):
_inherit = 'account.reconciliation.widget' _inherit = 'account.reconciliation.widget'
@@ -694,8 +755,9 @@ class AccountReconciliation(models.AbstractModel):
st_line, aml_accounts, partner_id, st_line, aml_accounts, partner_id,
excluded_ids=excluded_ids, search_str=search_str) excluded_ids=excluded_ids, search_str=search_str)
# We want to replace a domain item by another one # We want to replace a domain item by another one
position = domain.index(('payment_id', '<>', False)) if ('payment_id', '<>', False) in domain:
domain[position] = ['journal_id', '=', st_line.journal_id.id] position = domain.index(('payment_id', '<>', False))
domain[position] = ['journal_id', '=', st_line.journal_id.id]
return domain return domain
@@ -704,3 +766,22 @@ class ResConfigSettings(models.TransientModel):
transfer_account_id = fields.Many2one( transfer_account_id = fields.Many2one(
related='company_id.transfer_account_id', readonly=False) related='company_id.transfer_account_id', readonly=False)
class AccountChartTemplate(models.Model):
_inherit = "account.chart.template"
@api.model
def _prepare_transfer_account_template(self):
"""Change the type of default account in order to be
compliant with _check_account_type_on_bank_journal
Used at installation of payment modules like stripe
See https://github.com/akretion/odoo-usability/issues/115
"""
vals = super()._prepare_transfer_account_template()
current_assets_type = self.env.ref(
'account.data_account_type_liquidity', raise_if_not_found=False)
vals.update({
'user_type_id': current_assets_type and current_assets_type.id or False,
})
return vals

View File

@@ -31,6 +31,7 @@ This modules adds the following functions:
* don't attach PDF upon invoice report generation on supplier invoices/refunds * don't attach PDF upon invoice report generation on supplier invoices/refunds
* Add filter on debit and credit amount for Move Lines * Add filter on debit and credit amount for Move Lines
* Add supplier invoice number in invoice tree view * Add supplier invoice number in invoice tree view
* Change type from current_assets to liquidity for transfert account template.
Together with this module, I recommend the use of the following modules: Together with this module, I recommend the use of the following modules:

View File

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

View File

@@ -0,0 +1,62 @@
# Copyright 2020-2022 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': 'Base Dynamic List',
'version': '12.0.1.0.0',
'category': 'Tools',
'license': 'AGPL-3',
'summary': 'Dynamic lists',
'description': """
Base Dynamic List
=================
Very often during an Odoo implementation, we need to add selection fields on a native objet, and we don't want to have a hard-coded selection list (fields.Selection), but a selection list that can be changed by users (Many2one field). For that, the developper needs to add a new object (with just a 'name' and 'sequence' field) with a form/tree view. The goal of this module is to speed-up this process by defining a dynamic list object that already has all the required views.
This module provides several ready-to-go objects:
* simple list : fields *name*, *sequence* and *active*
* translatable list : fields *name* with translate=True, *sequence* and *active*
* code list : fields *code* (unique), *name*, *sequence* and *active*
* translatable code list : fields *code* (unique), *name* with translate=True, *sequence* and *active*
These objects are readable by the employee group. The system group has full rights on it.
To use it, you need to do 2 or 3 things :
1) Add an entry in the domain field and the object you selected:
domain = fields.Selection(selection_add=[('risk.type', "Risk Type")], ondelete={"risk.type": "cascade"})
2) Add the many2one field on your object:
risk_type_id = fields.Many2one(
'dynamic.list', string="Risk Type",
ondelete='restrict', domain=[('domain', '=', 'risk.type')])
3) Optionally, you can add a dedicated action and a menu entry (otherwize, you can use the generic menu entry under *Settings > Technical > Dynamic Lists*:
<record id="dynamic_list_risk_type_action" model="ir.actions.act_window">
<field name="name">Risk Type</field>
<field name="res_model">dynamic.list</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('domain', '=', 'risk.type')]</field>
<field name="context">{'default_domain': 'risk.type'}</field>
</record>
<menuitem id="dynamic_list_risk_type_menu" action="dynamic_list_risk_type_action"
parent="parent_menu_xmlid"/>
Limitation: when you want to have different access rights on these lists depending on the source object, you should prefer to use dedicated objects.
""",
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['base'],
'data': [
'security/ir.model.access.csv',
'views/dynamic_list.xml',
],
'installable': True,
}

View File

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

View File

@@ -0,0 +1,115 @@
# Copyright 2020-2022 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 api, fields, models
class DynamicList(models.Model):
_name = 'dynamic.list'
_description = 'Dynamic List (non translatable)'
_order = 'sequence, id'
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_name_uniq',
'unique(domain, name)',
'This entry already exists!'
)]
class DynamicListTranslate(models.Model):
_name = 'dynamic.list.translate'
_description = 'Translatable Dynamic List'
_order = 'sequence, id'
name = fields.Char(translate=True, required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_name_uniq',
'unique(domain, name)',
'This entry already exists!'
)]
class DynamicListCode(models.Model):
_name = 'dynamic.list.code'
_description = 'Dynamic list with code'
_order = 'sequence, id'
code = fields.Char(required=True)
name = fields.Char(translate=True, required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_code_uniq',
'unique(domain, code)',
'This code already exists!'
)]
@api.depends('code', 'name')
def name_get(self):
res = []
for rec in self:
res.append((rec.id, '[%s] %s' % (rec.code, rec.name)))
return res
@api.model
def name_search(
self, name='', args=None, operator='ilike', limit=80):
if args is None:
args = []
if name and operator == 'ilike':
recs = self.search(
[('code', '=', name)] + args, limit=limit)
if recs:
return recs.name_get()
return super().name_search(
name=name, args=args, operator=operator, limit=limit)
class DynamicListCodeTranslate(models.Model):
_name = 'dynamic.list.code.translate'
_description = 'Translatable dynamic list with code'
_order = 'sequence, id'
code = fields.Char(required=True)
name = fields.Char(translate=True, required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_code_uniq',
'unique(domain, code)',
'This code already exists!'
)]
@api.depends('code', 'name')
def name_get(self):
res = []
for rec in self:
res.append((rec.id, '[%s] %s' % (rec.code, rec.name)))
return res
@api.model
def name_search(
self, name='', args=None, operator='ilike', limit=80):
if args is None:
args = []
if name and operator == 'ilike':
recs = self.search(
[('code', '=', name)] + args, limit=limit)
if recs:
return recs.name_get()
return super().name_search(
name=name, args=args, operator=operator, limit=limit)

View File

@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dynamic_list_read,Read access on dynamic.list to employees,model_dynamic_list,base.group_user,1,0,0,0
access_dynamic_list_full,Full access to dynamic.list to System group,model_dynamic_list,base.group_system,1,1,1,1
access_dynamic_list_translate_read,Read access on dynamic.list.translate to employees,model_dynamic_list_translate,base.group_user,1,0,0,0
access_dynamic_list_translate_full,Full access to dynamic.list.translate to System group,model_dynamic_list_translate,base.group_system,1,1,1,1
access_dynamic_list_code_read,Read access on dynamic.list.code to employees,model_dynamic_list_code,base.group_user,1,0,0,0
access_dynamic_list_code_full,Full access to dynamic.list.code to System group,model_dynamic_list_code,base.group_system,1,1,1,1
access_dynamic_list_code_translate_read,Read access on dynamic.list.code.translate to employees,model_dynamic_list_code_translate,base.group_user,1,0,0,0
access_dynamic_list_code_translate_full,Full access to dynamic.list.code.translate to System group,model_dynamic_list_code_translate,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dynamic_list_read Read access on dynamic.list to employees model_dynamic_list base.group_user 1 0 0 0
3 access_dynamic_list_full Full access to dynamic.list to System group model_dynamic_list base.group_system 1 1 1 1
4 access_dynamic_list_translate_read Read access on dynamic.list.translate to employees model_dynamic_list_translate base.group_user 1 0 0 0
5 access_dynamic_list_translate_full Full access to dynamic.list.translate to System group model_dynamic_list_translate base.group_system 1 1 1 1
6 access_dynamic_list_code_read Read access on dynamic.list.code to employees model_dynamic_list_code base.group_user 1 0 0 0
7 access_dynamic_list_code_full Full access to dynamic.list.code to System group model_dynamic_list_code base.group_system 1 1 1 1
8 access_dynamic_list_code_translate_read Read access on dynamic.list.code.translate to employees model_dynamic_list_code_translate base.group_user 1 0 0 0
9 access_dynamic_list_code_translate_full Full access to dynamic.list.code.translate to System group model_dynamic_list_code_translate base.group_system 1 1 1 1

View File

@@ -0,0 +1,240 @@
<?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>
<menuitem id="dynamic_list_root_menu" name="Dynamic Lists" parent="base.menu_custom" sequence="100"/>
<record id="dynamic_list_form" model="ir.ui.view">
<field name="model">dynamic.list</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_tree" model="ir.ui.view">
<field name="model">dynamic.list</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_search" model="ir.ui.view">
<field name="model">dynamic.list</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_action" model="ir.actions.act_window">
<field name="name">Simple List</field>
<field name="res_model">dynamic.list</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_menu" action="dynamic_list_action" parent="dynamic_list_root_menu" sequence="10"/>
<record id="dynamic_list_translate_form" model="ir.ui.view">
<field name="model">dynamic.list.translate</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_translate_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_translate_tree" model="ir.ui.view">
<field name="model">dynamic.list.translate</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_translate_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_translate_search" model="ir.ui.view">
<field name="model">dynamic.list.translate</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_translate_action" model="ir.actions.act_window">
<field name="name">Translatable Simple List</field>
<field name="res_model">dynamic.list.translate</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_translate_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_translate_menu" action="dynamic_list_translate_action" parent="dynamic_list_root_menu" sequence="20"/>
<record id="dynamic_list_code_form" model="ir.ui.view">
<field name="model">dynamic.list.code</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_code_tree" model="ir.ui.view">
<field name="model">dynamic.list.code</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_code_search" model="ir.ui.view">
<field name="model">dynamic.list.code</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Name or Code" filter_domain="['|', ('name', 'ilike', self), ('code', 'ilike', self)]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<field name="code"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_code_action" model="ir.actions.act_window">
<field name="name">Code List</field>
<field name="res_model">dynamic.list.code</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_code_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_code_menu" action="dynamic_list_code_action" parent="dynamic_list_root_menu" sequence="30"/>
<record id="dynamic_list_code_translate_form" model="ir.ui.view">
<field name="model">dynamic.list.code.translate</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_translate_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_code_translate_tree" model="ir.ui.view">
<field name="model">dynamic.list.code.translate</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_translate_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_code_translate_search" model="ir.ui.view">
<field name="model">dynamic.list.code.translate</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Name or Code" filter_domain="['|', ('name', 'ilike', self), ('code', 'ilike', self)]"/>
<field name="code"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_code_translate_action" model="ir.actions.act_window">
<field name="name">Translatable Code List</field>
<field name="res_model">dynamic.list.code.translate</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_code_translate_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_code_translate_menu" action="dynamic_list_code_translate_action" parent="dynamic_list_root_menu" sequence="40"/>
</odoo>

View File

@@ -23,12 +23,9 @@ class ResUsers(models.Model):
@api.model @api.model
def _script_partners_linked_to_users_no_company(self): def _script_partners_linked_to_users_no_company(self):
if self.env.user.id != SUPERUSER_ID:
raise UserError(_('You must run this script as admin user'))
logger.info( logger.info(
'START to set company_id=False on partners related to users') 'START to set company_id=False on partners related to users')
users = self.search( users = self.sudo().with_context(active_test=False).search([])
['|', ('active', '=', True), ('active', '=', False)])
for user in users: for user in users:
if user.partner_id.company_id: if user.partner_id.company_id:
user.partner_id.company_id = False user.partner_id.company_id = False
@@ -37,4 +34,3 @@ class ResUsers(models.Model):
user.login, user.id) user.login, user.id)
logger.info( logger.info(
'END setting company_id=False on partners related to users') 'END setting company_id=False on partners related to users')
return True

View File

@@ -2,7 +2,7 @@
<odoo> <odoo>
<menuitem id="conf_tech" parent="base.menu_administration" name="🧰" groups="base.group_erp_manager" sequence="100"/> <menuitem id="conf_tech" parent="base.menu_administration" name="🧰" groups="base.group_erp_manager" sequence="1"/>
<menuitem id="model" name="Model" parent="conf_tech" action="base.action_model_model" sequence="10"/> <menuitem id="model" name="Model" parent="conf_tech" action="base.action_model_model" sequence="10"/>
<menuitem id="view" name="View" parent="conf_tech" action="base.action_ui_view" sequence="20" /> <menuitem id="view" name="View" parent="conf_tech" action="base.action_ui_view" sequence="20" />
<menuitem id="rec_rule" name="Record Rule" parent="conf_tech" action="base.action_rule" sequence="30" /> <menuitem id="rec_rule" name="Record Rule" parent="conf_tech" action="base.action_rule" sequence="30" />

View File

@@ -17,7 +17,7 @@ class MrpBomLabourLine(models.Model):
bom_id = fields.Many2one( bom_id = fields.Many2one(
comodel_name='mrp.bom', comodel_name='mrp.bom',
string='Labour Lines', string='Bill of Material',
ondelete='cascade') ondelete='cascade')
labour_time = fields.Float( labour_time = fields.Float(
@@ -75,7 +75,7 @@ class MrpBom(models.Model):
total_labour_cost = fields.Float( total_labour_cost = fields.Float(
compute='_compute_total_labour_cost', readonly=True, compute='_compute_total_labour_cost', readonly=True,
digits=dp.get_precision('Product Price'), digits=dp.get_precision('Product Price'),
string="Total Labour Cost", store=True) string="Total Labour Cost", store=True, track_visibility='onchange')
extra_cost = fields.Float( extra_cost = fields.Float(
string='Extra Cost', track_visibility='onchange', string='Extra Cost', track_visibility='onchange',
digits=dp.get_precision('Product Price'), digits=dp.get_precision('Product Price'),
@@ -117,15 +117,18 @@ class MrpBom(models.Model):
wproduct = bom.product_id wproduct = bom.product_id
if not wproduct: if not wproduct:
wproduct = bom.product_tmpl_id wproduct = bom.product_tmpl_id
bom_cost_per_unit_in_product_uom = 0
qty_product_uom = bom.product_uom_id._compute_quantity(bom.product_qty, wproduct.uom_id)
if qty_product_uom:
bom_cost_per_unit_in_product_uom = bom.total_cost / qty_product_uom
if float_compare( if float_compare(
wproduct.standard_price, bom.total_cost, wproduct.standard_price, bom_cost_per_unit_in_product_uom,
precision_digits=precision): precision_digits=precision):
wproduct.with_context().write( wproduct.with_context().write(
{'standard_price': bom.total_cost}) {'standard_price': bom_cost_per_unit_in_product_uom})
logger.info( logger.info(
'Cost price updated to %s on product %s', 'Cost price updated to %s on product %s',
bom.total_cost, wproduct.display_name) bom_cost_per_unit_in_product_uom, wproduct.display_name)
return True
class MrpBomLine(models.Model): class MrpBomLine(models.Model):
@@ -196,6 +199,7 @@ class MrpProduction(models.Model):
mo_total_price = 0.0 # In the UoM of the M0 mo_total_price = 0.0 # In the UoM of the M0
labor_cost_per_unit = 0.0 # In the UoM of the product labor_cost_per_unit = 0.0 # In the UoM of the product
extra_cost_per_unit = 0.0 # In the UoM of the product extra_cost_per_unit = 0.0 # In the UoM of the product
subcontract_cost_per_unit = 0.0
# I read the raw materials MO, not on BOM, in order to make # I read the raw materials MO, not on BOM, in order to make
# it work with the "dynamic" BOMs (few raw material are auto-added # it work with the "dynamic" BOMs (few raw material are auto-added
# on the fly on MO) # on the fly on MO)
@@ -231,6 +235,13 @@ class MrpProduction(models.Model):
assert bom_qty_product_uom > 0, 'BoM qty should be positive' assert bom_qty_product_uom > 0, 'BoM qty should be positive'
labor_cost_per_unit = bom.total_labour_cost / bom_qty_product_uom labor_cost_per_unit = bom.total_labour_cost / bom_qty_product_uom
extra_cost_per_unit = bom.extra_cost / bom_qty_product_uom extra_cost_per_unit = bom.extra_cost / bom_qty_product_uom
if bom.type == 'subcontract':
one_finished_move = self.env['stock.move'].search([
('production_id', '=', self.id),
('product_id', '=', self.product_id.id),
('move_dest_ids', '!=', False)], limit=1)
if one_finished_move:
subcontract_cost_per_unit = one_finished_move.move_dest_ids[0].price_unit
# mo_standard_price and labor_cost_per_unit are # mo_standard_price and labor_cost_per_unit are
# in the UoM of the product (not of the MO/BOM) # in the UoM of the product (not of the MO/BOM)
mo_qty_product_uom = self.product_uom_id._compute_quantity( mo_qty_product_uom = self.product_uom_id._compute_quantity(
@@ -238,10 +249,13 @@ class MrpProduction(models.Model):
assert mo_qty_product_uom > 0, 'MO qty should be positive' assert mo_qty_product_uom > 0, 'MO qty should be positive'
mo_standard_price = mo_total_price / mo_qty_product_uom mo_standard_price = mo_total_price / mo_qty_product_uom
logger.info( logger.info(
'MO %s: labor_cost_per_unit=%s extra_cost_per_unit=%s', 'MO %s: labor_cost_per_unit=%s extra_cost_per_unit=%s '
self.name, labor_cost_per_unit, extra_cost_per_unit) 'subcontract_cost_per_unit=%s',
self.name, labor_cost_per_unit, extra_cost_per_unit,
subcontract_cost_per_unit)
mo_standard_price += labor_cost_per_unit mo_standard_price += labor_cost_per_unit
mo_standard_price += extra_cost_per_unit mo_standard_price += extra_cost_per_unit
mo_standard_price += subcontract_cost_per_unit
return mo_standard_price return mo_standard_price
def post_inventory(self): def post_inventory(self):

View File

@@ -1,9 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_labour_cost_profile_read,Read access on labour.cost.profile to MRP user,model_labour_cost_profile,mrp.group_mrp_user,1,0,0,0 access_labour_cost_profile_read,Read access on labour.cost.profile to MRP user,model_labour_cost_profile,mrp.group_mrp_user,1,0,0,0
access_labour_cost_profile_read_sale,Read access on labour.cost.profile to Sale user,model_labour_cost_profile,sales_team.group_sale_salesman,1,0,0,0
access_labour_cost_profile_read_stock,Read access on labour.cost.profile to Stock user,model_labour_cost_profile,stock.group_stock_user,1,0,0,0 access_labour_cost_profile_read_stock,Read access on labour.cost.profile to Stock user,model_labour_cost_profile,stock.group_stock_user,1,0,0,0
access_labour_cost_profile_full,Full access on labour.cost.profile to MRP manager,model_labour_cost_profile,mrp.group_mrp_manager,1,1,1,1 access_labour_cost_profile_full,Full access on labour.cost.profile to MRP manager,model_labour_cost_profile,mrp.group_mrp_manager,1,1,1,1
access_mrp_bom_labour_line_read,Read access on mrp.bom.labour.line to MRP user,model_mrp_bom_labour_line,mrp.group_mrp_user,1,0,0,0 access_mrp_bom_labour_line_read,Read access on mrp.bom.labour.line to MRP user,model_mrp_bom_labour_line,mrp.group_mrp_user,1,0,0,0
access_mrp_bom_labour_line_read_sale,Read access on mrp.bom.labour.line to Sale user,model_mrp_bom_labour_line,sales_team.group_sale_salesman,1,0,0,0
access_mrp_bom_labour_line_read_stock,Read access on mrp.bom.labour.line to Stock user,model_mrp_bom_labour_line,stock.group_stock_user,1,0,0,0 access_mrp_bom_labour_line_read_stock,Read access on mrp.bom.labour.line to Stock user,model_mrp_bom_labour_line,stock.group_stock_user,1,0,0,0
access_mrp_bom_labour_line_full,Full access on mrp.bom.labour.line to MRP manager,model_mrp_bom_labour_line,mrp.group_mrp_manager,1,1,1,1 access_mrp_bom_labour_line_full,Full access on mrp.bom.labour.line to MRP manager,model_mrp_bom_labour_line,mrp.group_mrp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_labour_cost_profile_read Read access on labour.cost.profile to MRP user model_labour_cost_profile mrp.group_mrp_user 1 0 0 0
access_labour_cost_profile_read_sale Read access on labour.cost.profile to Sale user model_labour_cost_profile sales_team.group_sale_salesman 1 0 0 0
3 access_labour_cost_profile_read_stock Read access on labour.cost.profile to Stock user model_labour_cost_profile stock.group_stock_user 1 0 0 0
4 access_labour_cost_profile_full Full access on labour.cost.profile to MRP manager model_labour_cost_profile mrp.group_mrp_manager 1 1 1 1
5 access_mrp_bom_labour_line_read Read access on mrp.bom.labour.line to MRP user model_mrp_bom_labour_line mrp.group_mrp_user 1 0 0 0
access_mrp_bom_labour_line_read_sale Read access on mrp.bom.labour.line to Sale user model_mrp_bom_labour_line sales_team.group_sale_salesman 1 0 0 0
6 access_mrp_bom_labour_line_read_stock Read access on mrp.bom.labour.line to Stock user model_mrp_bom_labour_line stock.group_stock_user 1 0 0 0
7 access_mrp_bom_labour_line_full Full access on mrp.bom.labour.line to MRP manager model_mrp_bom_labour_line mrp.group_mrp_manager 1 1 1 1

View File

@@ -1,15 +1,18 @@
# © 2015-2016 Akretion (http://www.akretion.com) # Copyright 2015-2021 Akretion (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com> # @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models from odoo import api, fields, models
class MrpProduction(models.Model): class MrpProduction(models.Model):
_inherit = 'mrp.production' _inherit = 'mrp.production'
_order = 'id desc' _order = 'id desc'
date_planned_start = fields.Datetime(track_visibility='onchange')
date_planned_finished = fields.Datetime(track_visibility='onchange')
@api.model @api.model
def get_stock_move_sold_out_report(self, move): def get_stock_move_sold_out_report(self, move):
lines = move.active_move_line_ids lines = move.active_move_line_ids
@@ -18,3 +21,17 @@ class MrpProduction(models.Model):
if diff == 0.0: if diff == 0.0:
return "" return ""
return diff return diff
class MrpBom(models.Model):
_inherit = 'mrp.bom'
code = fields.Char(track_visibility='onchange')
type = fields.Selection(track_visibility='onchange')
product_tmpl_id = fields.Many2one(track_visibility='onchange')
product_id = fields.Many2one(track_visibility='onchange')
product_qty = fields.Float(track_visibility='onchange')
product_uom_id = fields.Many2one(track_visibility='onchange')
routing_id = fields.Many2one(track_visibility='onchange')
ready_to_produce = fields.Selection(track_visibility='onchange')
picking_type_id = fields.Many2one(track_visibility='onchange')

View File

@@ -26,6 +26,26 @@
<button name="action_cancel" type="object" position="attributes"> <button name="action_cancel" type="object" position="attributes">
<attribute name="confirm">Are you sure you want to cancel this manufacturing order?</attribute> <attribute name="confirm">Are you sure you want to cancel this manufacturing order?</attribute>
</button> </button>
<xpath expr="//field[@name='finished_move_line_ids']/tree/field[@name='product_uom_id']" position="replace"/>
<xpath expr="//field[@name='finished_move_line_ids']/tree/field[@name='qty_done']" position="after">
<field name="product_uom_id" groups="uom.group_uom"/> <!-- Move after qty -->
<field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
</xpath>
</field>
</record>
<record id="view_stock_move_lots" model="ir.ui.view">
<field name="model">stock.move</field>
<field name="inherit_id" ref="mrp.view_stock_move_lots" />
<field name="arch" type="xml">
<xpath expr="//field[@name='active_move_line_ids']/tree/field[@name='lot_id']" position="before">
<xpath expr="//field[@name='active_move_line_ids']/tree/field[@name='location_id']" position="move"/>
</xpath>
<xpath expr="//field[@name='active_move_line_ids']/tree/field[@name='location_id']" position="attributes">
<attribute name="invisible">0</attribute>
<attribute name="readonly">0</attribute>
<attribute name="groups">stock.group_stock_multi_locations</attribute>
</xpath>
</field> </field>
</record> </record>
@@ -40,6 +60,17 @@
</field> </field>
</record> </record>
<record id="view_finisehd_move_line" model="ir.ui.view">
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="mrp.view_finisehd_move_line" />
<field name="arch" type="xml">
<field name="lot_id" position="after">
<field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
</field>
</field>
</record>
<record id="mrp_bom_tree_view" model="ir.ui.view"> <record id="mrp_bom_tree_view" model="ir.ui.view">
<field name="model">mrp.bom</field> <field name="model">mrp.bom</field>
<field name="inherit_id" ref="mrp.mrp_bom_tree_view"/> <field name="inherit_id" ref="mrp.mrp_bom_tree_view"/>

View File

@@ -16,11 +16,6 @@
<field name="standard_price" class="oe_inline" position="after"> <field name="standard_price" class="oe_inline" position="after">
<button name="show_product_price_history" class="oe_inline oe_link" type="object" string="Show History" context="{'active_id': active_id}"/> <button name="show_product_price_history" class="oe_inline oe_link" type="object" string="Show History" context="{'active_id': active_id}"/>
</field> </field>
<!-- Don't make it too big, othesize computers with small resolutions
will see the product name + image under the block of buttons -->
<div class="oe_title" position="attributes">
<attribute name="style">width: 650px;</attribute>
</div>
</field> </field>
</record> </record>

View File

@@ -3,8 +3,9 @@
# @author Alexis de Lattre <alexis.delattre@akretion.com> # @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api from odoo import models, fields, api, _
from odoo.tools.misc import formatLang from odoo.tools.misc import formatLang
from odoo.tools import float_compare
class PurchaseOrder(models.Model): class PurchaseOrder(models.Model):
@@ -42,6 +43,51 @@ class PurchaseOrder(models.Model):
if po.partner_ref: if po.partner_ref:
name += ' (' + po.partner_ref + ')' name += ' (' + po.partner_ref + ')'
if self.env.context.get('show_total_amount') and po.amount_total: if self.env.context.get('show_total_amount') and po.amount_total:
name += ': ' + formatLang(self.env, po.amount_untaxed, currency_obj=po.currency_id) name += ': ' + formatLang(
self.env, po.amount_untaxed, currency_obj=po.currency_id)
result.append((po.id, name)) result.append((po.id, name))
return result return result
class PurchaseOrderLine(models.Model):
_inherit = 'purchase.order.line'
@api.onchange('product_qty', 'product_uom')
def _onchange_quantity(self):
# When the user has manually set a price and/or planned_date
# he is often upset when Odoo changes it when he changes the qty
# So we add a warning...
res = {}
old_price = self.price_unit
old_date_planned = self.date_planned
super()._onchange_quantity()
new_price = self.price_unit
new_date_planned = self.date_planned
prec = self.env['decimal.precision'].precision_get('Product Price')
price_compare = float_compare(old_price, new_price, precision_digits=prec)
if price_compare or old_date_planned != new_date_planned:
res['warning'] = {
'title': _('Updates'),
'message': _(
"Due to the update of the ordered quantity on line '%s', "
"the following data has been updated using the supplier info "
"of the product:"
) % self.name
}
if price_compare:
res['warning']['message'] += _(
"\nOld price: %s\nNew price: %s") % (
formatLang(
self.env, old_price,
currency_obj=self.order_id.currency_id),
formatLang(
self.env, new_price,
currency_obj=self.order_id.currency_id))
if old_date_planned != new_date_planned:
res['warning']['message'] += _(
"\nOld delivery date: %s\nNew delivery date: %s") % (
old_date_planned,
new_date_planned,
)
return res

View File

@@ -172,5 +172,40 @@
<!-- The menu entry should be added in customer-specific module --> <!-- The menu entry should be added in customer-specific module -->
<record id="purchase.action_purchase_order_report_all" model="ir.actions.act_window">
<field name="view_mode">pivot,graph,tree</field> <!--- native order is graph,pivot. Switch order and add tree -->
</record>
<record id="view_purchase_order_pivot" model="ir.ui.view">
<field name="model">purchase.report</field>
<field name="inherit_id" ref="purchase.view_purchase_order_pivot"/>
<field name="arch" type="xml">
<pivot position="attributes">
<attribute name="disable_linking"></attribute>
</pivot>
<field name="unit_quantity" position="replace"/> <!-- it's stupid to display a unit_quantity by default... it will sum qty of different products, which doesn't make a lot of sense -->
<field name="price_average" position="replace"/> <!-- it's stupid to display a price_average by default... it will average between different products, which is a non-sense -->
</field>
</record>
<record id="view_purchase_order_tree" model="ir.ui.view">
<field name="name">purchase.report.tree</field>
<field name="model">purchase.report</field>
<field name="arch" type="xml">
<tree>
<field name="commercial_partner_id"/>
<field name="date_order"/>
<field name="date_approve"/>
<field name="product_id"/>
<field name="unit_quantity" sum="1"/>
<field name="product_uom"/>
<field name="price_total" sum="1"/>
<field name="account_analytic_id"/>
<field name="currency_id" invisible="1"/>
<field name="user_id"/>
<field name="state"/>
</tree>
</field>
</record>
</odoo> </odoo>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2017 Akretion (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<openerp>
<data>
<record id="view_picking_form" model="ir.ui.view">
<field name="name">purchase_usability.stock.picking.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<field name="origin" position="after">
<field name="purchase_id" attrs="{'invisible': [('picking_type_code', '!=', 'incoming')]}" context="{'show_purchase': True}"/>
</field>
</field>
</record>
</data>
</openerp>

View File

@@ -0,0 +1,27 @@
# Copyright 2023 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 api, models
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
@api.multi
def action_invoice_open(self):
res = super().action_invoice_open()
amlo = self.env['account.move.line']
for inv in self:
if inv.state == 'open' and inv.type == 'out_invoice':
sales = inv.invoice_line_ids.mapped('sale_line_ids').\
mapped('order_id')
if sales:
mlines = amlo.search([('sale_id', 'in', sales.ids)])
if mlines:
receivable_lines = inv.move_id.mapped('line_ids').filtered(
lambda l: l.account_id == inv.account_id)
mlines |= receivable_lines
mlines.remove_move_reconcile()
mlines.reconcile()
return res

View File

@@ -30,3 +30,8 @@ class AccountMoveLine(models.Model):
def sale_advance_payement_account_id_change(self): def sale_advance_payement_account_id_change(self):
if self.sale_id and self.account_id.user_type_id.type != 'receivable': if self.sale_id and self.account_id.user_type_id.type != 'receivable':
self.sale_id = False self.sale_id = False
def _sale_down_payment_hook(self):
# can be used for notifications
self.ensure_one()

View File

@@ -25,6 +25,14 @@ class AccountPayment(models.Model):
res['sale_id'] = self.sale_id.id res['sale_id'] = self.sale_id.id
return res return res
def _create_payment_entry(self, amount):
move = super()._create_payment_entry(amount)
if hasattr(self, 'sale_id') and self.sale_id:
for line in move.line_ids:
if line.sale_id and line.account_id.internal_type == 'receivable':
line._sale_down_payment_hook()
return move
class AccountAbstractPayment(models.AbstractModel): class AccountAbstractPayment(models.AbstractModel):
_inherit = "account.abstract.payment" _inherit = "account.abstract.payment"
@@ -49,7 +57,7 @@ class AccountAbstractPayment(models.AbstractModel):
def _compute_payment_amount(self, invoices=None, currency=None): def _compute_payment_amount(self, invoices=None, currency=None):
amount = super(AccountAbstractPayment, self)._compute_payment_amount( amount = super(AccountAbstractPayment, self)._compute_payment_amount(
invoices=invoices, currency=currency) invoices=invoices, currency=currency)
if self.sale_id: if hasattr(self, 'sale_id') and self.sale_id:
payment_currency = currency payment_currency = currency
if not payment_currency: if not payment_currency:
payment_currency = self.sale_id.currency_id payment_currency = self.sale_id.currency_id

View File

@@ -64,7 +64,9 @@ class AccountBankStatementSale(models.TransientModel):
self.ensure_one() self.ensure_one()
for line in self.line_ids: for line in self.line_ids:
if line.move_line_id.sale_id != line.sale_id: if line.move_line_id.sale_id != line.sale_id:
line.move_line_id.sale_id = line.sale_id.id line.move_line_id.write({'sale_id': line.sale_id.id or False})
if line.sale_id:
line.move_line_id._sale_down_payment_hook()
class AccountBankStatementSaleLine(models.TransientModel): class AccountBankStatementSaleLine(models.TransientModel):

View File

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

View File

@@ -0,0 +1,23 @@
# Copyright 2021 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': 'Sale MRP Usability',
'version': '12.0.1.0.0',
'category': 'Sales',
'license': 'AGPL-3',
'summary': 'Usability improvements on sale_mrp module',
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': [
'sale_mrp',
'stock_usability',
],
'data': [
# Native in v14. Do no up-port to v14
'views/mrp_production.xml',
'views/sale_order.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,2 @@
from . import sale
from . import mrp_production

View File

@@ -0,0 +1,39 @@
# Backport from Odoo v14
# Copyright Odoo SA
# Same licence as Odoo (LGPL)
from odoo import api, fields, models, _
class MrpProduction(models.Model):
_inherit = 'mrp.production'
sale_order_count = fields.Integer(
"Count of Source SO",
compute='_compute_sale_order_count',
groups='sales_team.group_sale_salesman')
@api.depends('move_dest_ids.group_id.sale_id')
def _compute_sale_order_count(self):
for production in self:
production.sale_order_count = len(production.move_dest_ids.mapped('group_id').mapped('sale_id'))
def action_view_sale_orders(self):
self.ensure_one()
sale_order_ids = self.move_dest_ids.mapped('group_id').mapped('sale_id').ids
action = {
'res_model': 'sale.order',
'type': 'ir.actions.act_window',
}
if len(sale_order_ids) == 1:
action.update({
'view_mode': 'form',
'res_id': sale_order_ids[0],
})
else:
action.update({
'name': _("Sources Sale Orders of %s" % self.name),
'domain': [('id', 'in', sale_order_ids)],
'view_mode': 'tree,form',
})
return action

View File

@@ -0,0 +1,39 @@
# This code is a backport from odoo v14
# Copyright Odoo SA
# Same licence as Odoo (LGPL)
from odoo import api, fields, models, _
class SaleOrder(models.Model):
_inherit = 'sale.order'
mrp_production_count = fields.Integer(
"Count of MO generated",
compute='_compute_mrp_production_count',
groups='mrp.group_mrp_user')
@api.depends('procurement_group_id.stock_move_ids.created_production_id')
def _compute_mrp_production_count(self):
for sale in self:
sale.mrp_production_count = len(sale.procurement_group_id.stock_move_ids.mapped('created_production_id'))
def action_view_mrp_production(self):
self.ensure_one()
mrp_production_ids = self.procurement_group_id.stock_move_ids.mapped('created_production_id').ids
action = {
'res_model': 'mrp.production',
'type': 'ir.actions.act_window',
}
if len(mrp_production_ids) == 1:
action.update({
'view_mode': 'form',
'res_id': mrp_production_ids[0],
})
else:
action.update({
'name': _("Manufacturing Orders Generated by %s" % self.name),
'domain': [('id', 'in', mrp_production_ids)],
'view_mode': 'tree,form',
})
return action

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Backport from odoo v14
Copyright Odoo SA
Same licence as Odoo (LGPL) -->
<odoo>
<record id="mrp_production_form_view" model="ir.ui.view">
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
<field name="groups_id" eval="[(6, 0, [ref('sales_team.group_sale_salesman_all_leads')])]"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" name="action_view_sale_orders" type="object" icon="fa-dollar" attrs="{'invisible': [('sale_order_count', '=', 0)]}" groups="sales_team.group_sale_salesman">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="sale_order_count"/></span>
<span class="o_stat_text">Sales</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Backport from Odoo v14
Copyright Odoo SA
Same licence as Odoo (LGPL)
-->
<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="groups_id" eval="[(6, 0, [ref('mrp.group_mrp_user')])]"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" name="action_view_mrp_production" type="object" icon="fa-wrench" attrs="{'invisible': [('mrp_production_count', '=', 0)]}" groups="mrp.group_mrp_user">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="mrp_production_count"/></span>
<span class="o_stat_text">Manufacturing</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,106 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * sale_stock_usability
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 8.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-07-13 12:36+0000\n"
"PO-Revision-Date: 2016-07-13 14:37+0200\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"Language: fr\n"
"X-Generator: Poedit 1.8.7.1\n"
#. module: sale_stock_usability
#: view:sale.order:sale_stock_usability.view_order_form_inherit
msgid "Delivery Orders"
msgstr "Livraisons"
#. module: sale_stock_usability
#: view:procurement.group:sale_stock_usability.procurement_group_form_view
#: field:procurement.group,picking_ids:0
msgid "Pickings"
msgstr "Préparations"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_procurement_group
msgid "Procurement Requisition"
msgstr "Demande d'approvisionnement"
#. module: sale_stock_usability
#: view:procurement.group:sale_stock_usability.procurement_group_form_view
#: field:procurement.group,sale_ids:0
msgid "Sale Orders"
msgstr "Sale Orders"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_sale_order
msgid "Sales Order"
msgstr "Bon de commande"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_sale_order_line
msgid "Sales Order Line"
msgstr "Ligne de commandes de vente"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "Delivery Cancelled"
msgstr "Livraison annulée"
#. module: sale_stock_usability
#: model_terms:ir.ui.view,arch_db:sale_stock_usability.view_sales_order_filter
#: selection:sale.order,picking_status:0
msgid "Fully Delivered"
msgstr "Entierement Livré "
#. module: sale_stock_usability
#: model:ir.model.fields,field_description:sale_stock_usability.field_sale_order__incoterm
msgid "Incoterms"
msgstr "Incoterms"
#. module: sale_stock_usability
#: model:ir.model.fields,help:sale_stock_usability.field_sale_order__incoterm
msgid "International Commercial Terms are a series of predefined commercial terms used in international transactions."
msgstr "Les Incoterms sont une série de termes commerciaux prédéfinie utilisés dans les transactions internationales."
#. module: sale_stock_usability
#: model_terms:ir.ui.view,arch_db:sale_stock_usability.view_sales_order_filter
msgid "Not Fully Delivered"
msgstr "Livraison à faire"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "Nothing to Deliver"
msgstr "Rien à livrer"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "Partially Delivered"
msgstr "Livré partielement"
#. module: sale_stock_usability
#: model:ir.model.fields,field_description:sale_stock_usability.field_sale_order__picking_status
msgid "Picking Status"
msgstr "Status de BL"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_sale_order
msgid "Sale Order"
msgstr "Bon de commande"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "To Deliver"
msgstr "Prêt à livrer"
#. module: sale_stock_usability
#: model:ir.model.fields,field_description:sale_stock_usability.field_sale_order__warehouse_id
msgid "Warehouse"
msgstr "Entrepôt"

View File

@@ -36,7 +36,7 @@
<field name="inherit_id" ref="sale.view_order_form"/> <field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<field name="pricelist_id" position="after"> <field name="pricelist_id" position="after">
<field name="picking_status"/> <field name="picking_status" states="sale,done"/>
</field> </field>
</field> </field>
</record> </record>

View File

@@ -2,8 +2,9 @@
# @author Alexis de Lattre <alexis.delattre@akretion.com> # @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api from odoo import models, fields, api, _
from odoo.tools import float_is_zero from odoo.tools import float_is_zero, float_compare
from odoo.tools.misc import formatLang
class SaleOrder(models.Model): class SaleOrder(models.Model):
@@ -11,6 +12,7 @@ class SaleOrder(models.Model):
date_order = fields.Datetime(track_visibility='onchange') date_order = fields.Datetime(track_visibility='onchange')
confirmation_date = fields.Datetime(track_visibility='onchange') confirmation_date = fields.Datetime(track_visibility='onchange')
commitment_date = fields.Datetime(track_visibility='onchange')
client_order_ref = fields.Char(track_visibility='onchange') client_order_ref = fields.Char(track_visibility='onchange')
# for partner_id, the 'sale' module sets track_visibility='always' # for partner_id, the 'sale' module sets track_visibility='always'
partner_id = fields.Many2one(track_visibility='onchange') partner_id = fields.Many2one(track_visibility='onchange')
@@ -63,3 +65,35 @@ class SaleOrder(models.Model):
# {'subtotal': 8932.23}, # {'subtotal': 8932.23},
# ] # ]
return res return res
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
@api.onchange('product_uom', 'product_uom_qty')
def product_uom_change(self):
# When the user has manually set a custom price
# he is often upset when Odoo changes it when he changes the qty
# So we add a warning in which we recall the old price.
res = {}
old_price = self.price_unit
super().product_uom_change()
new_price = self.price_unit
prec = self.env['decimal.precision'].precision_get('Product Price')
if float_compare(old_price, new_price, precision_digits=prec):
pricelist = self.order_id.pricelist_id
res['warning'] = {
'title': _('Price updated'),
'message': _(
"Due to the update of the ordered quantity on line '%s', "
"the price has been updated according to pricelist '%s'.\n"
"Old price: %s\n"
"New price: %s") % (
self.name,
pricelist.display_name,
formatLang(
self.env, old_price, currency_obj=pricelist.currency_id),
formatLang(
self.env, new_price, currency_obj=pricelist.currency_id))
}
return res

View File

@@ -26,6 +26,9 @@
<field name="confirmation_date" position="after"> <field name="confirmation_date" position="after">
<field name="client_order_ref"/> <field name="client_order_ref"/>
</field> </field>
<button name="preview_sale_order" position="after">
<button name="action_quotation_send" type="object" string="Send Order Acknowledgement" attrs="{'invisible': ['|', ('state', 'not in', ('sale', 'done')), ('invoice_count','&gt;=',1)]}"/>
</button>
</field> </field>
</record> </record>
@@ -51,6 +54,9 @@
<field name="state" position="attributes"> <field name="state" position="attributes">
<attribute name="invisible">0</attribute> <attribute name="invisible">0</attribute>
</field> </field>
<field name="partner_id" position="after">
<field name="client_order_ref"/>
</field>
</field> </field>
</record> </record>

View File

@@ -12,6 +12,9 @@ logger = logging.getLogger(__name__)
class ProcurementGroup(models.Model): class ProcurementGroup(models.Model):
_inherit = 'procurement.group' _inherit = 'procurement.group'
# this field stock_move_ids is native in v14
stock_move_ids = fields.One2many('stock.move', 'group_id', string="Related Stock Moves")
@api.model @api.model
def _procure_orderpoint_confirm( def _procure_orderpoint_confirm(
self, use_new_cursor=False, company_id=False): self, use_new_cursor=False, company_id=False):

View File

@@ -3,6 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -98,7 +99,7 @@ class StockMove(models.Model):
picking = move.picking_id picking = move.picking_id
if picking: if picking:
product = move.product_id product = move.product_id
picking.message_post(_( picking.message_post(body=_(
"Product <a href=# data-oe-model=product.product " "Product <a href=# data-oe-model=product.product "
"data-oe-id=%d>%s</a> qty %s %s <b>unreserved</b>") "data-oe-id=%d>%s</a> qty %s %s <b>unreserved</b>")
% (product.id, product.display_name, % (product.id, product.display_name,
@@ -120,7 +121,7 @@ class StockMoveLine(models.Model):
picking = moveline.move_id.picking_id picking = moveline.move_id.picking_id
if picking: if picking:
product = moveline.product_id product = moveline.product_id
picking.message_post(_( picking.message_post(body=_(
"Product <a href=# data-oe-model=product.product " "Product <a href=# data-oe-model=product.product "
"data-oe-id=%d>%s</a> qty %s %s <b>unreserved</b>") "data-oe-id=%d>%s</a> qty %s %s <b>unreserved</b>")
% (product.id, product.display_name, % (product.id, product.display_name,
@@ -145,3 +146,31 @@ class StockQuant(models.Model):
action = self.action_view_stock_moves() action = self.action_view_stock_moves()
action['context'] = {'search_default_todo': True} action['context'] = {'search_default_todo': True}
return action return action
def action_stock_move_lines_reserved(self):
self.ensure_one()
action = self.env['ir.actions.act_window'].for_xml_id('stock', 'stock_move_line_action')
action['domain'] = [
('state', 'not in', ('draft', 'done')),
('product_id', '=', self.product_id.id),
('location_id', '=', self.location_id.id),
('lot_id', '=', self.lot_id.id or False),
'|',
('package_id', '=', self.package_id.id or False),
('result_package_id', '=', self.package_id.id or False),
]
action['context'] = {'create': 0}
return action
class StockInventoryLine(models.Model):
_inherit = 'stock.inventory.line'
state = fields.Selection(store=True)
partner_id = fields.Many2one(states={'done': [('readonly', True)]})
product_id = fields.Many2one(states={'done': [('readonly', True)]})
product_uom_id = fields.Many2one(states={'done': [('readonly', True)]})
product_qty = fields.Float(states={'done': [('readonly', True)]})
location_id = fields.Many2one(states={'done': [('readonly', True)]})
package_id = fields.Many2one(states={'done': [('readonly', True)]})
prod_lot_id = fields.Many2one(states={'done': [('readonly', True)]})

View File

@@ -23,6 +23,11 @@
<button name="action_cancel" type="object" position="attributes"> <button name="action_cancel" type="object" position="attributes">
<attribute name="confirm">Are you sure you want to cancel this picking?</attribute> <attribute name="confirm">Are you sure you want to cancel this picking?</attribute>
</button> </button>
<!-- picking_type_id updates location_id and location_dest_id, we it's
better to put it BEFORE in the view, not AFTER ! -->
<field name="location_id" position="before">
<field name="picking_type_id" position="move"/>
</field>
<!-- STOCK MOVE --> <!-- STOCK MOVE -->
<!-- This sum is useful to check the 'number of items' to transfer... --> <!-- This sum is useful to check the 'number of items' to transfer... -->
<xpath expr="//field[@name='move_ids_without_package']/tree/field[@name='product_uom_qty']" position="attributes"> <xpath expr="//field[@name='move_ids_without_package']/tree/field[@name='product_uom_qty']" position="attributes">
@@ -262,8 +267,22 @@
</field> </field>
</record> </record>
<record id="view_move_line_form" model="ir.ui.view">
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_move_line_form" />
<field name="arch" type="xml">
<field name="picking_id" position="replace"/>
<xpath expr="//field[@name='date']/.." position="before">
<group invisible="not context.get('stock_move_line_main_view')" name="origin" colspan="2">
<field name="picking_id" attrs="{'invisible': [('picking_id', '=', False)]}"/>
<field name="move_id"/>
</group>
</xpath>
</field>
</record>
<record id="stock.stock_move_line_action" model="ir.actions.act_window"> <record id="stock.stock_move_line_action" model="ir.actions.act_window">
<field name="context">{'search_default_done': 1}</field> <field name="context">{'search_default_done': 1, 'stock_move_line_main_view': True}</field>
</record> </record>
<record id="view_warehouse" model="ir.ui.view"> <record id="view_warehouse" model="ir.ui.view">
@@ -350,7 +369,9 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
<button name="action_reset_product_qty" type="object" position="attributes"> <button name="action_reset_product_qty" type="object" position="attributes">
<attribute name="confirm">Are you sure you want to reset all quantities to 0 ?</attribute> <attribute name="confirm">Are you sure you want to reset all quantities to 0 ?</attribute>
</button> </button>
<button name="action_inventory_line_tree" position="attributes">
<attribute name="states">confirm,done</attribute>
</button>
</field> </field>
</record> </record>
@@ -361,6 +382,7 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
<field name="arch" type="xml"> <field name="arch" type="xml">
<field name="date" position="after"> <field name="date" position="after">
<field name="location_id" groups="stock.group_stock_multi_locations"/> <field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="filter"/>
</field> </field>
<tree position="attributes"> <tree position="attributes">
<attribute name="decoration-info">state == 'draft'</attribute> <attribute name="decoration-info">state == 'draft'</attribute>
@@ -369,6 +391,16 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
</field> </field>
</record> </record>
<record id="stock_inventory_line_tree2" model="ir.ui.view">
<field name="model">stock.inventory.line</field>
<field name="inherit_id" ref="stock.stock_inventory_line_tree2"/>
<field name="arch" type="xml">
<field name="prod_lot_id" position="attributes">
<attribute name="attrs">{'readonly': ['|', ('product_tracking', '=', 'none'), ('state', '=', 'done')]}</attribute>
</field>
</field>
</record>
<record id="view_stock_quant_tree" model="ir.ui.view"> <record id="view_stock_quant_tree" model="ir.ui.view">
<field name="name">stock.usability.quant.tree</field> <field name="name">stock.usability.quant.tree</field>
<field name="model">stock.quant</field> <field name="model">stock.quant</field>
@@ -380,6 +412,11 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
<field name="reserved_quantity" position="attributes"> <field name="reserved_quantity" position="attributes">
<attribute name="sum">1</attribute> <attribute name="sum">1</attribute>
</field> </field>
<!-- Move available_quantity AFTER quantity -->
<field name="quantity" position="after">
<field name="reserved_quantity" position="move"/>
<button type="object" name="action_stock_move_lines_reserved" string="Reservations" attrs="{'invisible': [('reserved_quantity', '=', 0)]}"/>
</field>
</field> </field>
</record> </record>
@@ -414,6 +451,24 @@ So I create another "regular" Quants" menu entry -->
parent="stock.menu_stock_inventory_control" parent="stock.menu_stock_inventory_control"
sequence="135"/> sequence="135"/>
<record id="stock_move_line_from_lot_action" model="ir.actions.act_window">
<field name="name">Product Moves</field>
<field name="res_model">stock.move.line</field>
<field name="view_mode">tree,kanban,pivot,form</field>
<field name="domain">[('lot_id', '=', active_id)]</field>
<field name="context">{'search_default_done': 1, 'create': 0}</field>
</record>
<record id="view_production_lot_form" model="ir.ui.view">
<field name="model">stock.production.lot</field>
<field name="inherit_id" ref="stock.view_production_lot_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button name="%(stock_move_line_from_lot_action)d" string="Product Moves" type="action" icon="fa-exchange"/>
</div>
</field>
</record>
<record id="stock.action_production_lot_form" model="ir.actions.act_window"> <record id="stock.action_production_lot_form" model="ir.actions.act_window">
<field name="context">{}</field> <!-- remove group by product --> <field name="context">{}</field> <!-- remove group by product -->
</record> </record>

View File

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

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Stock Valuation XLSX', 'name': 'Stock Valuation XLSX',
'version': '12.0.1.0.0', 'version': '12.0.1.0.1',
'category': 'Tools', 'category': 'Tools',
'license': 'AGPL-3', 'license': 'AGPL-3',
'summary': 'Generate XLSX reports for past or present stock levels', 'summary': 'Generate XLSX reports for past or present stock levels',
@@ -37,8 +37,11 @@ This module has been written by Alexis de Lattre from Akretion <alexis.delattre@
'website': 'http://www.akretion.com', 'website': 'http://www.akretion.com',
'depends': ['stock_account'], 'depends': ['stock_account'],
'data': [ 'data': [
'security/ir.model.access.csv',
'wizard/stock_valuation_xlsx_view.xml', 'wizard/stock_valuation_xlsx_view.xml',
'wizard/stock_variation_xlsx_view.xml',
'views/stock_inventory.xml', 'views/stock_inventory.xml',
'views/stock_expiry_depreciation_rule.xml',
], ],
'installable': True, 'installable': True,
} }

View File

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

View File

@@ -0,0 +1,35 @@
# Copyright 2021 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 fields, models
class StockExpiryDepreciationRule(models.Model):
_name = 'stock.expiry.depreciation.rule'
_description = 'Stock Expiry Depreciation Rule'
_order = 'company_id, start_limit_days'
company_id = fields.Many2one(
'res.company', string='Company',
ondelete='cascade', required=True,
default=lambda self: self.env['res.company']._company_default_get())
start_limit_days = fields.Integer(
string='Days Before/After Expiry', required=True,
help="Enter negative value for days before expiry. Enter positive values for days after expiry. This value is the START of the time interval when going from future to past.")
ratio = fields.Integer(string='Depreciation Ratio (%)', required=True)
name = fields.Char(string='Label')
_sql_constraints = [(
'ratio_positive',
'CHECK(ratio >= 0)',
'The depreciation ratio must be positive.'
), (
'ratio_max',
'CHECK(ratio <= 100)',
'The depreciation ratio cannot be above 100%.'
), (
'start_limit_days_unique',
'unique(company_id, start_limit_days)',
'This depreciation rule already exists in this company.'
)]

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_expiry_depreciation_rule_full,Full access on stock.expiry.depreciation.rule to account manager,model_stock_expiry_depreciation_rule,account.group_account_manager,1,1,1,1
access_stock_expiry_depreciation_rule_read,Read access on stock.expiry.depreciation.rule to stock manager,model_stock_expiry_depreciation_rule,stock.group_stock_manager,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_expiry_depreciation_rule_full Full access on stock.expiry.depreciation.rule to account manager model_stock_expiry_depreciation_rule account.group_account_manager 1 1 1 1
3 access_stock_expiry_depreciation_rule_read Read access on stock.expiry.depreciation.rule to stock manager model_stock_expiry_depreciation_rule stock.group_stock_manager 1 0 0 0

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 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>
<data>
<record id="stock_expiry_depreciation_rule_tree" model="ir.ui.view">
<field name="model">stock.expiry.depreciation.rule</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="start_limit_days"/>
<field name="ratio"/>
<field name="name"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="stock_expiry_depreciation_rule_action" model="ir.actions.act_window">
<field name="name">Stock Depreciation Rules</field>
<field name="res_model">stock.expiry.depreciation.rule</field>
<field name="view_mode">tree</field>
</record>
<menuitem id="stock_expiry_depreciation_rule_menu"
action="stock_expiry_depreciation_rule_action"
parent="account.account_management_menu"
sequence="100"/>
</data>
</odoo>

View File

@@ -1 +1,2 @@
from . import stock_valuation_xlsx from . import stock_valuation_xlsx
from . import stock_variation_xlsx

View File

@@ -4,6 +4,7 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
from dateutil.relativedelta import relativedelta
from odoo.tools import float_is_zero, float_round from odoo.tools import float_is_zero, float_round
from io import BytesIO from io import BytesIO
from datetime import datetime from datetime import datetime
@@ -18,7 +19,7 @@ class StockValuationXlsx(models.TransientModel):
_name = 'stock.valuation.xlsx' _name = 'stock.valuation.xlsx'
_description = 'Generate XLSX report for stock valuation' _description = 'Generate XLSX report for stock valuation'
export_file = fields.Binary(string='XLSX Report', readonly=True) export_file = fields.Binary(string='XLSX Report', readonly=True, attachment=True)
export_filename = fields.Char(readonly=True) export_filename = fields.Char(readonly=True)
# I don't use ir.actions.url on v12, because it renders # I don't use ir.actions.url on v12, because it renders
# the wizard unusable after the first report generation, which creates # the wizard unusable after the first report generation, which creates
@@ -38,8 +39,10 @@ class StockValuationXlsx(models.TransientModel):
help="The childen locations of the selected locations will " help="The childen locations of the selected locations will "
u"be taken in the valuation.") u"be taken in the valuation.")
categ_ids = fields.Many2many( categ_ids = fields.Many2many(
'product.category', string='Product Categories', 'product.category', string='Product Category Filter',
states={'done': [('readonly', True)]}) help="Leave this field empty to have a stock valuation for all your products.",
states={'done': [('readonly', True)]},
)
source = fields.Selection([ source = fields.Selection([
('inventory', 'Physical Inventory'), ('inventory', 'Physical Inventory'),
('stock', 'Stock Levels'), ('stock', 'Stock Levels'),
@@ -59,17 +62,33 @@ class StockValuationXlsx(models.TransientModel):
categ_subtotal = fields.Boolean( categ_subtotal = fields.Boolean(
string='Subtotals per Categories', default=True, string='Subtotals per Categories', default=True,
states={'done': [('readonly', True)]}, states={'done': [('readonly', True)]},
help="Show a subtotal per product category") help="Show a subtotal per product category.")
standard_price_date = fields.Selection([ standard_price_date = fields.Selection([
('past', 'Past Date or Inventory Date'), ('past', 'Past Date or Inventory Date'),
('present', 'Current'), ('present', 'Current'),
], default='past', string='Cost Price Date', ], default='past', string='Cost Price Date',
states={'done': [('readonly', True)]}) states={'done': [('readonly', True)]})
# I can't put a compute field for has_expiry_date
# because I want to have the value when the wizard is started,
# and not wait until run
has_expiry_date = fields.Boolean(
default=lambda self: self._default_has_expiry_date(), readonly=True)
apply_depreciation = fields.Boolean(
string='Apply Depreciation Rules', default=True,
states={'done': [('readonly', True)]})
split_by_lot = fields.Boolean( split_by_lot = fields.Boolean(
string='Display Lots', states={'done': [('readonly', True)]}) string='Display Lots', states={'done': [('readonly', True)]})
split_by_location = fields.Boolean( split_by_location = fields.Boolean(
string='Display Stock Locations', states={'done': [('readonly', True)]}) string='Display Stock Locations', states={'done': [('readonly', True)]})
@api.model
def _default_has_expiry_date(self):
splo = self.env['stock.production.lot']
has_expiry_date = False
if hasattr(splo, 'expiry_date'):
has_expiry_date = True
return has_expiry_date
@api.model @api.model
def _default_location(self): def _default_location(self):
wh = self.env.ref('stock.warehouse0') wh = self.env.ref('stock.warehouse0')
@@ -123,11 +142,22 @@ class StockValuationXlsx(models.TransientModel):
def _prepare_product_fields(self): def _prepare_product_fields(self):
return ['uom_id', 'name', 'default_code', 'categ_id'] return ['uom_id', 'name', 'default_code', 'categ_id']
def _prepare_expiry_depreciation_rules(self, company_id, past_date):
rules = self.env['stock.expiry.depreciation.rule'].search_read([('company_id', '=', company_id)], ['start_limit_days', 'ratio'], order='start_limit_days desc')
if past_date:
date_dt = fields.Date.to_date(past_date) # convert datetime to date
else:
date_dt = fields.Date.context_today(self)
for rule in rules:
rule['start_date'] = date_dt - relativedelta(days=rule['start_limit_days'])
logger.debug('depreciation_rules=%s', rules)
return rules
def compute_product_data( def compute_product_data(
self, company_id, in_stock_product_ids, standard_price_past_date=False): self, company_id, in_stock_product_ids, standard_price_past_date=False):
self.ensure_one() self.ensure_one()
logger.debug('Start compute_product_data') logger.debug('Start compute_product_data')
ppo = self.env['product.product'] ppo = self.env['product.product'].with_context(force_company=company_id)
ppho = self.env['product.price.history'] ppho = self.env['product.price.history']
fields_list = self._prepare_product_fields() fields_list = self._prepare_product_fields()
if not standard_price_past_date: if not standard_price_past_date:
@@ -156,38 +186,56 @@ class StockValuationXlsx(models.TransientModel):
logger.debug('End compute_product_data') logger.debug('End compute_product_data')
return product_id2data return product_id2data
def id2name(self, product_ids): @api.model
logger.debug('Start id2name') def product_categ_id2name(self, categories):
pco = self.env['product.category'] 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_id2name = {}
categ_domain = [] categ_domain = []
if self.categ_ids: if categories:
categ_domain = [('id', 'child_of', self.categ_ids.ids)] categ_domain = [('id', 'child_of', categories.ids)]
for categ in pco.search_read(categ_domain, ['display_name']): for categ in pco.search_read(categ_domain, ['display_name']):
categ_id2name[categ['id']] = categ['display_name'] categ_id2name[categ['id']] = categ['display_name']
return categ_id2name
@api.model
def uom_id2name(self):
puo = self.env['uom.uom'].with_context(active_test=False)
uom_id2name = {} uom_id2name = {}
uoms = puo.search_read([], ['name']) uoms = puo.search_read([], ['name'])
for uom in uoms: for uom in uoms:
uom_id2name[uom['id']] = uom['name'] uom_id2name[uom['id']] = uom['name']
return uom_id2name
@api.model
def prodlot_id2data(self, product_ids, has_expiry_date, depreciation_rules):
splo = self.env['stock.production.lot']
lot_id2data = {} lot_id2data = {}
lot_fields = ['name'] lot_fields = ['name']
if hasattr(splo, 'expiry_date'): if has_expiry_date:
lot_fields.append('expiry_date') lot_fields.append('expiry_date')
lots = splo.search_read( lots = splo.search_read(
[('product_id', 'in', product_ids)], lot_fields) [('product_id', 'in', product_ids)], lot_fields)
for lot in lots: for lot in lots:
lot_id2data[lot['id']] = lot lot_id2data[lot['id']] = lot
lot_id2data[lot['id']]['depreciation_ratio'] = 0
if depreciation_rules and lot.get('expiry_date'):
expiry_date = lot['expiry_date']
for rule in depreciation_rules:
if expiry_date <= rule['start_date']:
lot_id2data[lot['id']]['depreciation_ratio'] = rule['ratio'] / 100.0
break
return lot_id2data
@api.model
def stock_location_id2name(self, location):
slo = self.env['stock.location'].with_context(active_test=False)
loc_id2name = {} loc_id2name = {}
locs = slo.search_read( locs = slo.search_read(
[('id', 'child_of', self.location_id.id)], ['display_name']) [('id', 'child_of', self.location_id.id)], ['display_name'])
for loc in locs: for loc in locs:
loc_id2name[loc['id']] = loc['display_name'] loc_id2name[loc['id']] = loc['display_name']
logger.debug('End id2name') return loc_id2name
return categ_id2name, uom_id2name, lot_id2data, loc_id2name
def compute_data_from_inventory(self, product_ids, prec_qty): def compute_data_from_inventory(self, product_ids, prec_qty):
self.ensure_one() self.ensure_one()
@@ -239,7 +287,7 @@ class StockValuationXlsx(models.TransientModel):
self.ensure_one() self.ensure_one()
logger.debug('Start compute_data_from_past_stock past_date=%s', past_date) logger.debug('Start compute_data_from_past_stock past_date=%s', past_date)
ppo = self.env['product.product'] ppo = self.env['product.product']
products = ppo.with_context(to_date=past_date, location_id=self.location_id.id).browse(product_ids) products = ppo.with_context(to_date=past_date, location=self.location_id.id).browse(product_ids)
res = [] res = []
in_stock_products = {} in_stock_products = {}
for product in products: for product in products:
@@ -275,7 +323,7 @@ class StockValuationXlsx(models.TransientModel):
def stringify_and_sort_result( def stringify_and_sort_result(
self, product_ids, product_id2data, data, self, product_ids, product_id2data, data,
prec_qty, prec_price, prec_cur_rounding, categ_id2name, prec_qty, prec_price, prec_cur_rounding, categ_id2name,
uom_id2name, lot_id2data, loc_id2name): uom_id2name, lot_id2data, loc_id2name, apply_depreciation):
logger.debug('Start stringify_and_sort_result') logger.debug('Start stringify_and_sort_result')
res = [] res = []
for l in data: for l in data:
@@ -284,17 +332,27 @@ class StockValuationXlsx(models.TransientModel):
standard_price = float_round( standard_price = float_round(
product_id2data[product_id]['standard_price'], product_id2data[product_id]['standard_price'],
precision_digits=prec_price) precision_digits=prec_price)
subtotal = float_round( subtotal_before_depreciation = float_round(
standard_price * qty, precision_rounding=prec_cur_rounding) standard_price * qty, precision_rounding=prec_cur_rounding)
depreciation_ratio = 0
if apply_depreciation and l['lot_id']:
depreciation_ratio = lot_id2data[l['lot_id']].get('depreciation_ratio', 0)
subtotal = float_round(
subtotal_before_depreciation * (1 - depreciation_ratio),
precision_rounding=prec_cur_rounding)
else:
subtotal = subtotal_before_depreciation
res.append(dict( res.append(dict(
product_id2data[product_id], product_id2data[product_id],
product_name=product_id2data[product_id]['name'], product_name=product_id2data[product_id]['name'],
loc_name=l['location_id'] and loc_id2name[l['location_id']] or '', 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 '', 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'), expiry_date=l['lot_id'] and lot_id2data[l['lot_id']].get('expiry_date'),
depreciation_ratio=depreciation_ratio,
qty=qty, qty=qty,
uom_name=uom_id2name[product_id2data[product_id]['uom_id']], uom_name=uom_id2name[product_id2data[product_id]['uom_id']],
standard_price=standard_price, standard_price=standard_price,
subtotal_before_depreciation=subtotal_before_depreciation,
subtotal=subtotal, subtotal=subtotal,
categ_name=categ_id2name[product_id2data[product_id]['categ_id']], categ_name=categ_id2name[product_id2data[product_id]['categ_id']],
)) ))
@@ -313,6 +371,12 @@ class StockValuationXlsx(models.TransientModel):
prec_cur_rounding = company.currency_id.rounding prec_cur_rounding = company.currency_id.rounding
self._check_config(company_id) self._check_config(company_id)
apply_depreciation = self.apply_depreciation
if (
(self.source == 'stock' and self.stock_date_type == 'past') or
not self.split_by_lot or
not self.has_expiry_date):
apply_depreciation = False
product_ids = self.get_product_ids() product_ids = self.get_product_ids()
if not product_ids: if not product_ids:
raise UserError(_("There are no products to analyse.")) raise UserError(_("There are no products to analyse."))
@@ -332,18 +396,32 @@ class StockValuationXlsx(models.TransientModel):
elif self.source == 'inventory': elif self.source == 'inventory':
past_date = self.inventory_id.date past_date = self.inventory_id.date
data, in_stock_products = self.compute_data_from_inventory(product_ids, prec_qty) data, in_stock_products = self.compute_data_from_inventory(product_ids, prec_qty)
standard_price_past_date = past_date if self.source == 'stock' and self.stock_date_type == 'present':
if not (self.source == 'stock' and self.stock_date_type == 'present') and self.standard_price_date == 'present':
standard_price_past_date = False standard_price_past_date = False
else: # field standard_price_date is shown on screen
if self.standard_price_date == 'present':
standard_price_past_date = False
else:
standard_price_past_date = past_date
depreciation_rules = []
if apply_depreciation:
depreciation_rules = self._prepare_expiry_depreciation_rules(company_id, past_date)
if not depreciation_rules:
raise UserError(_(
"The are not stock depreciation rule for company '%s'.")
% company.display_name)
in_stock_product_ids = list(in_stock_products.keys()) in_stock_product_ids = list(in_stock_products.keys())
product_id2data = self.compute_product_data( product_id2data = self.compute_product_data(
company_id, in_stock_product_ids, company_id, in_stock_product_ids,
standard_price_past_date=standard_price_past_date) standard_price_past_date=standard_price_past_date)
data_res = self.group_result(data, split_by_lot, split_by_location) data_res = self.group_result(data, split_by_lot, split_by_location)
categ_id2name, uom_id2name, lot_id2data, loc_id2name = self.id2name(product_ids) categ_id2name = self.product_categ_id2name(self.categ_ids)
uom_id2name = self.uom_id2name()
lot_id2data = self.prodlot_id2data(in_stock_product_ids, self.has_expiry_date, depreciation_rules)
loc_id2name = self.stock_location_id2name(self.location_id)
res = self.stringify_and_sort_result( res = self.stringify_and_sort_result(
product_ids, product_id2data, data_res, prec_qty, prec_price, prec_cur_rounding, product_ids, product_id2data, data_res, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name, lot_id2data, loc_id2name) categ_id2name, uom_id2name, lot_id2data, loc_id2name, apply_depreciation)
logger.debug('Start create XLSX workbook') logger.debug('Start create XLSX workbook')
file_data = BytesIO() file_data = BytesIO()
@@ -356,12 +434,15 @@ class StockValuationXlsx(models.TransientModel):
if not split_by_lot: if not split_by_lot:
cols.pop('lot_name', None) cols.pop('lot_name', None)
cols.pop('expiry_date', None) cols.pop('expiry_date', None)
if not hasattr(splo, 'expiry_date'): if not self.has_expiry_date:
cols.pop('expiry_date', None) cols.pop('expiry_date', None)
if not split_by_location: if not split_by_location:
cols.pop('loc_name', None) cols.pop('loc_name', None)
if not categ_subtotal: if not categ_subtotal:
cols.pop('categ_subtotal', None) cols.pop('categ_subtotal', None)
if not apply_depreciation:
cols.pop('depreciation_ratio', None)
cols.pop('subtotal_before_depreciation', None)
j = 0 j = 0
for col, col_vals in sorted(cols.items(), key=lambda x: x[1]['sequence']): for col, col_vals in sorted(cols.items(), key=lambda x: x[1]['sequence']):
@@ -417,6 +498,9 @@ class StockValuationXlsx(models.TransientModel):
letter_qty = cols['qty']['pos_letter'] letter_qty = cols['qty']['pos_letter']
letter_price = cols['standard_price']['pos_letter'] letter_price = cols['standard_price']['pos_letter']
letter_subtotal = cols['subtotal']['pos_letter'] letter_subtotal = cols['subtotal']['pos_letter']
if apply_depreciation:
letter_subtotal_before_depreciation = cols['subtotal_before_depreciation']['pos_letter']
letter_depreciation_ratio = cols['depreciation_ratio']['pos_letter']
crow = 0 crow = 0
lines = res lines = res
for categ_id in categ_ids: for categ_id in categ_ids:
@@ -432,12 +516,20 @@ class StockValuationXlsx(models.TransientModel):
total += l['subtotal'] total += l['subtotal']
ctotal += l['subtotal'] ctotal += l['subtotal']
categ_has_line = True categ_has_line = True
subtotal_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1) qty_by_price_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1)
if apply_depreciation:
sheet.write_formula(i, cols['subtotal_before_depreciation']['pos'], qty_by_price_formula, styles['regular_currency'], l['subtotal_before_depreciation'])
subtotal_formula = '=%s%d*(1 - %s%d)' % (letter_subtotal_before_depreciation, i + 1, letter_depreciation_ratio, i + 1)
else:
subtotal_formula = qty_by_price_formula
sheet.write_formula(i, cols['subtotal']['pos'], subtotal_formula, styles['regular_currency'], l['subtotal']) sheet.write_formula(i, cols['subtotal']['pos'], subtotal_formula, styles['regular_currency'], l['subtotal'])
for col_name, col in cols.items(): for col_name, col in cols.items():
if not col.get('formula'): if not col.get('formula'):
if col.get('type') == 'date' and l[col_name]: if col.get('type') == 'date':
l[col_name] = fields.Date.from_string(l[col_name]) if l[col_name]:
l[col_name] = fields.Date.from_string(l[col_name])
else:
l[col_name] = '' # to avoid display of 31/12/1899
sheet.write(i, col['pos'], l[col_name], styles[col['style']]) sheet.write(i, col['pos'], l[col_name], styles[col['style']])
if categ_subtotal: if categ_subtotal:
if categ_has_line: if categ_has_line:
@@ -503,6 +595,7 @@ class StockValuationXlsx(models.TransientModel):
'regular_date': workbook.add_format({'num_format': 'dd/mm/yyyy'}), 'regular_date': workbook.add_format({'num_format': 'dd/mm/yyyy'}),
'regular_currency': workbook.add_format({'num_format': currency_num_format}), 'regular_currency': workbook.add_format({'num_format': currency_num_format}),
'regular_price_currency': workbook.add_format({'num_format': price_currency_num_format}), 'regular_price_currency': workbook.add_format({'num_format': price_currency_num_format}),
'regular_int_percent': workbook.add_format({'num_format': u'0.%'}),
'regular': workbook.add_format({}), 'regular': workbook.add_format({}),
'regular_small': workbook.add_format({'font_size': regular_font_size - 2}), 'regular_small': workbook.add_format({'font_size': regular_font_size - 2}),
'categ_title': workbook.add_format({ 'categ_title': workbook.add_format({
@@ -527,8 +620,10 @@ class StockValuationXlsx(models.TransientModel):
'qty': {'width': 8, 'style': 'regular', 'sequence': 60, 'title': _('Qty')}, 'qty': {'width': 8, 'style': 'regular', 'sequence': 60, 'title': _('Qty')},
'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 70, 'title': _('UoM')}, 'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 70, 'title': _('UoM')},
'standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 80, 'title': _('Cost Price')}, '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}, 'subtotal_before_depreciation': {'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}, 'depreciation_ratio': {'width': 10, 'style': 'regular_int_percent', 'sequence': 100, 'title': _('Depreciation')},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 110, 'title': _('Product Category')}, 'subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 110, 'title': _('Sub-total'), 'formula': True},
'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 120, 'title': _('Categ Sub-total'), 'formula': True},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 130, 'title': _('Product Category')},
} }
return cols return cols

View File

@@ -27,8 +27,10 @@
<field name="past_date" attrs="{'invisible': ['|', ('source', '!=', 'stock'), ('stock_date_type', '!=', 'past')], 'required': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/> <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="standard_price_date" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'present')]}" widget="radio"/>
<field name="categ_subtotal" /> <field name="categ_subtotal" />
<field name="has_expiry_date" invisible="1"/>
<field name="split_by_lot" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}" groups="stock.group_production_lot"/> <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')]}"/> <field name="split_by_location" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
<field name="apply_depreciation" groups="stock.group_production_lot" attrs="{'invisible': ['|', '|', ('split_by_lot', '=', False), ('has_expiry_date', '=', False), '&amp;', ('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
</group> </group>
<group name="done" states="done" string="Result"> <group name="done" states="done" string="Result">
<field name="export_file" filename="export_filename"/> <field name="export_file" filename="export_filename"/>
@@ -55,6 +57,7 @@
<record id="stock_account.menu_valuation" model="ir.ui.menu"> <record id="stock_account.menu_valuation" model="ir.ui.menu">
<field name="action" ref="stock_valuation_xlsx.stock_valuation_xlsx_action"/> <field name="action" ref="stock_valuation_xlsx.stock_valuation_xlsx_action"/>
<field name="name">Stock Valuation XLSX</field> <field name="name">Stock Valuation XLSX</field>
<field name="sequence">0</field>
</record> </record>
</odoo> </odoo>

View File

@@ -0,0 +1,459 @@
# Copyright 2020-2021 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
import base64
from datetime import datetime
import xlsxwriter
import logging
logger = logging.getLogger(__name__)
class StockVariationXlsx(models.TransientModel):
_name = 'stock.variation.xlsx'
_description = 'Generate XLSX report for stock valuation variation between 2 dates'
export_file = fields.Binary(string='XLSX Report', readonly=True, attachment=True)
export_filename = fields.Char(readonly=True)
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 "
"be taken in the valuation.")
categ_ids = fields.Many2many(
'product.category', string='Product Category Filter',
help="Leave this fields empty to have a stock valuation for all your products.",
states={'done': [('readonly', True)]})
start_date = fields.Datetime(
string='Start Date', required=True,
states={'done': [('readonly', True)]})
standard_price_start_date_type = fields.Selection([
('start', 'Start Date'),
('present', 'Current'),
], default='start', required=True,
string='Cost Price for Start Date',
states={'done': [('readonly', True)]})
end_date_type = fields.Selection([
('present', 'Present'),
('past', 'Past'),
], string='End Date Type', default='present', required=True,
states={'done': [('readonly', True)]})
end_date = fields.Datetime(
string='End Date', states={'done': [('readonly', True)]},
default=fields.Datetime.now)
standard_price_end_date_type = fields.Selection([
('end', 'End Date'),
('present', 'Current'),
], default='end', string='Cost Price for End Date', required=True,
states={'done': [('readonly', True)]})
categ_subtotal = fields.Boolean(
string='Subtotals per Categories', default=True,
states={'done': [('readonly', True)]},
help="Show a subtotal per product category.")
@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()
present = fields.Datetime.now()
if self.end_date_type == 'past':
if not self.end_date:
raise UserError(_("End Date is missing."))
if self.end_date > present:
raise UserError(_("The end date must be in the past."))
if self.end_date <= self.start_date:
raise UserError(_("The start date must be before the end date."))
else:
if self.start_date >= present:
raise UserError(_("The start date must be in the past."))
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()
# Should we also add inactive products ??
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, filter_product_ids,
standard_price_start_date=False, standard_price_end_date=False):
self.ensure_one()
logger.debug('Start compute_product_data')
ppo = self.env['product.product'].with_context(force_company=company_id)
ppho = self.env['product.price.history']
fields_list = self._prepare_product_fields()
if not standard_price_start_date or not standard_price_end_date:
fields_list.append('standard_price')
products = ppo.search_read([('id', 'in', filter_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_start_date:
history = ppho.search_read([
('company_id', '=', company_id),
('product_id', '=', p['id']),
('datetime', '<=', standard_price_start_date)],
['cost'], order='datetime desc, id desc', limit=1)
start_standard_price = history and history[0]['cost'] or 0.0
else:
start_standard_price = p['standard_price']
if standard_price_end_date:
history = ppho.search_read([
('company_id', '=', company_id),
('product_id', '=', p['id']),
('datetime', '<=', standard_price_end_date)],
['cost'], order='datetime desc, id desc', limit=1)
end_standard_price = history and history[0]['cost'] or 0.0
else:
end_standard_price = p['standard_price']
product_id2data[p['id']] = {
'start_standard_price': start_standard_price,
'end_standard_price': end_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 compute_data_from_stock(self, product_ids, prec_qty, start_date, end_date_type, end_date, company_id):
self.ensure_one()
logger.debug('Start compute_data_from_stock past_date=%s end_date_type=%s, end_date=%s', start_date, end_date_type, end_date)
ppo = self.env['product.product']
smo = self.env['stock.move']
sqo = self.env['stock.quant']
ppo_loc = ppo.with_context(location=self.location_id.id, force_company=company_id)
# Inspired by odoo/addons/stock/models/product.py
# method _compute_quantities_dict()
domain_quant_loc, domain_move_in_loc, domain_move_out_loc = ppo_loc._get_domain_locations()
domain_quant = [('product_id', 'in', product_ids)] + domain_quant_loc
domain_move_in = [('product_id', 'in', product_ids), ('state', '=', 'done')] + domain_move_in_loc
domain_move_out = [('product_id', 'in', product_ids), ('state', '=', 'done')] + domain_move_out_loc
quants_res = dict((item['product_id'][0], item['quantity']) for item in sqo.read_group(domain_quant, ['product_id', 'quantity'], ['product_id'], orderby='id'))
domain_move_in_start_to_end = [('date', '>', start_date)] + domain_move_in
domain_move_out_start_to_end = [('date', '>', start_date)] + domain_move_out
if end_date_type == 'past':
domain_move_in_end_to_present = [('date', '>', end_date)] + domain_move_in
domain_move_out_end_to_present = [('date', '>', end_date)] + domain_move_out
moves_in_res_end_to_present = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_in_end_to_present, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
moves_out_res_end_to_present = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_out_end_to_present, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
domain_move_in_start_to_end += [('date', '<', end_date)]
domain_move_out_start_to_end += [('date', '<', end_date)]
moves_in_res_start_to_end = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_in_start_to_end, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
moves_out_res_start_to_end = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_out_start_to_end, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
product_data = {} # key = product_id , value = dict
for product in ppo.browse(product_ids):
end_qty = quants_res.get(product.id, 0.0)
if end_date_type == 'past':
end_qty += moves_out_res_end_to_present.get(product.id, 0.0) - moves_in_res_end_to_present.get(product.id, 0.0)
in_qty = moves_in_res_start_to_end.get(product.id, 0.0)
out_qty = moves_out_res_start_to_end.get(product.id, 0.0)
start_qty = end_qty - in_qty + out_qty
if (
not float_is_zero(start_qty, precision_digits=prec_qty) or
not float_is_zero(in_qty, precision_digits=prec_qty) or
not float_is_zero(out_qty, precision_digits=prec_qty) or
not float_is_zero(end_qty, precision_digits=prec_qty)):
product_data[product.id] = {
'product_id': product.id,
'start_qty': start_qty,
'in_qty': in_qty,
'out_qty': out_qty,
'end_qty': end_qty,
}
logger.debug('End compute_data_from_stock')
return product_data
def stringify_and_sort_result(
self, product_data, product_id2data, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name):
logger.debug('Start stringify_and_sort_result')
res = []
for product_id, l in product_data.items():
start_qty = float_round(l['start_qty'], precision_digits=prec_qty)
in_qty = float_round(l['in_qty'], precision_digits=prec_qty)
out_qty = float_round(l['out_qty'], precision_digits=prec_qty)
end_qty = float_round(l['end_qty'], precision_digits=prec_qty)
start_standard_price = float_round(
product_id2data[product_id]['start_standard_price'],
precision_digits=prec_price)
end_standard_price = float_round(
product_id2data[product_id]['end_standard_price'],
precision_digits=prec_price)
start_subtotal = float_round(
start_standard_price * start_qty, precision_rounding=prec_cur_rounding)
end_subtotal = float_round(
end_standard_price * end_qty, precision_rounding=prec_cur_rounding)
variation = float_round(
end_subtotal - start_subtotal, precision_rounding=prec_cur_rounding)
res.append(dict(
product_id2data[product_id],
product_name=product_id2data[product_id]['name'],
start_qty=start_qty,
start_standard_price=start_standard_price,
start_subtotal=start_subtotal,
in_qty=in_qty,
out_qty=out_qty,
end_qty=end_qty,
end_standard_price=end_standard_price,
end_subtotal=end_subtotal,
variation=variation,
uom_name=uom_id2name[product_id2data[product_id]['uom_id']],
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 variation report')
svxo = self.env['stock.valuation.xlsx']
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."))
product_data = self.compute_data_from_stock(
product_ids, prec_qty, self.start_date, self.end_date_type, self.end_date,
company_id)
standard_price_start_date = standard_price_end_date = False
if self.standard_price_start_date_type == 'start':
standard_price_start_date = self.start_date
if self.standard_price_end_date_type == 'end' and self.end_date_type == 'past':
standard_price_end_date = self.end_date
product_id2data = self.compute_product_data(
company_id, list(product_data.keys()),
standard_price_start_date, standard_price_end_date)
categ_id2name = svxo.product_categ_id2name(self.categ_ids)
uom_id2name = svxo.uom_id2name()
res = self.stringify_and_sort_result(
product_data, product_id2data, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name)
logger.debug('Start create XLSX workbook')
file_data = BytesIO()
workbook = xlsxwriter.Workbook(file_data)
sheet = workbook.add_worksheet('Stock_Variation')
styles = svxo._prepare_styles(workbook, company, prec_price)
cols = self._prepare_cols()
categ_subtotal = self.categ_subtotal
# remove cols that we won't use
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)
start_time_utc_dt = self.start_date
start_time_dt = fields.Datetime.context_timestamp(self, start_time_utc_dt)
start_time_str = fields.Datetime.to_string(start_time_dt)
if self.end_date_type == 'past':
end_time_utc_dt = self.end_date
end_time_dt = fields.Datetime.context_timestamp(self, end_time_utc_dt)
end_time_str = fields.Datetime.to_string(end_time_dt)
else:
end_time_str = now_str
if standard_price_start_date:
standard_price_start_date_str = start_time_str
else:
standard_price_start_date_str = now_str
if standard_price_end_date:
standard_price_end_date_str = end_time_str
else:
standard_price_end_date_str = now_str
i = 0
sheet.write(i, 0, 'Odoo - Stock Valuation Variation', styles['doc_title'])
sheet.set_row(0, 26)
i += 1
sheet.write(i, 0, 'Start Date: %s' % start_time_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Cost Price Start Date: %s' % standard_price_start_date_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'End Date: %s' % end_time_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Cost Price End Date: %s' % standard_price_end_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, 0, _("TOTALS:"), styles['total_title'])
total_row = i
# LINES
if categ_subtotal:
categ_ids = categ_id2name.keys()
else:
categ_ids = [0]
start_total = end_total = variation_total = 0.0
letter_start_qty = cols['start_qty']['pos_letter']
letter_in_qty = cols['in_qty']['pos_letter']
letter_out_qty = cols['out_qty']['pos_letter']
letter_end_qty = cols['end_qty']['pos_letter']
letter_start_price = cols['start_standard_price']['pos_letter']
letter_end_price = cols['end_standard_price']['pos_letter']
letter_start_subtotal = cols['start_subtotal']['pos_letter']
letter_end_subtotal = cols['end_subtotal']['pos_letter']
letter_variation = cols['variation']['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
start_total += l['start_subtotal']
end_total += l['end_subtotal']
variation_total += l['variation']
ctotal += l['variation']
categ_has_line = True
end_qty_formula = '=%s%d+%s%d-%s%d' % (letter_start_qty, i + 1, letter_in_qty, i + 1, letter_out_qty, i + 1)
sheet.write_formula(i, cols['end_qty']['pos'], end_qty_formula, styles[cols['end_qty']['style']], l['end_qty'])
start_subtotal_formula = '=%s%d*%s%d' % (letter_start_qty, i + 1, letter_start_price, i + 1)
sheet.write_formula(i, cols['start_subtotal']['pos'], start_subtotal_formula, styles[cols['start_subtotal']['style']], l['start_subtotal'])
end_subtotal_formula = '=%s%d*%s%d' % (letter_end_qty, i + 1, letter_end_price, i + 1)
sheet.write_formula(i, cols['end_subtotal']['pos'], end_subtotal_formula, styles[cols['end_subtotal']['style']], l['end_subtotal'])
variation_formula = '=%s%d-%s%d' % (letter_end_subtotal, i + 1, letter_start_subtotal, i + 1)
sheet.write_formula(i, cols['variation']['pos'], variation_formula, styles[cols['variation']['style']], l['variation'])
sheet.write_formula(i, cols['end_subtotal']['pos'], end_subtotal_formula, styles[cols['end_subtotal']['style']], l['end_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_variation, crow + 2, letter_variation, 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
start_total_formula = '=SUM(%s%d:%s%d)' % (letter_start_subtotal, total_row + 2, letter_start_subtotal, i + 1)
sheet.write_formula(total_row, cols['start_subtotal']['pos'], start_total_formula, styles['total_currency'], float_round(start_total, precision_rounding=prec_cur_rounding))
end_total_formula = '=SUM(%s%d:%s%d)' % (letter_end_subtotal, total_row + 2, letter_end_subtotal, i + 1)
sheet.write_formula(total_row, cols['end_subtotal']['pos'], end_total_formula, styles['total_currency'], float_round(end_total, precision_rounding=prec_cur_rounding))
variation_total_formula = '=SUM(%s%d:%s%d)' % (letter_variation, total_row + 2, letter_variation, i + 1)
sheet.write_formula(total_row, cols['variation']['pos'], variation_total_formula, styles['total_currency'], float_round(variation_total, precision_rounding=prec_cur_rounding))
workbook.close()
logger.debug('End create XLSX workbook')
file_data.seek(0)
filename = 'Odoo_stock_%s_%s.xlsx' % (
start_time_str.replace(' ', '-').replace(':', '_'),
end_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 = self.env['ir.actions.act_window'].for_xml_id(
'stock_valuation_xlsx', 'stock_variation_xlsx_action')
action['res_id'] = self.id
return action
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')},
'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 30, 'title': _('UoM')},
'start_qty': {'width': 8, 'style': 'regular', 'sequence': 40, 'title': _('Start Qty')},
'start_standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 50, 'title': _('Start Cost Price')},
'start_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 60, 'title': _('Start Value'), 'formula': True},
'in_qty': {'width': 8, 'style': 'regular', 'sequence': 70, 'title': _('In Qty')},
'out_qty': {'width': 8, 'style': 'regular', 'sequence': 80, 'title': _('Out Qty')},
'end_qty': {'width': 8, 'style': 'regular', 'sequence': 90, 'title': _('End Qty'), 'formula': True},
'end_standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 100, 'title': _('End Cost Price')},
'end_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 110, 'title': _('End Value'), 'formula': True},
'variation': {'width': 16, 'style': 'regular_currency', 'sequence': 120, 'title': _('Variation'), 'formula': True},
'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 130, 'title': _('Categ Sub-total'), 'formula': True},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 140, 'title': _('Product Category')},
}
return cols

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 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_variation_xlsx_form" model="ir.ui.view">
<field name="name">stock.variation.xlsx.form</field>
<field name="model">stock.variation.xlsx</field>
<field name="arch" type="xml">
<form string="Stock variation 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="categ_subtotal" />
</group>
<group name="start_end">
<group name="start" string="Start">
<field name="start_date"/>
<field name="standard_price_start_date_type"/>
</group>
<group name="end" string="End">
<field name="end_date_type"/>
<field name="end_date" attrs="{'invisible': [('end_date_type', '!=', 'past')], 'required': [('end_date_type', '=', 'past')]}"/>
<field name="standard_price_end_date_type"/>
</group>
</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_variation_xlsx_action" model="ir.actions.act_window">
<field name="name">Stock Variation XLSX</field>
<field name="res_model">stock.variation.xlsx</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Replace native menu, to avoid user confusion -->
<menuitem id="stock_variation_xlsx_menu" action="stock_variation_xlsx_action" parent="stock.menu_warehouse_report" sequence="1"/>
</odoo>